Merge branch 'main' into fix/onpayload-replacement-completions-bedrock-cursor

This commit is contained in:
ranxianglei
2026-08-18 09:13:09 +08:00
committed by GitHub
70 changed files with 3718 additions and 603 deletions
+5
View File
@@ -5,6 +5,11 @@
### Fixed
- Fixed OpenAI Completions, Amazon Bedrock, and Cursor providers ignoring `onPayload` replacement payloads. The hook now transforms the actual request body sent upstream on these providers, matching the Anthropic/Gemini/OpenAI Responses replacement contract. `devin-agent` still does not fire the hook (its payload is a protobuf object).
## [17.3.7] - 2026-08-17
### Changed
- Send the `omp/<version>` User-Agent on xAI chat (`xai` and `xai-oauth`) unless the request already set its own.
## [17.3.5] - 2026-08-16
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-ai",
"version": "17.3.5",
"version": "17.3.7",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"homepage": "https://omp.sh",
"author": "Can Boluk",
+3 -3
View File
@@ -2698,7 +2698,7 @@ export class AuthStorage {
/**
* Whether a request could resolve a key for this provider, including
* cross-provider env aliases (`xai-oauth` borrowing `XAI_API_KEY`).
* Use this for explicit model preflight (`xai-oauth/grok-4.5`); use
* Use this for explicit model preflight (`xai-oauth/grok-4.6`); use
* {@link hasAuth} for auto-availability so the default picker stays on
* paid `xai` when only `XAI_API_KEY` is set.
*/
@@ -2732,8 +2732,8 @@ export class AuthStorage {
* `getEnvApiKey("xai-oauth")` also accepts `XAI_API_KEY` so an explicit
* `xai-oauth/…` stream can still borrow the paid key. Availability and
* origin must not: otherwise an API-key-only setup marks SuperGrok as
* signed in and `pickDefaultAvailableModel` prefers `xai-oauth/grok-4.5`
* over paid `xai/grok-4.5`.
* signed in and `pickDefaultAvailableModel` prefers `xai-oauth/grok-4.6`
* over paid `xai/grok-4.6`.
*/
#hasDedicatedEnvAuth(provider: string): boolean {
if (provider === "xai-oauth") {
@@ -31,6 +31,7 @@ import {
parseStreamingJsonThrottled,
stringifyJson,
structuredCloneJSON,
USER_AGENT,
} from "@oh-my-pi/pi-utils";
import * as AIError from "../error";
import {
@@ -315,6 +316,10 @@ export function resolveOpenAIRequestSetup(
if (options.defaultBaseUrl !== undefined) {
baseUrl = baseUrl ?? ($env.OPENAI_BASE_URL?.trim() || options.defaultBaseUrl);
}
// Attribute xAI traffic as omp unless a User-Agent is already set.
if (model.provider === "xai" || model.provider === "xai-oauth") {
setHeaderIfAbsent(headers, "User-Agent", USER_AGENT);
}
const requestHeaders = { ...headers };
// A keyless provider (`auth: none` in models.yml) resolves to the `N/A`
// sentinel rather than a real key. Injecting `Authorization: Bearer N/A`
+8 -6
View File
@@ -16,7 +16,9 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build";
// timer IS the unit under test), but never guess durations: the simulated local
// work completes only once the watchdog has demonstrably reached an expired
// deadline and consulted the local-work probe, so the tests stay causal on a
// loaded machine. Budgets are a few milliseconds.
// loaded machine. Budgets are tens of milliseconds — wide enough that a noisy
// virtualized CI runner cannot make a single scheduling hiccup span a full
// idle budget.
function createModel(): Model<"bedrock-converse-stream"> {
return buildModel({
@@ -71,7 +73,7 @@ describe("idle watchdog local-work deferral (issue #4593)", () => {
let idleFired = false;
const items: string[] = [];
for await (const item of iterateWithIdleTimeout(source(), {
idleTimeoutMs: 5,
idleTimeoutMs: 50,
errorMessage: "stalled",
onIdle: () => {
idleFired = true;
@@ -104,7 +106,7 @@ describe("idle watchdog local-work deferral (issue #4593)", () => {
let error: Error | undefined;
try {
for await (const item of iterateWithIdleTimeout(source(), {
idleTimeoutMs: 5,
idleTimeoutMs: 50,
errorMessage: "stalled",
hasPendingLocalWork: () => {
workDone.resolve();
@@ -131,8 +133,8 @@ describe("idle watchdog local-work deferral (issue #4593)", () => {
}
const items: string[] = [];
for await (const item of iterateWithIdleTimeout(source(), {
idleTimeoutMs: 5,
firstItemTimeoutMs: 5,
idleTimeoutMs: 50,
firstItemTimeoutMs: 50,
errorMessage: "stalled",
firstItemErrorMessage: "first event timed out",
hasPendingLocalWork: () => {
@@ -179,7 +181,7 @@ describe("idle watchdog local-work deferral (issue #4593)", () => {
},
});
const stream = streamBedrock(createModel(), baseContext, { streamIdleTimeoutMs: 5 });
const stream = streamBedrock(createModel(), baseContext, { streamIdleTimeoutMs: 50 });
const result = await stream.result();
expect(providerSignal?.aborted).toBe(false);
@@ -0,0 +1,173 @@
import { describe, expect, test } from "bun:test";
import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions";
import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses";
import type { Context, FetchImpl, Model, ModelSpec } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { USER_AGENT } from "@oh-my-pi/pi-utils";
import { resolveOpenAIRequestSetup } from "../src/providers/openai-shared";
const context: Context = {
messages: [{ role: "user", content: "ping", timestamp: 0 }],
};
function createResponsesSse(): Response {
return new Response(
`data: ${JSON.stringify({
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_1", role: "assistant", content: [] },
})}\n\n` +
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}\n\n` +
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "ok" })}\n\n` +
`data: ${JSON.stringify({
type: "response.output_item.done",
output_index: 0,
item: { type: "message", id: "msg_1", role: "assistant", content: [{ type: "output_text", text: "ok" }] },
})}\n\n` +
`data: ${JSON.stringify({
type: "response.completed",
response: {
status: "completed",
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
},
})}\n\n`,
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}
function createChatSse(): Response {
return new Response(
`data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: "ok" }, finish_reason: null }] })}\n\n` +
`data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n` +
`data: [DONE]\n\n`,
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}
function xaiResponsesModel(provider: "xai" | "xai-oauth" = "xai"): Model<"openai-responses"> {
return buildModel({
id: "grok-4.6",
name: "Grok 4.6",
api: "openai-responses",
provider,
baseUrl: "https://api.x.ai/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 500_000,
maxTokens: 8192,
} as ModelSpec<"openai-responses">);
}
function openaiCompletionsModel(): Model<"openai-completions"> {
return buildModel({
id: "gpt-4o-mini",
name: "GPT-4o Mini",
api: "openai-completions",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
} as ModelSpec<"openai-completions">);
}
async function captureStreamHeaders(
run: (fetch: FetchImpl) => AsyncIterable<{ type: string }>,
sse: Response,
): Promise<{ url: string; userAgent: string | null }> {
let url = "";
let userAgent: string | null = null;
const fetchMock: FetchImpl = async (input, init) => {
url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
userAgent = new Headers(init?.headers).get("user-agent");
return sse;
};
for await (const event of run(fetchMock)) {
if (event.type === "done" || event.type === "error") break;
}
return { url, userAgent };
}
describe("resolveOpenAIRequestSetup User-Agent", () => {
test("sets omp User-Agent on xAI when none is provided", () => {
for (const provider of ["xai", "xai-oauth"] as const) {
const setup = resolveOpenAIRequestSetup(
{ provider, id: "grok-4.6", baseUrl: "https://api.x.ai/v1" },
{ apiKey: "sk-test", messages: [] },
);
expect(setup.headers["User-Agent"]).toBe(USER_AGENT);
expect(setup.requestHeaders["User-Agent"]).toBe(USER_AGENT);
}
});
test("does not set User-Agent on other OpenAI-wire providers", () => {
for (const provider of ["openai", "deepseek"] as const) {
const setup = resolveOpenAIRequestSetup(
{ provider, id: "m", baseUrl: "https://api.example/v1" },
{ apiKey: "sk-test", messages: [] },
);
expect(setup.headers["User-Agent"]).toBeUndefined();
expect(setup.requestHeaders["User-Agent"]).toBeUndefined();
}
});
test("does not override a caller-supplied xAI User-Agent", () => {
const setup = resolveOpenAIRequestSetup(
{
provider: "xai",
id: "grok-4.6",
baseUrl: "https://api.x.ai/v1",
headers: { "User-Agent": "custom-xai-client/1.0" },
},
{ apiKey: "sk-test", messages: [] },
);
expect(setup.headers["User-Agent"]).toBe("custom-xai-client/1.0");
});
test("does not override a lowercase xAI user-agent header", () => {
const setup = resolveOpenAIRequestSetup(
{
provider: "xai",
id: "grok-4.6",
baseUrl: "https://api.x.ai/v1",
headers: { "user-agent": "custom-xai-client/1.0" },
},
{ apiKey: "sk-test", messages: [] },
);
expect(setup.headers["user-agent"]).toBe("custom-xai-client/1.0");
expect(setup.headers["User-Agent"]).toBeUndefined();
});
});
describe("xAI stream User-Agent", () => {
test("xAI Responses POST sends omp User-Agent", async () => {
const captured = await captureStreamHeaders(
fetch => streamOpenAIResponses(xaiResponsesModel(), context, { apiKey: "sk-test", fetch }),
createResponsesSse(),
);
expect(captured.url).toBe("https://api.x.ai/v1/responses");
expect(captured.userAgent).toBe(USER_AGENT);
expect(captured.userAgent).toMatch(/^omp\/\d+\.\d+\.\d+$/);
});
test("xAI OAuth Responses POST sends omp User-Agent", async () => {
const captured = await captureStreamHeaders(
fetch => streamOpenAIResponses(xaiResponsesModel("xai-oauth"), context, { apiKey: "sk-test", fetch }),
createResponsesSse(),
);
expect(captured.url).toBe("https://api.x.ai/v1/responses");
expect(captured.userAgent).toBe(USER_AGENT);
});
test("OpenAI Completions POST does not send omp User-Agent", async () => {
const captured = await captureStreamHeaders(
fetch => streamOpenAICompletions(openaiCompletionsModel(), context, { apiKey: "sk-test", fetch }),
createChatSse(),
);
expect(captured.url).toBe("https://api.openai.com/v1/chat/completions");
expect(captured.userAgent).toBeNull();
});
});