Squashed 'third_party/pi/' content from commit d981de122

git-subtree-dir: third_party/pi
git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:40:08 -04:00
commit 67b361f0e9
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,351 @@
import { describe, expect, it } from "vitest";
import { complete, getModel, stream } from "../src/compat.ts";
import type { Api, Context, Model, StreamOptions } from "../src/types.ts";
type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts";
import { hasBedrockCredentials } from "./bedrock-utils.ts";
import { resolveApiKey } from "./oauth.ts";
// Resolve OAuth tokens at module level (async, runs before tests)
const [openaiCodexToken] = await Promise.all([resolveApiKey("openai-codex")]);
async function testAbortSignal<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
const context: Context = {
messages: [
{
role: "user",
content: "What is 15 + 27? Think step by step. Then list 50 first names.",
timestamp: Date.now(),
},
],
systemPrompt: "You are a helpful assistant.",
};
let abortFired = false;
let text = "";
const controller = new AbortController();
const response = await stream(llm, context, { ...options, signal: controller.signal });
for await (const event of response) {
if (abortFired) return;
if (event.type === "text_delta" || event.type === "thinking_delta") {
text += event.delta;
}
if (text.length >= 50) {
controller.abort();
abortFired = true;
}
}
const msg = await response.result();
// If we get here without throwing, the abort didn't work
expect(msg.stopReason).toBe("aborted");
expect(msg.content.length).toBeGreaterThan(0);
context.messages.push(msg);
context.messages.push({
role: "user",
content: "Please continue, but only generate 5 names.",
timestamp: Date.now(),
});
const followUp = await complete(llm, context, options);
expect(followUp.stopReason).toBe("stop");
expect(followUp.content.length).toBeGreaterThan(0);
}
async function testImmediateAbort<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
const controller = new AbortController();
controller.abort();
const context: Context = {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
const response = await complete(llm, context, { ...options, signal: controller.signal });
expect(response.stopReason).toBe("aborted");
}
async function testAbortThenNewMessage<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
// First request: abort immediately before any response content arrives
const controller = new AbortController();
controller.abort();
const context: Context = {
messages: [{ role: "user", content: "Hello, how are you?", timestamp: Date.now() }],
};
const abortedResponse = await complete(llm, context, { ...options, signal: controller.signal });
expect(abortedResponse.stopReason).toBe("aborted");
// The aborted message has empty content since we aborted before anything arrived
expect(abortedResponse.content.length).toBe(0);
// Add the aborted assistant message to context (this is what happens in the real coding agent)
context.messages.push(abortedResponse);
// Second request: send a new message - this should work even with the aborted message in context
context.messages.push({
role: "user",
content: "What is 2 + 2?",
timestamp: Date.now(),
});
const followUp = await complete(llm, context, options);
expect(followUp.stopReason).toBe("stop");
expect(followUp.content.length).toBeGreaterThan(0);
}
describe("AI Providers Abort Tests", () => {
describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider Abort", () => {
const llm = getModel("google", "gemini-2.5-flash");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm, { thinking: { enabled: true } });
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm, { thinking: { enabled: true } });
});
});
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions Provider Abort", () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
void _compat;
const llm: Model<"openai-completions"> = {
...baseModel,
api: "openai-completions",
};
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses Provider Abort", () => {
const llm = getModel("openai", "gpt-5-mini");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider Abort", () => {
const llm = getModel("azure-openai-responses", "gpt-4o-mini");
const azureDeploymentName = resolveAzureDeploymentName(llm.id);
const azureOptions = azureDeploymentName ? { azureDeploymentName } : {};
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm, azureOptions);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm, azureOptions);
});
});
describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("Anthropic Provider Abort", () => {
const llm = getModel("anthropic", "claude-sonnet-4-6");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
});
});
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider Abort", () => {
const llm = getModel("mistral", "devstral-medium-latest");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider Abort", () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm, { reasoningEffort: "high" });
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm, { reasoningEffort: "high" });
});
});
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider Abort", () => {
const llm = getModel("baseten", "zai-org/GLM-5.2");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm, { reasoningEffort: "high" });
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm, { reasoningEffort: "high" });
});
});
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider Abort", () => {
const llm = getModel("minimax", "MiniMax-M2.7");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider Abort", () => {
const llm = getModel("xiaomi", "mimo-v2.5-pro");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)("Xiaomi MiMo Token Plan (CN) Provider Abort", () => {
const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)("Xiaomi MiMo Token Plan (AMS) Provider Abort", () => {
const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)("Xiaomi MiMo Token Plan (SGP) Provider Abort", () => {
const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider Abort", () => {
const llm = getModel("qwen-token-plan", "qwen3.7-max");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Abort", () => {
const llm = getModel("qwen-token-plan-individual", "qwen3.8-max");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Abort", () => {
const llm = getModel("qwen-token-plan-cn", "qwen3.7-max");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Abort", () => {
const llm = getModel("kimi-coding", "kimi-for-coding");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider Abort", () => {
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
});
describe("OpenAI Codex Provider Abort", () => {
it.skipIf(!openaiCodexToken)("should abort mid-stream", { retry: 3 }, async () => {
const llm = getModel("openai-codex", "gpt-5.5");
await testAbortSignal(llm, { apiKey: openaiCodexToken });
});
it.skipIf(!openaiCodexToken)("should handle immediate abort", { retry: 3 }, async () => {
const llm = getModel("openai-codex", "gpt-5.5");
await testImmediateAbort(llm, { apiKey: openaiCodexToken });
});
});
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider Abort", () => {
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm, { reasoning: "medium" });
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm);
});
it("should handle abort then new message", { retry: 3 }, async () => {
await testAbortThenNewMessage(llm);
});
});
});

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { getModels, getProviders } from "../src/compat.ts";
import type { Api, Model } from "../src/types.ts";
const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [
"anthropic/claude-fable-5",
"anthropic/claude-opus-4-8",
"anthropic/claude-opus-5",
"anthropic/claude-sonnet-5",
"cloudflare-ai-gateway/claude-fable-5",
"kimi-coding/kimi-for-coding",
"kimi-coding/k3",
"kimi-coding/kimi-for-coding-highspeed",
"opencode/claude-opus-4-8",
"opencode/claude-opus-5",
"vercel-ai-gateway/anthropic/claude-opus-4.8",
"vercel-ai-gateway/anthropic/claude-opus-5",
"vercel-ai-gateway/anthropic/claude-sonnet-5",
];
function getAllModels(): Model<Api>[] {
return getProviders().flatMap((provider) => getModels(provider) as Model<Api>[]);
}
describe("Anthropic adaptive thinking model metadata", () => {
it("marks built-in Anthropic Messages models that use adaptive thinking", () => {
const flaggedModels = getAllModels()
.filter((model): model is Model<"anthropic-messages"> => model.api === "anthropic-messages")
.filter((model) => model.compat?.forceAdaptiveThinking === true)
.map((model) => `${model.provider}/${model.id}`)
.sort();
expect(flaggedModels).toEqual(expect.arrayContaining([...EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS].sort()));
expect(flaggedModels).toEqual(
flaggedModels.filter((modelId) =>
/(opus[-.](4[-.][678]|5)|sonnet[-.]4[-.]6|sonnet[-.]5|fable[-.]5|kimi-coding\/)/.test(modelId),
),
);
});
});

View File

@@ -0,0 +1,235 @@
import { arch, platform, release } from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../src/env-api-keys.ts";
import { createModels } from "../src/models.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
import type { Context, Model } from "../src/types.ts";
const mockState = vi.hoisted(() => ({
constructorOpts: undefined as Record<string, unknown> | undefined,
createParams: undefined as Record<string, unknown> | undefined,
}));
vi.mock("@anthropic-ai/sdk", () => {
function createSseResponse(): Response {
const body = [
`event: message_start\ndata: ${JSON.stringify({
type: "message_start",
message: {
id: "msg_test",
usage: { input_tokens: 1, output_tokens: 0 },
},
})}\n`,
`event: message_delta\ndata: ${JSON.stringify({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { output_tokens: 1 },
})}\n`,
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n`,
].join("\n");
return new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
class FakeAnthropic {
constructor(opts: Record<string, unknown>) {
mockState.constructorOpts = opts;
}
beta = {
messages: {
create: (params: Record<string, unknown>) => {
mockState.createParams = params;
return {
asResponse: async () => createSseResponse(),
};
},
},
};
}
return { default: FakeAnthropic };
});
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const neverAbortedSignal = new AbortController().signal;
const context: Context = {
systemPrompt: "System prompt.",
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
const anthropicModel: Model<"anthropic-messages"> = {
id: "claude-test",
name: "Claude Test",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 100000,
maxTokens: 4096,
};
const kimiCodingModel: Model<"anthropic-messages"> = {
...anthropicModel,
id: "kimi-for-coding",
name: "Kimi For Coding",
provider: "kimi-coding",
baseUrl: "https://api.kimi.com/coding",
};
afterEach(() => {
mockState.constructorOpts = undefined;
mockState.createParams = undefined;
});
describe("Anthropic auth token env", () => {
it("resolves ANTHROPIC_AUTH_TOKEN as a bearer Authorization header", async () => {
const provider = anthropicProvider();
const auth = await provider.auth.apiKey?.resolve({
ctx: {
env: async (name) =>
({
ANTHROPIC_AUTH_TOKEN: "auth-token",
ANTHROPIC_OAUTH_TOKEN: "oauth-token",
ANTHROPIC_API_KEY: "api-key",
})[name],
fileExists: async () => false,
},
signal: neverAbortedSignal,
});
expect(auth).toEqual({
auth: { headers: { Authorization: "Bearer auth-token" } },
source: ANTHROPIC_AUTH_TOKEN_ENV,
});
});
it("preserves ANTHROPIC_OAUTH_TOKEN as OAuth-shaped API auth", async () => {
const provider = anthropicProvider();
const auth = await provider.auth.apiKey?.resolve({
ctx: {
env: async (name) =>
({
ANTHROPIC_OAUTH_TOKEN: "oauth-token",
ANTHROPIC_API_KEY: "api-key",
})[name],
fileExists: async () => false,
},
signal: neverAbortedSignal,
});
expect(auth).toEqual({
auth: { apiKey: "oauth-token" },
source: ANTHROPIC_OAUTH_TOKEN_ENV,
});
});
it("uses Authorization headers without OAuth-mode request shaping", async () => {
const stream = streamAnthropic(anthropicModel, context, {
headers: { Authorization: "Bearer gateway-token" },
});
await stream.result();
expect(mockState.constructorOpts?.apiKey).toBeNull();
expect(mockState.constructorOpts?.authToken).toBeNull();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string | null>;
expect(headers.Authorization).toBe("Bearer gateway-token");
expect(mockState.createParams?.betas ?? []).not.toContain("oauth-2025-04-20");
expect(mockState.createParams?.system).toEqual([expect.objectContaining({ text: "System prompt." })]);
});
it("threads authContext ANTHROPIC_AUTH_TOKEN through request headers", async () => {
const models = createModels({
authContext: {
env: async (name) => (name === "ANTHROPIC_AUTH_TOKEN" ? "ctx-token" : undefined),
fileExists: async () => false,
},
});
models.setProvider(anthropicProvider());
await models.streamSimple(anthropicModel, context).result();
expect(mockState.constructorOpts?.apiKey).toBeNull();
expect(mockState.constructorOpts?.authToken).toBeNull();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(headers.Authorization).toBe("Bearer ctx-token");
expect(mockState.createParams?.betas ?? []).not.toContain("oauth-2025-04-20");
expect(mockState.createParams?.system).toEqual([expect.objectContaining({ text: "System prompt." })]);
});
it("preserves OAuth request shaping for ANTHROPIC_OAUTH_TOKEN", async () => {
const models = createModels({
authContext: {
env: async (name) => (name === "ANTHROPIC_OAUTH_TOKEN" ? "sk-ant-oat-test" : undefined),
fileExists: async () => false,
},
});
models.setProvider(anthropicProvider());
await models.streamSimple(anthropicModel, context).result();
expect(mockState.constructorOpts?.apiKey).toBeNull();
expect(mockState.constructorOpts?.authToken).toBe("sk-ant-oat-test");
expect(mockState.createParams?.betas).toContain("oauth-2025-04-20");
});
it("lets explicit request headers override ANTHROPIC_AUTH_TOKEN", async () => {
const models = createModels({
authContext: {
env: async (name) => (name === "ANTHROPIC_AUTH_TOKEN" ? "ctx-token" : undefined),
fileExists: async () => false,
},
});
models.setProvider(anthropicProvider());
await models
.streamSimple(anthropicModel, context, { headers: { Authorization: "Bearer explicit-token" } })
.result();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(headers.Authorization).toBe("Bearer explicit-token");
});
});
describe("Anthropic-compatible user agents", () => {
it("uses pi's User-Agent by default for Anthropic Messages requests", async () => {
await streamAnthropic(anthropicModel, context, { apiKey: "anthropic-key" }).result();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(headers["User-Agent"]).toBe(PI_USER_AGENT);
});
it("lets explicit headers override the default Anthropic Messages User-Agent", async () => {
await streamAnthropic(kimiCodingModel, context, {
apiKey: "kimi-key",
headers: { "User-Agent": "custom-client" },
}).result();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(headers["User-Agent"]).toBe("custom-client");
});
it("preserves explicit Anthropic beta header replacement", async () => {
await streamAnthropic(anthropicModel, context, {
apiKey: "anthropic-key",
headers: { "anthropic-beta": "custom-beta" },
}).result();
expect(mockState.createParams?.betas).toEqual(["custom-beta"]);
});
it("preserves explicit Anthropic beta header suppression", async () => {
await streamAnthropic(anthropicModel, context, {
apiKey: "anthropic-key",
headers: { "anthropic-beta": null },
}).result();
expect(mockState.createParams?.betas).toBeUndefined();
});
});

View File

@@ -0,0 +1,86 @@
import type Anthropic from "@anthropic-ai/sdk";
import { describe, expect, it } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { getModel } from "../src/compat.ts";
import type { Context } from "../src/types.ts";
function createSseResponse(events: Array<{ event: string; data: string }>): Response {
const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n");
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
}
function createFakeAnthropicClient(response: Response): Anthropic {
return {
beta: { messages: { create: () => ({ asResponse: async () => response }) } },
} as unknown as Anthropic;
}
function eventsWithCacheCreation(
cacheCreation: Record<string, number> | undefined,
): Array<{ event: string; data: string }> {
const startUsage: Record<string, unknown> = {
input_tokens: 100,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 1_000_000,
};
if (cacheCreation) startUsage.cache_creation = cacheCreation;
return [
{
event: "message_start",
data: JSON.stringify({ type: "message_start", message: { id: "msg_test", usage: startUsage } }),
},
{
event: "content_block_start",
data: JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }),
},
{
event: "content_block_delta",
data: JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hi" } }),
},
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) },
{
event: "message_delta",
data: JSON.stringify({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: {
input_tokens: 100,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 1_000_000,
},
}),
},
{ event: "message_stop", data: JSON.stringify({ type: "message_stop" }) },
];
}
// claude-opus-4-8: input 5, cacheWrite (5m) 6.25 per Mtok. 1h write = 2x input = 10.
const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] };
describe("Anthropic 1h cache write cost", () => {
it("prices the 1h portion at 2x input and the rest at the 5m rate", async () => {
const model = getModel("anthropic", "claude-opus-4-8");
const response = createSseResponse(
eventsWithCacheCreation({ ephemeral_5m_input_tokens: 600_000, ephemeral_1h_input_tokens: 400_000 }),
);
const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result();
expect(result.usage.cacheWrite).toBe(1_000_000);
expect(result.usage.cacheWrite1h).toBe(400_000);
// 600k * 6.25/Mtok + 400k * 10/Mtok = 3.75 + 4.0 = 7.75
expect(result.usage.cost.cacheWrite).toBeCloseTo(7.75, 10);
});
it("falls back to the 5m rate when no breakdown is reported", async () => {
const model = getModel("anthropic", "claude-opus-4-8");
const response = createSseResponse(eventsWithCacheCreation(undefined));
const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result();
expect(result.usage.cacheWrite).toBe(1_000_000);
expect(result.usage.cacheWrite1h ?? 0).toBe(0);
// 1M * 6.25/Mtok = 6.25
expect(result.usage.cost.cacheWrite).toBeCloseTo(6.25, 10);
});
});

View File

@@ -0,0 +1,166 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import type { Context, Model, Tool } from "../src/types.ts";
interface CapturedRequest {
headers: IncomingMessage["headers"];
body: Record<string, unknown>;
}
function createModel(baseUrl: string, compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> {
return {
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
api: "anthropic-messages",
provider: "test-anthropic",
baseUrl,
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
compat: { forceAdaptiveThinking: true, ...compat },
};
}
const tool: Tool = {
name: "lookup",
description: "Look up a value",
parameters: Type.Object({ value: Type.String() }),
};
const schemaCompatibilityTool: Tool = {
...tool,
parameters: Type.Object({ value: Type.String() }, { additionalProperties: false, title: "LookupInput" }),
};
const strictTool: Tool = {
...tool,
parameters: Type.Object(
{ value: Type.String(), optional: Type.Optional(Type.Number()) },
{ title: "StrictLookupInput" },
),
constrainedSampling: { type: "json_schema", strict: "prefer" },
};
function createContext(tools: Tool[] = [tool]): Context {
return {
messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }],
...(tools.length > 0 ? { tools } : {}),
};
}
async function readRequestBody(request: IncomingMessage): Promise<Record<string, unknown>> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
}
function writeEmptySseResponse(response: ServerResponse): void {
response.writeHead(200, { "content-type": "text/event-stream" });
response.end();
}
async function captureAnthropicRequest(
compat: Model<"anthropic-messages">["compat"],
context: Context,
): Promise<CapturedRequest> {
let capturedRequest: CapturedRequest | undefined;
const server = createServer(async (request, response) => {
capturedRequest = {
headers: request.headers,
body: await readRequestBody(request),
};
writeEmptySseResponse(response);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
try {
const stream = streamAnthropic(createModel(`http://127.0.0.1:${address.port}`, compat), context, {
apiKey: "test-key",
cacheRetention: "none",
});
for await (const event of stream) {
if (event.type === "done" || event.type === "error") break;
}
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
if (!capturedRequest) {
throw new Error("Anthropic request was not captured");
}
return capturedRequest;
}
function getFirstTool(body: Record<string, unknown>): Record<string, unknown> {
const tools = body.tools;
if (!Array.isArray(tools) || typeof tools[0] !== "object" || tools[0] === null) {
throw new Error("Expected first tool in request body");
}
return tools[0] as Record<string, unknown>;
}
function getFirstToolInputSchema(body: Record<string, unknown>): Record<string, unknown> {
const inputSchema = getFirstTool(body).input_schema;
if (typeof inputSchema !== "object" || inputSchema === null || Array.isArray(inputSchema)) {
throw new Error("Expected first tool input schema in request body");
}
return inputSchema as Record<string, unknown>;
}
describe("Anthropic eager tool input streaming compatibility", () => {
it("sends per-tool eager_input_streaming by default", async () => {
const request = await captureAnthropicRequest(undefined, createContext());
expect(getFirstTool(request.body).eager_input_streaming).toBe(true);
expect(request.headers["anthropic-beta"]).toBeUndefined();
});
it("uses the legacy fine-grained tool streaming beta when eager tool input streaming is disabled", async () => {
const request = await captureAnthropicRequest({ supportsEagerToolInputStreaming: false }, createContext());
expect(getFirstTool(request.body).eager_input_streaming).toBeUndefined();
expect(request.headers["anthropic-beta"]).toBe("fine-grained-tool-streaming-2025-05-14");
});
it("does not send the legacy fine-grained tool streaming beta when there are no tools", async () => {
const request = await captureAnthropicRequest({ supportsEagerToolInputStreaming: false }, createContext([]));
expect(request.body.tools).toBeUndefined();
expect(request.headers["anthropic-beta"]).toBeUndefined();
});
it("only sends the full input schema for strict JSON-schema tools", async () => {
const legacyRequest = await captureAnthropicRequest(
{ supportsStrictTools: true },
createContext([schemaCompatibilityTool]),
);
const parameters = schemaCompatibilityTool.parameters as { properties?: unknown; required?: unknown };
expect(getFirstToolInputSchema(legacyRequest.body)).toEqual({
type: "object",
properties: parameters.properties,
required: parameters.required,
});
const strictRequest = await captureAnthropicRequest({ supportsStrictTools: true }, createContext([strictTool]));
expect(getFirstTool(strictRequest.body).strict).toBe(true);
expect(getFirstToolInputSchema(strictRequest.body)).toMatchObject({
additionalProperties: false,
required: ["value", "optional"],
properties: { optional: { anyOf: [{ type: "number" }, { type: "null" }] } },
title: "StrictLookupInput",
});
});
});

View File

@@ -0,0 +1,155 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { type BuiltinProvider, complete, getModels, getProviders } from "../src/compat.ts";
import { getEnvApiKey } from "../src/env-api-keys.ts";
import type { Api, KnownProvider, Model, ProviderStreamOptions, Tool } from "../src/types.ts";
import { resolveApiKey } from "./oauth.ts";
const githubCopilotToken = await resolveApiKey("github-copilot");
const echoToolSchema = Type.Object({
value: Type.String({ description: "The value to echo" }),
});
const echoTool: Tool<typeof echoToolSchema> = {
name: "echo_value",
description: "Echo a string value",
parameters: echoToolSchema,
};
interface AnthropicEagerE2ECase {
name: string;
provider: BuiltinProvider;
model: Model<"anthropic-messages">;
apiKey: string | undefined;
}
function getE2EApiKey(provider: KnownProvider): string | undefined {
if (provider === "github-copilot") {
return githubCopilotToken;
}
return getEnvApiKey(provider);
}
function getAnthropicMessagesModels(provider: BuiltinProvider): Model<"anthropic-messages">[] {
const models = getModels(provider) as Model<Api>[];
return models.filter((model) => model.api === "anthropic-messages") as Model<"anthropic-messages">[];
}
const anthropicMessagesCases: AnthropicEagerE2ECase[] = getProviders().flatMap((provider) =>
getAnthropicMessagesModels(provider).map((model) => ({
name: `${provider}/${model.id}`,
provider,
model,
apiKey: getE2EApiKey(provider),
})),
);
function getProbePriority(model: Model<"anthropic-messages">): number {
const modelId = model.id.toLowerCase();
const cost = model.cost.input + model.cost.output;
let priority = cost;
// Prefer current Claude 4 Haiku routes when present: they are cheap and avoid
// stale Claude 3.x aliases that can remain in catalogs after upstream removal.
if (modelId.includes("haiku") && (modelId.includes("4-5") || modelId.includes("4.5"))) {
priority -= 1000;
} else if (modelId.includes("sonnet") && (modelId.includes("4-") || modelId.includes("4."))) {
priority -= 750;
} else if (modelId.includes("claude") && (modelId.includes("4-") || modelId.includes("4."))) {
priority -= 500;
}
return priority;
}
function selectOneCasePerProvider(cases: AnthropicEagerE2ECase[]): AnthropicEagerE2ECase[] {
const byProvider = new Map<BuiltinProvider, AnthropicEagerE2ECase[]>();
for (const testCase of cases) {
const providerCases = byProvider.get(testCase.provider) ?? [];
providerCases.push(testCase);
byProvider.set(testCase.provider, providerCases);
}
return Array.from(byProvider.values()).map(
(providerCases) =>
providerCases.sort(
(a, b) => getProbePriority(a.model) - getProbePriority(b.model) || a.model.id.localeCompare(b.model.id),
)[0],
);
}
const generatedCompatCases = selectOneCasePerProvider(anthropicMessagesCases);
const forcedEagerProbeCases = selectOneCasePerProvider(
anthropicMessagesCases.filter((testCase) => testCase.model.compat?.supportsEagerToolInputStreaming !== false),
);
function withEagerToolInputStreaming(model: Model<"anthropic-messages">): Model<"anthropic-messages"> {
return {
...model,
compat: {
...model.compat,
supportsEagerToolInputStreaming: true,
},
};
}
async function expectToolEnabledRequestAccepted(
model: Model<"anthropic-messages">,
apiKey: string | undefined,
): Promise<void> {
const options: ProviderStreamOptions = {
apiKey,
maxTokens: 128,
thinkingEnabled: false,
};
const response = await complete(
model,
{
systemPrompt: "You are a concise assistant. Use tools when useful.",
messages: [
{
role: "user",
content: "Call echo_value with value set to eager-input-streaming-compat.",
timestamp: Date.now(),
},
],
tools: [echoTool],
},
options,
);
expect(response.errorMessage, response.errorMessage).toBeFalsy();
expect(response.stopReason, response.errorMessage).not.toBe("error");
}
describe("Anthropic Messages eager tool input streaming E2E", () => {
it("covers every generated anthropic-messages model", () => {
const expectedModels = getProviders().flatMap((provider) =>
getAnthropicMessagesModels(provider).map((model) => `${provider}/${model.id}`),
);
expect(anthropicMessagesCases.map((testCase) => testCase.name).sort()).toEqual(expectedModels.sort());
});
describe("generated compatibility settings", () => {
for (const testCase of generatedCompatCases) {
it.skipIf(!testCase.apiKey)(`${testCase.name} accepts configured tool streaming`, { retry: 2 }, async () => {
await expectToolEnabledRequestAccepted(testCase.model, testCase.apiKey);
});
}
});
describe("forced eager_input_streaming probe", () => {
for (const testCase of forcedEagerProbeCases) {
const model = withEagerToolInputStreaming(testCase.model);
it.skipIf(!testCase.apiKey)(
`${testCase.name} accepts forced eager_input_streaming`,
{ retry: 2 },
async () => {
await expectToolEnabledRequestAccepted(model, testCase.apiKey);
},
);
}
});
});

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import type { AssistantMessage, Context, Model } from "../src/types.ts";
interface AnthropicPayload {
messages?: Array<{
role: string;
content: Array<{ type: string; text?: string; thinking?: string; signature?: string }>;
}>;
}
class PayloadCaptured extends Error {
constructor() {
super("payload captured");
this.name = "PayloadCaptured";
}
}
function makeModel(allowEmptySignature?: boolean): Model<"anthropic-messages"> {
return {
id: "mimo-v2.5-pro",
name: "MiMo-V2.5-Pro",
api: "anthropic-messages",
provider: "xiaomi-token-plan-ams",
baseUrl: "http://127.0.0.1:9/anthropic",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 1024,
...(allowEmptySignature === undefined ? {} : { compat: { allowEmptySignature } }),
};
}
function makeContext(
thinkingSignature: string,
thinking = "internal reasoning",
provider = "xiaomi-token-plan-ams",
model = "mimo-v2.5-pro",
): Context {
const assistant: AssistantMessage = {
role: "assistant",
content: [{ type: "thinking", thinking, thinkingSignature }],
provider,
api: "anthropic-messages",
model,
timestamp: Date.now(),
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
};
return {
messages: [
{ role: "user", content: "first", timestamp: Date.now() },
assistant,
{ role: "user", content: "second", timestamp: Date.now() },
],
};
}
async function capturePayload(model: Model<"anthropic-messages">, context: Context): Promise<AnthropicPayload> {
let capturedPayload: AnthropicPayload | undefined;
const stream = streamSimple(model, context, {
apiKey: "fake-key",
onPayload: (payload) => {
capturedPayload = payload as AnthropicPayload;
throw new PayloadCaptured();
},
});
await stream.result();
if (!capturedPayload) throw new Error("Expected payload capture before request");
return capturedPayload;
}
describe("Anthropic empty thinking signature compat", () => {
it("converts empty-signature thinking to text by default", async () => {
const payload = await capturePayload(makeModel(), makeContext(""));
const assistant = payload.messages?.find((message) => message.role === "assistant");
expect(assistant?.content).toEqual([{ type: "text", text: "internal reasoning" }]);
});
it("preserves empty thinking text when the signature is present", async () => {
const payload = await capturePayload(makeModel(), makeContext("signed-thinking", ""));
const assistant = payload.messages?.find((message) => message.role === "assistant");
expect(assistant?.content).toEqual([{ type: "thinking", thinking: "", signature: "signed-thinking" }]);
});
it("preserves empty-signature thinking when allowEmptySignature is enabled", async () => {
const payload = await capturePayload(makeModel(true), makeContext(" "));
const assistant = payload.messages?.find((message) => message.role === "assistant");
expect(assistant?.content).toEqual([{ type: "thinking", thinking: "internal reasoning", signature: "" }]);
});
it.each(["k3"] as const)("allows empty signatures for Kimi Coding %s", async (modelId) => {
const model = getModel("kimi-coding", modelId);
expect(model.compat?.allowEmptySignature).toBe(true);
const payload = await capturePayload(model, makeContext(" ", "internal reasoning", "kimi-coding", modelId));
const assistant = payload.messages?.find((message) => message.role === "assistant");
expect(assistant?.content).toEqual([{ type: "thinking", thinking: "internal reasoning", signature: "" }]);
});
});

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import type { Context, Model, SimpleStreamOptions } from "../src/types.ts";
interface AnthropicThinkingPayload {
thinking?: { type: string; budget_tokens?: number; display?: string };
output_config?: { effort?: string };
}
class PayloadCaptured extends Error {
constructor() {
super("payload captured");
this.name = "PayloadCaptured";
}
}
function makeContext(): Context {
return {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
}
function makeCustomModel(compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> {
return {
// Id intentionally does not match any built-in adaptive substring. This
// mirrors corporate proxy schemes such as `anthropic--claude-opus-latest`.
id: "vendor--claude-opus-latest",
name: "Vendor Proxy Opus Latest",
api: "anthropic-messages",
provider: "vendor-proxy",
baseUrl: "http://127.0.0.1:9",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
compat,
};
}
async function capturePayload(
model: Model<"anthropic-messages">,
options?: SimpleStreamOptions,
): Promise<AnthropicThinkingPayload> {
let capturedPayload: AnthropicThinkingPayload | undefined;
const payloadCaptureModel: Model<"anthropic-messages"> = {
...model,
baseUrl: "http://127.0.0.1:9",
};
const s = streamSimple(payloadCaptureModel, makeContext(), {
...options,
apiKey: "fake-key",
onPayload: (payload) => {
capturedPayload = payload as AnthropicThinkingPayload;
throw new PayloadCaptured();
},
});
await s.result();
if (!capturedPayload) {
throw new Error("Expected payload to be captured before request failure");
}
return capturedPayload;
}
describe("Anthropic forceAdaptiveThinking compat override", () => {
it("sends legacy thinking payload for custom model ids by default", async () => {
const payload = await capturePayload(makeCustomModel(), { reasoning: "medium" });
expect(payload.thinking?.type).toBe("enabled");
expect(payload.output_config).toBeUndefined();
});
it("sends adaptive thinking payload when compat.forceAdaptiveThinking is true", async () => {
const payload = await capturePayload(makeCustomModel({ forceAdaptiveThinking: true }), { reasoning: "medium" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "medium" });
});
it("uses adaptive thinking with native xhigh effort for Claude Fable 5", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-fable-5"), { reasoning: "xhigh" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "xhigh" });
});
it.each([
["kimi-for-coding", "medium", "medium"],
["k3", "max", "max"],
["kimi-for-coding-highspeed", "medium", "medium"],
] as const)(
"uses adaptive thinking effort without a token budget for Kimi Coding %s",
async (modelId, reasoning, effort) => {
const payload = await capturePayload(getModel("kimi-coding", modelId), { reasoning });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort });
},
);
it("allows built-in adaptive models to opt out with compat.forceAdaptiveThinking false", async () => {
const model: Model<"anthropic-messages"> = {
...getModel("anthropic", "claude-opus-4-8"),
compat: { forceAdaptiveThinking: false },
};
const payload = await capturePayload(model, { reasoning: "medium" });
expect(payload.thinking?.type).toBe("enabled");
expect(payload.output_config).toBeUndefined();
});
it("preserves thinking.type=disabled when reasoning is off regardless of override", async () => {
const payload = await capturePayload(makeCustomModel({ forceAdaptiveThinking: true }));
expect(payload.thinking).toEqual({ type: "disabled" });
expect(payload.output_config).toBeUndefined();
});
});

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { type BuiltinProvider, complete, getModels, getProviders } from "../src/compat.ts";
import { getEnvApiKey } from "../src/env-api-keys.ts";
import type { Api, KnownProvider, Model, ProviderStreamOptions } from "../src/types.ts";
import { resolveApiKey } from "./oauth.ts";
const githubCopilotToken = await resolveApiKey("github-copilot");
interface AnthropicLongCacheRetentionE2ECase {
name: string;
provider: BuiltinProvider;
model: Model<"anthropic-messages">;
apiKey: string | undefined;
}
function getE2EApiKey(provider: KnownProvider): string | undefined {
if (provider === "github-copilot") {
return githubCopilotToken;
}
return getEnvApiKey(provider);
}
function getAnthropicMessagesModels(provider: BuiltinProvider): Model<"anthropic-messages">[] {
const models = getModels(provider) as Model<Api>[];
return models.filter((model) => model.api === "anthropic-messages") as Model<"anthropic-messages">[];
}
const anthropicMessagesCases: AnthropicLongCacheRetentionE2ECase[] = getProviders().flatMap((provider) =>
getAnthropicMessagesModels(provider).map((model) => ({
name: `${provider}/${model.id}`,
provider,
model,
apiKey: getE2EApiKey(provider),
})),
);
function getProbePriority(model: Model<"anthropic-messages">): number {
const modelId = model.id.toLowerCase();
const cost = model.cost.input + model.cost.output;
let priority = cost;
if (modelId.includes("haiku") && (modelId.includes("4-5") || modelId.includes("4.5"))) {
priority -= 1000;
} else if (modelId.includes("sonnet") && (modelId.includes("4-") || modelId.includes("4."))) {
priority -= 750;
} else if (modelId.includes("claude") && (modelId.includes("4-") || modelId.includes("4."))) {
priority -= 500;
}
return priority;
}
function selectOneCasePerProvider(cases: AnthropicLongCacheRetentionE2ECase[]): AnthropicLongCacheRetentionE2ECase[] {
const byProvider = new Map<BuiltinProvider, AnthropicLongCacheRetentionE2ECase[]>();
for (const testCase of cases) {
const providerCases = byProvider.get(testCase.provider) ?? [];
providerCases.push(testCase);
byProvider.set(testCase.provider, providerCases);
}
return Array.from(byProvider.values()).map(
(providerCases) =>
providerCases.sort(
(a, b) => getProbePriority(a.model) - getProbePriority(b.model) || a.model.id.localeCompare(b.model.id),
)[0],
);
}
const probeCases = selectOneCasePerProvider(anthropicMessagesCases);
function withLongCacheRetention(model: Model<"anthropic-messages">): Model<"anthropic-messages"> {
return {
...model,
compat: {
...model.compat,
supportsLongCacheRetention: true,
},
};
}
async function expectLongCacheRetentionAccepted(
model: Model<"anthropic-messages">,
apiKey: string | undefined,
): Promise<void> {
const options: ProviderStreamOptions = {
apiKey,
cacheRetention: "long",
maxTokens: 128,
thinkingEnabled: false,
};
const response = await complete(
model,
{
systemPrompt: "You are a concise assistant.",
messages: [
{
role: "user",
content: "Reply with exactly: long cache retention accepted",
timestamp: Date.now(),
},
],
},
options,
);
expect(response.errorMessage, response.errorMessage).toBeFalsy();
expect(response.stopReason, response.errorMessage).not.toBe("error");
}
describe("Anthropic Messages long cache retention E2E", () => {
it("covers every generated anthropic-messages model", () => {
const expectedModels = getProviders().flatMap((provider) =>
getAnthropicMessagesModels(provider).map((model) => `${provider}/${model.id}`),
);
expect(anthropicMessagesCases.map((testCase) => testCase.name).sort()).toEqual(expectedModels.sort());
});
describe("forced long cache retention probe", () => {
for (const testCase of probeCases) {
const model = withLongCacheRetention(testCase.model);
it.skipIf(!testCase.apiKey)(`${testCase.name} accepts long cache retention`, { retry: 2 }, async () => {
await expectLongCacheRetentionAccepted(model, testCase.apiKey);
});
}
});
});

View File

@@ -0,0 +1,212 @@
import { describe, expect, it } from "vitest";
import { stream } from "../src/api/anthropic-messages.ts";
import { getModel } from "../src/compat.ts";
import type { AssistantMessage, Context, Model } from "../src/types.ts";
interface WireMessage {
role: string;
content: unknown;
output_config?: { effort?: string };
}
interface CapturedPayload {
messages: WireMessage[];
thinking?: {
type: string;
display?: string;
block_binding?: { prefix_mismatch_behavior?: string };
};
output_config?: { effort?: string };
fallbacks?: Array<{ model: string }>;
}
function managedModel(provider = "anthropic"): Model<"anthropic-messages"> {
return {
id: "claude-fable-5-1",
name: "Claude Fable 5.1",
api: "anthropic-messages",
provider,
baseUrl: "http://127.0.0.1:9",
reasoning: true,
thinkingLevelMap: { off: null, minimal: "low", low: "low", medium: "medium", high: "high", max: "max" },
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
compat: { forceAdaptiveThinking: true, supportsMidConvoEffort: true },
};
}
function assistant(model: Model<"anthropic-messages">, level?: string): AssistantMessage {
return {
role: "assistant",
content: [
{ type: "thinking", thinking: "reasoning", thinkingSignature: "signature" },
{ type: "text", text: "answer" },
],
api: "anthropic-messages",
provider: model.provider,
model: model.id,
...(level === undefined ? {} : { providerThinkingLevel: level }),
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1,
};
}
async function capture(
model: Model<"anthropic-messages">,
context: Context,
effort?: "low" | "medium" | "high" | "xhigh" | "max",
): Promise<{ payload: CapturedPayload; message: AssistantMessage }> {
let payload: CapturedPayload | undefined;
const result = stream(model, context, {
apiKey: "test-key",
cacheRetention: "none",
thinkingEnabled: true,
effort,
onPayload: (value) => {
payload = value as CapturedPayload;
throw new Error("payload captured");
},
});
const message = await result.result();
if (!payload) throw new Error("Expected payload capture");
return { payload, message };
}
const user = (text: string, timestamp: number) => ({ role: "user" as const, content: text, timestamp });
function effortMessages(payload: CapturedPayload): WireMessage[] {
return payload.messages.filter((message) => message.role === "system");
}
describe("Anthropic mid-conversation effort", () => {
it("reconstructs an exact historical marker prefix and appends the current marker", async () => {
const model = managedModel();
const first = await capture(model, { messages: [user("one", 1)] }, "low");
const second = await capture(
model,
{ messages: [user("one", 1), assistant(model, "low"), user("two", 2)] },
"high",
);
expect(first.payload.messages).toEqual([
{ role: "user", content: "one" },
{ role: "system", content: [], output_config: { effort: "low" } },
]);
expect(second.payload.messages.slice(0, first.payload.messages.length)).toEqual(first.payload.messages);
expect(second.payload.messages.at(-1)).toEqual({
role: "system",
content: [],
output_config: { effort: "high" },
});
expect(first.payload.output_config).toEqual({ effort: "high" });
expect(second.payload.output_config).toEqual({ effort: "high" });
expect(second.payload.thinking).toEqual({
type: "adaptive",
display: "summarized",
block_binding: { prefix_mismatch_behavior: "drop_block" },
});
expect(first.message.providerThinkingLevel).toBe("low");
});
it.each(["low", "medium", "high", "xhigh", "max"] as const)("preserves native effort %s", async (effort) => {
const model = managedModel();
const { payload, message } = await capture(model, { messages: [user("one", 1)] }, effort);
expect(effortMessages(payload)).toEqual([{ role: "system", content: [], output_config: { effort } }]);
expect(message.providerThinkingLevel).toBe(effort);
});
it("defaults omitted effort to high and still enables drop_block", async () => {
const { payload, message } = await capture(managedModel(), { messages: [user("one", 1)] });
expect(payload.messages.at(-1)).toEqual({
role: "system",
content: [],
output_config: { effort: "high" },
});
expect(payload.thinking?.block_binding?.prefix_mismatch_behavior).toBe("drop_block");
expect(message.providerThinkingLevel).toBe("high");
});
it("does not invent markers for legacy or other-provider assistants", async () => {
const model = managedModel();
const legacy = assistant(model);
const otherProvider = { ...assistant(model, "low"), provider: "other-provider" };
const { payload } = await capture(
model,
{ messages: [user("one", 1), legacy, user("two", 2), otherProvider, user("three", 3)] },
"medium",
);
expect(effortMessages(payload)).toEqual([{ role: "system", content: [], output_config: { effort: "medium" } }]);
});
it("leaves unsupported models on top-level effort", async () => {
const model = managedModel();
model.compat = { forceAdaptiveThinking: true };
const { payload, message } = await capture(model, { messages: [user("one", 1)] }, "low");
expect(payload.messages).toEqual([{ role: "user", content: "one" }]);
expect(payload.output_config).toEqual({ effort: "low" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(message.providerThinkingLevel).toBeUndefined();
});
it("sends the effort and binding beta headers", async () => {
let betaHeader: string | null = null;
const events = [
{
type: "message_start",
message: {
id: "msg_test",
model: "claude-fable-5-1",
usage: { input_tokens: 1, output_tokens: 0 },
},
},
{
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { input_tokens: 1, output_tokens: 1 },
},
{ type: "message_stop" },
];
const body = events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join("");
const fetchImpl: typeof fetch = async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init);
betaHeader = request.headers.get("anthropic-beta");
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
};
const result = await stream(
managedModel(),
{ messages: [user("one", 1)] },
{
apiKey: "test-key",
cacheRetention: "none",
fetch: fetchImpl,
},
).result();
expect(result.stopReason).toBe("stop");
expect(betaHeader).toContain("mid-conversation-output-config-2026-07-01");
expect(betaHeader).toContain("thinking-binding-controls-2026-08-01");
});
it("generates exact model and transport gates", () => {
const direct = getModel("anthropic", "claude-fable-5-1");
const openRouter = getModel("openrouter", "anthropic/claude-fable-5.1");
const unsupported = getModel("anthropic", "claude-opus-4-8");
expect(direct.compat?.supportsMidConvoEffort).toBe(true);
expect(direct.thinkingLevelMap?.off).toBeNull();
expect(openRouter.api).toBe("anthropic-messages");
expect(openRouter.baseUrl).toBe("https://openrouter.ai/api");
expect(openRouter.compat?.supportsMidConvoEffort).toBe(true);
expect(unsupported.compat?.supportsMidConvoEffort).toBeUndefined();
expect(getModel("anthropic", "claude-opus-5").compat?.allowedFallbackModels).toBeUndefined();
});
});

View File

@@ -0,0 +1,144 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts";
const neverAbortedSignal = new AbortController().signal;
function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
},
});
}
function getUrl(input: unknown): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
if (input instanceof Request) {
return input.url;
}
throw new Error(`Unsupported fetch input: ${String(input)}`);
}
function getJsonBody(init?: RequestInit): Record<string, string> {
if (typeof init?.body !== "string") {
throw new Error(`Expected string request body, got ${typeof init?.body}`);
}
return JSON.parse(init.body) as Record<string, string>;
}
describe.sequential("Anthropic OAuth", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("keeps the localhost redirect_uri for manual callback login", async () => {
let authUrl = "";
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
expect(init?.method).toBe("POST");
const body = getJsonBody(init);
expect(body.grant_type).toBe("authorization_code");
expect(body.code).toBe("manual-code");
expect(body.redirect_uri).toBe("http://localhost:53692/callback");
return jsonResponse({
access_token: "access-token",
refresh_token: "refresh-token",
expires_in: 3600,
});
});
vi.stubGlobal("fetch", fetchMock);
const credentials = await anthropicOAuth.login({
signal: neverAbortedSignal,
notify: (event) => {
if (event.type === "auth_url") authUrl = event.url;
},
prompt: async (prompt) => {
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
const url = new URL(authUrl);
const state = url.searchParams.get("state");
const redirectUri = url.searchParams.get("redirect_uri");
if (!state || !redirectUri) throw new Error("Missing OAuth state or redirect_uri in auth URL");
return `${redirectUri}?code=manual-code&state=${state}`;
},
});
expect(credentials.access).toBe("access-token");
expect(credentials.refresh).toBe("refresh-token");
expect(fetchMock).toHaveBeenCalledOnce();
});
it("omits scope from refresh token requests", async () => {
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
expect(init?.method).toBe("POST");
const body = getJsonBody(init);
expect(body.grant_type).toBe("refresh_token");
expect(body.client_id).toBeTruthy();
expect(body.refresh_token).toBe("refresh-token");
expect(body).not.toHaveProperty("scope");
return jsonResponse({
access_token: "new-access-token",
refresh_token: "new-refresh-token",
expires_in: 3600,
});
});
vi.stubGlobal("fetch", fetchMock);
const credentials = await anthropicOAuth.refresh(
{
type: "oauth",
access: "old-access-token",
refresh: "refresh-token",
expires: 0,
},
neverAbortedSignal,
);
expect(credentials.access).toBe("new-access-token");
expect(credentials.refresh).toBe("new-refresh-token");
expect(fetchMock).toHaveBeenCalledOnce();
});
it("anthropicOAuth.login resolves through the manual_code prompt and aborts it after settling", async () => {
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
const url = typeof input === "string" ? input : String(input);
if (url.includes("/oauth/token")) {
return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const events: AuthEvent[] = [];
const prompts: AuthPrompt[] = [];
let manualSignal: AbortSignal | undefined;
const credential = await anthropicOAuth.login({
signal: neverAbortedSignal,
notify: (event) => events.push(event),
prompt: async (prompt) => {
prompts.push(prompt);
if (prompt.type === "manual_code") {
manualSignal = prompt.signal;
return "the-code";
}
throw new Error(`Unexpected prompt: ${prompt.type}`);
},
});
expect(credential.type).toBe("oauth");
expect(credential.access).toBe("access");
expect(events.some((e) => e.type === "auth_url")).toBe(true);
expect(prompts.some((p) => p.type === "manual_code")).toBe(true);
// the prompt's signal is aborted once login settles, so UIs can dismiss it
expect(manualSignal?.aborted).toBe(true);
});
});

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import type { Context } from "../src/types.ts";
interface AnthropicThinkingPayload {
thinking?: { type: string };
output_config?: { effort?: string };
}
function makeContext(): Context {
return {
systemPrompt: "You are a precise assistant. Follow the user's instructions exactly.",
messages: [
{
role: "user",
content:
"Compute 48291 * 7317 and 90844 - 17729, add the results, and determine whether the sum is divisible by 11. Reply with exactly this format and nothing else: sum=<sum>; divisibleBy11=<yes|no>",
timestamp: Date.now(),
},
],
};
}
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.8 smoke", () => {
it("streams Claude Opus 4.8 with reasoning enabled", { retry: 2, timeout: 30000 }, async () => {
const model = getModel("anthropic", "claude-opus-4-8");
let capturedPayload: AnthropicThinkingPayload | undefined;
const s = streamSimple(model, makeContext(), {
reasoning: "high",
maxTokens: 1024,
onPayload: (payload) => {
capturedPayload = payload as AnthropicThinkingPayload;
return payload;
},
});
let sawThinking = false;
for await (const event of s) {
if (event.type === "thinking_start" || event.type === "thinking_delta" || event.type === "thinking_end") {
sawThinking = true;
}
}
const response = await s.result();
expect(response.stopReason, response.errorMessage).toBe("stop");
expect(response.errorMessage).toBeFalsy();
expect(capturedPayload?.thinking).toEqual({ type: "adaptive" });
expect(capturedPayload?.output_config).toEqual({ effort: "high" });
expect(sawThinking).toBe(true);
const thinkingBlock = response.content.find((block) => block.type === "thinking");
expect(thinkingBlock?.type).toBe("thinking");
if (!thinkingBlock || thinkingBlock.type !== "thinking") {
throw new Error("Expected thinking block from Claude Opus 4.8");
}
expect(typeof thinkingBlock.thinkingSignature).toBe("string");
const thinkingSignature = thinkingBlock.thinkingSignature;
if (!thinkingSignature) {
throw new Error("Expected thinking signature from Claude Opus 4.8");
}
expect(thinkingSignature.length).toBeGreaterThan(0);
const text = response.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("")
.trim();
expect(text).toBe("sum=353418362; divisibleBy11=yes");
});
});

View File

@@ -0,0 +1,585 @@
import type Anthropic from "@anthropic-ai/sdk";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { getModel } from "../src/compat.ts";
import type { Context, ToolCall } from "../src/types.ts";
function createSseResponse(events: Array<{ event: string; data: string }>): Response {
const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n");
return new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
const minimalAnthropicEvents = [
{
event: "message_start",
data: JSON.stringify({
type: "message_start",
message: {
id: "msg_test",
usage: {
input_tokens: 12,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
}),
},
{
event: "content_block_start",
data: JSON.stringify({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
}),
},
{
event: "content_block_delta",
data: JSON.stringify({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello" },
}),
},
{
event: "content_block_stop",
data: JSON.stringify({ type: "content_block_stop", index: 0 }),
},
{
event: "message_delta",
data: JSON.stringify({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: {
input_tokens: 12,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
}),
},
{
event: "message_stop",
data: JSON.stringify({ type: "message_stop" }),
},
];
function createFakeAnthropicClient(response: Response): Anthropic {
return {
beta: {
messages: {
create: () => ({
asResponse: async () => response,
}),
},
},
} as unknown as Anthropic;
}
describe("Anthropic raw SSE parsing", () => {
it("fails safely when Anthropic falls back after output begins", async () => {
const model = getModel("anthropic", "claude-opus-5");
const response = createSseResponse([
{
event: "message_start",
data: JSON.stringify({
type: "message_start",
message: {
id: "msg_fallback",
model: "claude-opus-5",
usage: { input_tokens: 1, output_tokens: 0 },
},
}),
},
{
event: "content_block_start",
data: JSON.stringify({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "partial" },
}),
},
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) },
{
event: "content_block_start",
data: JSON.stringify({
type: "content_block_start",
index: 1,
content_block: {
type: "fallback",
from: { model: "claude-opus-5" },
to: { model: "claude-opus-4-8" },
},
}),
},
]);
const result = await streamAnthropic(
model,
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
{ client: createFakeAnthropicClient(response) },
).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("unsupported mid-output model fallback");
});
it("forces streaming after an onPayload replacement", async () => {
let streaming: boolean | undefined;
const client = {
beta: {
messages: {
create: (params: { stream?: boolean }) => {
streaming = params.stream;
return { asResponse: async () => createSseResponse(minimalAnthropicEvents) };
},
},
},
} as unknown as Anthropic;
await streamAnthropic(
getModel("anthropic", "claude-fable-5-1"),
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
{
client,
onPayload: (payload) => ({ ...(payload as Record<string, unknown>), stream: false }),
},
).result();
expect(streaming).toBe(true);
});
it("omits the interleaved-thinking beta when thinking is disabled", async () => {
let betaFeatures: string[] | undefined;
const client = {
beta: {
messages: {
create: (params: { betas?: string[] }) => {
betaFeatures = params.betas;
return { asResponse: async () => createSseResponse(minimalAnthropicEvents) };
},
},
},
} as unknown as Anthropic;
await streamAnthropic(
getModel("openrouter", "anthropic/claude-3-haiku"),
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
{ client, thinkingEnabled: false },
).result();
expect(betaFeatures ?? []).not.toContain("interleaved-thinking-2025-05-14");
});
it("passes managed beta features to injected clients", async () => {
let betaFeatures: string[] | undefined;
const client = {
beta: {
messages: {
create: (params: { betas?: string[] }) => {
betaFeatures = params.betas;
return { asResponse: async () => createSseResponse(minimalAnthropicEvents) };
},
},
},
} as unknown as Anthropic;
const result = await streamAnthropic(
getModel("anthropic", "claude-fable-5-1"),
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
{ client },
).result();
expect(result.stopReason).toBe("stop");
expect(betaFeatures).toContain("mid-conversation-output-config-2026-07-01");
expect(betaFeatures).toContain("thinking-binding-controls-2026-08-01");
});
it("uses the serving model input transformations from the final stream event", async () => {
const events = minimalAnthropicEvents.map((event) => ({ ...event }));
events[0].data = JSON.stringify({
type: "message_start",
message: {
id: "msg_transformations",
model: "claude-fable-5-1",
usage: { input_tokens: 12, output_tokens: 0 },
input_transformations: [
{ type: "thinking_dropped", path: "messages.1.content.0", reason: "prefix_binding_mismatch" },
],
},
});
const delta = JSON.parse(events[4].data) as Record<string, unknown>;
delta.input_transformations = [
{ type: "thinking_dropped", path: "messages.3.content.0", reason: "model_binding_mismatch" },
];
events[4].data = JSON.stringify(delta);
const result = await streamAnthropic(
getModel("anthropic", "claude-fable-5-1"),
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
{ client: createFakeAnthropicClient(createSseResponse(events)) },
).result();
expect(result.diagnostics).toEqual([
{
type: "anthropic_input_transformations",
timestamp: expect.any(Number),
details: {
transformations: [
{
type: "thinking_dropped",
path: "messages.3.content.0",
reason: "model_binding_mismatch",
},
],
},
},
]);
});
it("repairs malformed SSE JSON and malformed streamed tool JSON", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const context: Context = {
messages: [{ role: "user", content: "Use the edit tool.", timestamp: Date.now() }],
tools: [
{
name: "edit",
description: "Edit a file.",
parameters: Type.Object({
path: Type.String(),
text: Type.String(),
}),
},
],
};
const malformedToolJsonDelta = String.raw`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"A\H\",\"text\":\"col1 col2\"}"}}`;
const response = createSseResponse([
{
event: "message_start",
data: JSON.stringify({
type: "message_start",
message: {
id: "msg_test",
usage: {
input_tokens: 12,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
}),
},
{
event: "content_block_start",
data: JSON.stringify({
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "toolu_test",
name: "edit",
input: {},
},
}),
},
{ event: "content_block_delta", data: malformedToolJsonDelta },
{
event: "content_block_stop",
data: JSON.stringify({ type: "content_block_stop", index: 0 }),
},
{
event: "message_delta",
data: JSON.stringify({
type: "message_delta",
delta: { stop_reason: "tool_use" },
usage: {
input_tokens: 12,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
}),
},
{
event: "message_stop",
data: JSON.stringify({ type: "message_stop" }),
},
]);
const stream = streamAnthropic(model, context, {
client: createFakeAnthropicClient(response),
});
const result = await stream.result();
expect(result.stopReason).toBe("toolUse");
expect(result.errorMessage).toBeUndefined();
const toolCall = result.content.find((block): block is ToolCall => block.type === "toolCall");
expect(toolCall).toBeDefined();
expect(toolCall?.arguments).toEqual({
path: "A\\H",
text: "col1\tcol2",
});
});
it("preserves content from content_block_start events", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const context: Context = {
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
};
const response = createSseResponse([
{
event: "message_start",
data: JSON.stringify({
type: "message_start",
message: {
id: "msg_initial_content",
usage: {
input_tokens: 12,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
}),
},
{
event: "content_block_start",
data: JSON.stringify({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "Initial text" },
}),
},
{
event: "content_block_delta",
data: JSON.stringify({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: " plus delta" },
}),
},
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) },
{
event: "content_block_start",
data: JSON.stringify({
type: "content_block_start",
index: 1,
content_block: {
type: "thinking",
thinking: "Initial thinking",
signature: "initial signature",
},
}),
},
{
event: "content_block_delta",
data: JSON.stringify({
type: "content_block_delta",
index: 1,
delta: { type: "thinking_delta", thinking: " plus delta" },
}),
},
{
event: "content_block_delta",
data: JSON.stringify({
type: "content_block_delta",
index: 1,
delta: { type: "signature_delta", signature: " plus delta" },
}),
},
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 1 }) },
{
event: "message_delta",
data: JSON.stringify({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: {
input_tokens: 12,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
}),
},
{ event: "message_stop", data: JSON.stringify({ type: "message_stop" }) },
]);
const stream = streamAnthropic(model, context, {
client: createFakeAnthropicClient(response),
});
const result = await stream.result();
expect(result.content).toEqual([
{ type: "text", text: "Initial text plus delta" },
{
type: "thinking",
thinking: "Initial thinking plus delta",
thinkingSignature: "initial signature plus delta",
},
]);
});
it("preserves refusal stop details from message_delta", async () => {
const model = getModel("anthropic", "claude-fable-5");
const context: Context = {
messages: [{ role: "user", content: "blocked request", timestamp: Date.now() }],
};
const explanation =
"This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, provide feedback, or request an exemption based on how you use Claude, visit our help center: https://support.claude.com/en/articles/14604842-real-time-cyber-safeguards-on-claude.";
const response = createSseResponse([
{
event: "message_start",
data: JSON.stringify({
type: "message_start",
message: {
id: "msg_01XFUDYJgAACzvnptvVoYEL",
usage: {
input_tokens: 412,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
}),
},
{
event: "message_delta",
data: JSON.stringify({
type: "message_delta",
delta: {
stop_reason: "refusal",
stop_details: {
type: "refusal",
category: "cyber",
explanation,
},
},
usage: {
input_tokens: 412,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
}),
},
{
event: "message_stop",
data: JSON.stringify({ type: "message_stop" }),
},
]);
const stream = streamAnthropic(model, context, {
client: createFakeAnthropicClient(response),
});
const result = await stream.result();
expect(result.stopReason).toBe("error");
expect(result.rawStopReason).toBe("refusal");
expect(result.errorMessage).toBe(explanation);
});
it("preserves sensitive stop reasons with a descriptive error message", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const context: Context = {
messages: [{ role: "user", content: "blocked request", timestamp: Date.now() }],
};
const response = createSseResponse([
{
event: "message_start",
data: JSON.stringify({
type: "message_start",
message: {
id: "msg_sensitive",
usage: {
input_tokens: 12,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
}),
},
{
event: "message_delta",
data: JSON.stringify({
type: "message_delta",
delta: { stop_reason: "sensitive" },
usage: {
input_tokens: 12,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
}),
},
{
event: "message_stop",
data: JSON.stringify({ type: "message_stop" }),
},
]);
const stream = streamAnthropic(model, context, {
client: createFakeAnthropicClient(response),
});
const result = await stream.result();
expect(result.stopReason).toBe("error");
expect(result.rawStopReason).toBe("sensitive");
expect(result.errorMessage).toBe("Provider stopped with: sensitive");
});
it("treats message_delta without usage as a no-op for usage accumulation", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const context: Context = {
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
};
const response = createSseResponse(
minimalAnthropicEvents.map((event) =>
event.event === "message_delta"
? {
event: "message_delta",
data: JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" } }),
}
: event,
),
);
const stream = streamAnthropic(model, context, {
client: createFakeAnthropicClient(response),
});
const result = await stream.result();
expect(result.stopReason).toBe("stop");
expect(result.errorMessage).toBeUndefined();
expect(result.content).toEqual([{ type: "text", text: "Hello" }]);
expect(result.usage.input).toBe(12);
expect(result.usage.totalTokens).toBe(12);
});
it("ignores unknown SSE events after message_stop", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const context: Context = {
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
};
const response = createSseResponse([
...minimalAnthropicEvents,
{ event: "done", data: "[DONE]" },
{ event: "proxy.stats", data: "not json" },
]);
const stream = streamAnthropic(model, context, {
client: createFakeAnthropicClient(response),
});
const result = await stream.result();
expect(result.stopReason).toBe("stop");
expect(result.errorMessage).toBeUndefined();
expect(result.content).toEqual([{ type: "text", text: "Hello" }]);
});
});

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import type { Context, Model, SimpleStreamOptions } from "../src/types.ts";
interface AnthropicTemperaturePayload {
temperature?: number;
}
class PayloadCaptured extends Error {
constructor() {
super("payload captured");
this.name = "PayloadCaptured";
}
}
function makeContext(): Context {
return {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
}
function makeCustomModel(compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> {
return {
id: "vendor--claude-opus-4-7",
name: "Vendor Proxy Opus 4.7",
api: "anthropic-messages",
provider: "vendor-proxy",
baseUrl: "http://127.0.0.1:9",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
compat,
};
}
async function capturePayload(
model: Model<"anthropic-messages">,
options?: SimpleStreamOptions,
): Promise<AnthropicTemperaturePayload> {
let capturedPayload: AnthropicTemperaturePayload | undefined;
const payloadCaptureModel: Model<"anthropic-messages"> = {
...model,
baseUrl: "http://127.0.0.1:9",
};
const s = streamSimple(payloadCaptureModel, makeContext(), {
...options,
apiKey: "fake-key",
onPayload: (payload) => {
capturedPayload = payload as AnthropicTemperaturePayload;
throw new PayloadCaptured();
},
});
await s.result();
if (!capturedPayload) {
throw new Error("Expected payload to be captured before request failure");
}
return capturedPayload;
}
describe("Anthropic temperature compatibility", () => {
it("omits temperature for Claude Opus 4.7", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { temperature: 0 });
expect(payload.temperature).toBeUndefined();
});
it("omits temperature for Claude Opus 4.8", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { temperature: 0 });
expect(payload.temperature).toBeUndefined();
});
it("omits default temperature for Claude Opus 4.7", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { temperature: 1 });
expect(payload.temperature).toBeUndefined();
});
it("keeps temperature for Claude Opus 4.6", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-6"), { temperature: 0 });
expect(payload.temperature).toBe(0);
});
it("keeps temperature for Claude Sonnet 4.6", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-sonnet-4-6"), { temperature: 0 });
expect(payload.temperature).toBe(0);
});
it("omits temperature for custom models with supportsTemperature disabled", async () => {
const payload = await capturePayload(makeCustomModel({ supportsTemperature: false }), { temperature: 0 });
expect(payload.temperature).toBeUndefined();
});
});

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { stream } from "../src/api/anthropic-messages.ts";
import { getModel } from "../src/compat.ts";
import type { AssistantMessage, Context } from "../src/types.ts";
const enabled = Boolean(process.env.ANTHROPIC_API_KEY);
const model = getModel("anthropic", "claude-fable-5-1");
const user = (content: string, timestamp: number) => ({ role: "user" as const, content, timestamp });
function strictBinding(payload: unknown): unknown {
const params = payload as {
thinking?: {
block_binding?: { prefix_mismatch_behavior?: "drop_block" | "error" };
};
};
if (params.thinking?.block_binding) {
params.thinking.block_binding.prefix_mismatch_behavior = "error";
}
return params;
}
async function request(context: Context, effort: "low" | "high"): Promise<AssistantMessage> {
return stream(model, context, {
apiKey: process.env.ANTHROPIC_API_KEY,
cacheRetention: "none",
maxTokens: 1536,
thinkingEnabled: true,
thinkingDisplay: "summarized",
effort,
onPayload: strictBinding,
}).result();
}
describe.skipIf(!enabled)("Anthropic thinking binding conformance", () => {
it("replays managed effort markers required by signed Fable thinking", { timeout: 120000 }, async () => {
const firstUser = user("Compute 982451653 multiplied by 961748941. Return only the integer.", 1);
const first = await request({ messages: [firstUser] }, "low");
expect(first.stopReason, first.errorMessage).toBe("stop");
expect(
first.content.some(
(block) =>
block.type === "thinking" &&
typeof block.thinkingSignature === "string" &&
block.thinkingSignature.length > 0,
),
).toBe(true);
expect(first.providerThinkingLevel).toBe("low");
const secondUser = user("Reply with exactly: ok", 2);
const exact = await request({ messages: [firstUser, first, secondUser] }, "high");
expect(exact.stopReason, exact.errorMessage).toBe("stop");
const unmanagedHistory: AssistantMessage = { ...first };
delete unmanagedHistory.providerThinkingLevel;
const missingMarker = await request({ messages: [firstUser, unmanagedHistory, secondUser] }, "high");
expect(missingMarker.stopReason).toBe("error");
expect(missingMarker.errorMessage).toContain("Invalid `signature`");
});
});

View File

@@ -0,0 +1,172 @@
import { describe, expect, it } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import type { Context, Model, SimpleStreamOptions } from "../src/types.ts";
interface AnthropicThinkingPayload {
thinking?: { type: string; budget_tokens?: number; display?: string };
output_config?: { effort?: string };
}
class PayloadCaptured extends Error {
constructor() {
super("payload captured");
this.name = "PayloadCaptured";
}
}
function makePayloadCaptureContext(): Context {
return {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
}
async function capturePayload(
model: Model<"anthropic-messages">,
options?: SimpleStreamOptions,
): Promise<AnthropicThinkingPayload> {
let capturedPayload: AnthropicThinkingPayload | undefined;
const payloadCaptureModel: Model<"anthropic-messages"> = {
...model,
baseUrl: "http://127.0.0.1:9",
};
const s = streamSimple(payloadCaptureModel, makePayloadCaptureContext(), {
...options,
apiKey: "fake-key",
onPayload: (payload) => {
capturedPayload = payload as AnthropicThinkingPayload;
throw new PayloadCaptured();
},
});
await s.result();
if (!capturedPayload) {
throw new Error("Expected payload to be captured before request failure");
}
return capturedPayload;
}
interface RunResult {
thinkingEventCount: number;
thinkingCharCount: number;
text: string;
contentTypes: string[];
}
function makeE2EContext(): Context {
return {
systemPrompt: "You are a precise assistant. Follow the requested output format exactly.",
messages: [
{
role: "user",
content:
"Before replying, carefully solve 36863 * 5279 internally. Then reply with the word pong repeated exactly 40 times, separated by single spaces. Do not add any other text.",
timestamp: Date.now(),
},
],
};
}
function countPongs(text: string): number {
return text.match(/\bpong\b/gi)?.length ?? 0;
}
async function runWithoutReasoning(model: Model<"anthropic-messages">): Promise<RunResult> {
const s = streamSimple(model, makeE2EContext(), {
temperature: 0,
maxTokens: 160,
});
let thinkingEventCount = 0;
let thinkingCharCount = 0;
for await (const event of s) {
if (event.type === "thinking_start" || event.type === "thinking_end") {
thinkingEventCount += 1;
}
if (event.type === "thinking_delta") {
thinkingEventCount += 1;
thinkingCharCount += event.delta.length;
}
}
const response = await s.result();
expect(response.stopReason, response.errorMessage).toBe("stop");
const text = response.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("")
.trim();
return {
thinkingEventCount,
thinkingCharCount,
text,
contentTypes: response.content.map((block) => block.type),
};
}
describe("Anthropic thinking disable payload", () => {
it("sends thinking.type=disabled for budget-based reasoning models when thinking is off", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-sonnet-4-5"));
expect(payload.thinking).toEqual({ type: "disabled" });
expect(payload.output_config).toBeUndefined();
});
it("sends thinking.type=disabled for adaptive reasoning models when thinking is off", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-6"));
expect(payload.thinking).toEqual({ type: "disabled" });
expect(payload.output_config).toBeUndefined();
});
it("sends thinking.type=disabled for Claude Opus 4.8 when thinking is off", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"));
expect(payload.thinking).toEqual({ type: "disabled" });
expect(payload.output_config).toBeUndefined();
});
it("omits thinking.type=disabled for Claude Fable 5 when thinking is off", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-fable-5"));
expect(payload.thinking).toBeUndefined();
expect(payload.output_config).toBeUndefined();
});
it("uses adaptive thinking for Claude Opus 4.8 when reasoning is enabled", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "high" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "high" });
});
it("uses adaptive thinking for Claude Sonnet 5 when reasoning is enabled", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-sonnet-5"), { reasoning: "high" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "high" });
});
it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.8", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "xhigh" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "xhigh" });
});
});
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic thinking disable E2E", () => {
it("disables thinking for Claude reasoning models", { retry: 2, timeout: 30000 }, async () => {
const result = await runWithoutReasoning(getModel("anthropic", "claude-sonnet-4-5"));
expect(result.thinkingEventCount).toBe(0);
expect(result.thinkingCharCount).toBe(0);
expect(result.contentTypes).not.toContain("thinking");
expect(countPongs(result.text)).toBeGreaterThanOrEqual(35);
});
});

View File

@@ -0,0 +1,204 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { getModel, stream } from "../src/compat.ts";
import type { Context, Tool } from "../src/types.ts";
import { resolveApiKey } from "./oauth.ts";
const oauthToken = await resolveApiKey("anthropic");
/**
* Tests for Anthropic OAuth tool name normalization.
*
* When using Claude Code OAuth, tool names must match CC's canonical casing.
* The normalization should:
* 1. Convert tool names that match CC tools (case-insensitive) to CC casing on outbound
* 2. Convert tool names back to the original casing on inbound
*
* This is a simple case-insensitive lookup, NOT a mapping of different names.
* e.g., "todowrite" -> "TodoWrite" -> "todowrite" (round-trip works)
*
* The old `find -> Glob` mapping was WRONG because:
* - Outbound: "find" -> "Glob"
* - Inbound: "Glob" -> ??? (no tool named "glob" in context.tools, only "find")
* - Result: tool call has name "Glob" but no tool exists with that name
*/
describe.skipIf(!oauthToken)("Anthropic OAuth tool name normalization", () => {
const model = getModel("anthropic", "claude-sonnet-4-6");
it("should normalize user-defined tool matching CC name (todowrite -> TodoWrite -> todowrite)", async () => {
// User defines a tool named "todowrite" (lowercase)
// CC has "TodoWrite" - this should round-trip correctly
const todoTool: Tool = {
name: "todowrite",
description: "Write a todo item",
parameters: Type.Object({
task: Type.String({ description: "The task to add" }),
}),
};
const context: Context = {
systemPrompt: "You are a helpful assistant. Use the todowrite tool when asked to add todos.",
messages: [
{
role: "user",
content: "Add a todo: buy milk. Use the todowrite tool.",
timestamp: Date.now(),
},
],
tools: [todoTool],
};
const s = stream(model, context, { apiKey: oauthToken });
let toolCallName: string | undefined;
for await (const event of s) {
if (event.type === "toolcall_end") {
const toolCall = event.partial.content[event.contentIndex];
if (toolCall.type === "toolCall") {
toolCallName = toolCall.name;
}
}
}
const response = await s.result();
expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("toolUse");
// The tool call should come back with the ORIGINAL name "todowrite", not "TodoWrite"
expect(toolCallName).toBe("todowrite");
});
it("should handle pi's built-in tools (read, write, edit, bash)", async () => {
// Pi's tools use lowercase names, CC uses PascalCase
const readTool: Tool = {
name: "read",
description: "Read a file",
parameters: Type.Object({
path: Type.String({ description: "File path" }),
}),
};
const context: Context = {
systemPrompt: "You are a helpful assistant. Use the read tool to read files.",
messages: [
{
role: "user",
content: "Read the file /tmp/test.txt using the read tool.",
timestamp: Date.now(),
},
],
tools: [readTool],
};
const s = stream(model, context, { apiKey: oauthToken });
let toolCallName: string | undefined;
for await (const event of s) {
if (event.type === "toolcall_end") {
const toolCall = event.partial.content[event.contentIndex];
if (toolCall.type === "toolCall") {
toolCallName = toolCall.name;
}
}
}
const response = await s.result();
expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("toolUse");
// The tool call should come back with the ORIGINAL name "read", not "Read"
expect(toolCallName).toBe("read");
});
it("should NOT map find to Glob - find is not a CC tool name", async () => {
// Pi has a "find" tool, CC has "Glob" - these are DIFFERENT tools
// The old code incorrectly mapped find -> Glob, which broke the round-trip
// because there's no tool named "glob" in context.tools
const findTool: Tool = {
name: "find",
description: "Find files by pattern",
parameters: Type.Object({
pattern: Type.String({ description: "Glob pattern" }),
}),
};
const context: Context = {
systemPrompt: "You are a helpful assistant. Use the find tool to search for files.",
messages: [
{
role: "user",
content: "Find all .ts files using the find tool.",
timestamp: Date.now(),
},
],
tools: [findTool],
};
const s = stream(model, context, { apiKey: oauthToken });
let toolCallName: string | undefined;
for await (const event of s) {
if (event.type === "toolcall_end") {
const toolCall = event.partial.content[event.contentIndex];
if (toolCall.type === "toolCall") {
toolCallName = toolCall.name;
}
}
}
const response = await s.result();
expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("toolUse");
// With the BROKEN find -> Glob mapping:
// - Sent as "Glob" to Anthropic
// - Received back as "Glob"
// - fromClaudeCodeName("Glob", tools) looks for tool.name.toLowerCase() === "glob"
// - No match (tool is named "find"), returns "Glob"
// - Test fails: toolCallName is "Glob" instead of "find"
//
// With the CORRECT implementation (no find->Glob mapping):
// - Sent as "find" to Anthropic (no CC tool named "Find")
// - Received back as "find"
// - Test passes: toolCallName is "find"
expect(toolCallName).toBe("find");
});
it("should handle custom tools that don't match any CC tool names", async () => {
// A completely custom tool should pass through unchanged
const customTool: Tool = {
name: "my_custom_tool",
description: "A custom tool",
parameters: Type.Object({
input: Type.String({ description: "Input value" }),
}),
};
const context: Context = {
systemPrompt: "You are a helpful assistant. Use my_custom_tool when asked.",
messages: [
{
role: "user",
content: "Use my_custom_tool with input 'hello'.",
timestamp: Date.now(),
},
],
tools: [customTool],
};
const s = stream(model, context, { apiKey: oauthToken });
let toolCallName: string | undefined;
for await (const event of s) {
if (event.type === "toolcall_end") {
const toolCall = event.partial.content[event.contentIndex];
if (toolCall.type === "toolCall") {
toolCallName = toolCall.name;
}
}
}
const response = await s.result();
expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("toolUse");
// Custom tool names should pass through unchanged
expect(toolCallName).toBe("my_custom_tool");
});
});

View File

@@ -0,0 +1,606 @@
import type { ResponseStreamEvent } from "openai/resources/responses/responses.js";
import { describe, expect, it } from "vitest";
import { processResponsesStream } from "../src/api/openai-responses-shared.ts";
import {
type AssistantMessage,
type AssistantMessageEvent,
type AssistantMessageFrame,
AssistantMessageFrameEncoder,
type Model,
reduceAssistantMessageFrames,
} from "../src/index.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function seed(): AssistantMessage {
return {
role: "assistant",
content: [],
api: "test-api",
provider: "test-provider",
model: "test-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "pending",
timestamp: 1,
};
}
function frame(encoder: AssistantMessageFrameEncoder, event: AssistantMessageEvent): AssistantMessageFrame {
const converted = encoder.encode(event);
if (!converted) throw new Error(`Expected ${event.type} event to produce a frame`);
return converted;
}
describe("assistant message frames", () => {
it("uses authoritative text end content and signature", () => {
const partial = seed();
const encoder = new AssistantMessageFrameEncoder();
const frames: AssistantMessageFrame[] = [frame(encoder, { type: "start", partial })];
partial.content.push({ type: "text", text: "Hello " });
frames.push(frame(encoder, { type: "text_start", contentIndex: 0, partial }));
partial.content[0] = { type: "text", text: "Hello world", textSignature: "sig-text" };
frames.push(
frame(encoder, { type: "text_delta", contentIndex: 0, delta: "incorrect", partial }),
frame(encoder, { type: "text_end", contentIndex: 0, content: "Hello world", partial }),
);
expect(frames.at(-1)).toEqual({
type: "text_end",
contentIndex: 0,
content: "Hello world",
textSignature: "sig-text",
});
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([
{ type: "text", text: "Hello world", textSignature: "sig-text" },
]);
});
it("preserves provider thinking level from the stream start", () => {
const partial = seed();
partial.providerThinkingLevel = "high";
const encoder = new AssistantMessageFrameEncoder();
const start = frame(encoder, { type: "start", partial });
expect(start).toMatchObject({ type: "start", partial: { providerThinkingLevel: "high" } });
expect(reduceAssistantMessageFrames([start])?.providerThinkingLevel).toBe("high");
});
it("preserves initial and final thinking metadata, including redaction", () => {
const partial = seed();
const encoder = new AssistantMessageFrameEncoder();
const frames: AssistantMessageFrame[] = [frame(encoder, { type: "start", partial })];
partial.content.push({
type: "thinking",
thinking: "[redacted]",
thinkingSignature: "encrypted-start",
redacted: true,
});
frames.push(frame(encoder, { type: "thinking_start", contentIndex: 0, partial }));
partial.content[0] = {
type: "thinking",
thinking: "[redacted]",
thinkingSignature: "encrypted-final",
redacted: true,
};
frames.push(frame(encoder, { type: "thinking_end", contentIndex: 0, content: "[redacted]", partial }));
expect(frames.at(-1)).toEqual({
type: "thinking_end",
contentIndex: 0,
content: "[redacted]",
thinkingSignature: "encrypted-final",
redacted: true,
});
expect(reduceAssistantMessageFrames(frames)?.content[0]).toEqual({
type: "thinking",
thinking: "[redacted]",
thinkingSignature: "encrypted-final",
redacted: true,
});
});
it("parses unfinished tool JSON once and uses authoritative completed arguments", () => {
const initialFrames: AssistantMessageFrame[] = [
{ type: "start", partial: seed() },
{
type: "toolcall_start",
contentIndex: 0,
toolCall: { type: "toolCall", id: "initial-id", name: "write", arguments: {} },
},
{ type: "toolcall_delta", contentIndex: 0, delta: '{"path":"READ' },
];
expect(reduceAssistantMessageFrames(initialFrames)?.content[0]).toMatchObject({
type: "toolCall",
arguments: { path: "READ" },
});
const completeFrames: AssistantMessageFrame[] = [
...initialFrames,
{ type: "toolcall_delta", contentIndex: 0, delta: 'ME.md","lines":[1,2]}' },
{
type: "toolcall_end",
contentIndex: 0,
id: "final-id",
name: "write_file",
arguments: { path: "final.md", lines: [3] },
thoughtSignature: "thought",
namespace: "files",
},
];
expect(reduceAssistantMessageFrames(completeFrames)?.content[0]).toEqual({
type: "toolCall",
id: "final-id",
name: "write_file",
arguments: { path: "final.md", lines: [3] },
thoughtSignature: "thought",
namespace: "files",
});
});
it("round-trips OpenAI Responses content supplied only by authoritative end events", async () => {
const output = seed();
output.api = "openai-responses";
output.provider = "openai";
const model: Model<"openai-responses"> = {
id: output.model,
name: "Test",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
const events: ResponseStreamEvent[] = [
{
type: "response.output_item.added",
sequence_number: 0,
output_index: 0,
item: { type: "message", id: "msg", role: "assistant", status: "in_progress", content: [] },
} as ResponseStreamEvent,
{
type: "response.output_item.done",
sequence_number: 1,
output_index: 0,
item: {
type: "message",
id: "msg",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "final text", annotations: [] }],
},
} as ResponseStreamEvent,
{
type: "response.output_item.added",
sequence_number: 2,
output_index: 1,
item: {
type: "function_call",
id: "fc",
call_id: "call",
name: "lookup",
arguments: "",
},
} as ResponseStreamEvent,
{
type: "response.output_item.done",
sequence_number: 3,
output_index: 1,
item: {
type: "function_call",
id: "fc",
call_id: "call",
name: "lookup",
arguments: '{"query":"pi"}',
},
} as ResponseStreamEvent,
{
type: "response.completed",
sequence_number: 4,
response: { id: "response", status: "completed", output: [] },
} as unknown as ResponseStreamEvent,
];
async function* source(): AsyncGenerator<ResponseStreamEvent> {
for (const event of events) yield event;
}
const encoder = new AssistantMessageFrameEncoder();
const frames: AssistantMessageFrame[] = [frame(encoder, { type: "start", partial: output })];
const stream = new AssistantMessageEventStream();
const push = stream.push.bind(stream);
stream.push = (event) => {
const converted = encoder.encode(event);
if (converted) frames.push(converted);
push(event);
};
await processResponsesStream(source(), output, stream, model);
expect(reduceAssistantMessageFrames(frames)?.content).toEqual(output.content);
});
it("reconciles queued text events against one advanced live partial without duplicate content", () => {
const partial = seed();
const events: AssistantMessageEvent[] = [{ type: "start", partial }];
const text = { type: "text" as const, text: "" };
partial.content.push(text);
events.push({ type: "text_start", contentIndex: 0, partial });
for (const delta of ["Hel", "lo", " ", "world"]) {
text.text += delta;
events.push({ type: "text_delta", contentIndex: 0, delta, partial });
}
const encoder = new AssistantMessageFrameEncoder();
const frames = events.flatMap((event) => {
const encoded = encoder.encode(event);
return encoded === undefined ? [] : [encoded];
});
expect(frames.map((item) => item.type)).toEqual(["start", "text_start"]);
expect(frames[0]).toMatchObject({ type: "start", partial: { content: [], stopReason: "pending" } });
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([{ type: "text", text: "Hello world" }]);
});
it("trims only the covered prefix when a start snapshot lands inside a delta", () => {
const partial = seed();
const encoder = new AssistantMessageFrameEncoder();
const frames: AssistantMessageFrame[] = [frame(encoder, { type: "start", partial })];
const text = { type: "text" as const, text: "Hel" };
partial.content.push(text);
frames.push(frame(encoder, { type: "text_start", contentIndex: 0, partial }));
expect(encoder.encode({ type: "text_delta", contentIndex: 0, delta: "He", partial })).toBeUndefined();
const remainder = encoder.encode({ type: "text_delta", contentIndex: 0, delta: "llo", partial });
if (remainder === undefined) throw new Error("Expected uncovered text delta");
frames.push(remainder);
expect(remainder).toEqual({ type: "text_delta", contentIndex: 0, delta: "lo" });
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([{ type: "text", text: "Hello" }]);
});
it("checkpoints queued tool JSON without replaying covered deltas", () => {
const partial = seed();
const toolCall = { type: "toolCall" as const, id: "call", name: "write", arguments: {} };
const events: AssistantMessageEvent[] = [{ type: "start", partial }];
partial.content.push(toolCall);
events.push({ type: "toolcall_start", contentIndex: 0, partial });
toolCall.arguments = { path: "README.md" };
events.push(
{ type: "toolcall_delta", contentIndex: 0, delta: '{"path":"READ', partial },
{ type: "toolcall_delta", contentIndex: 0, delta: 'ME.md"}', partial },
);
const encoder = new AssistantMessageFrameEncoder();
const frames = events.flatMap((event) => {
const encoded = encoder.encode(event);
return encoded === undefined ? [] : [encoded];
});
expect(frames.map((item) => item.type)).toEqual(["start", "toolcall_start", "toolcall_checkpoint"]);
expect(frames.at(-1)).toEqual({
type: "toolcall_checkpoint",
contentIndex: 0,
json: '{"path":"README.md"}',
});
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([
{ type: "toolCall", id: "call", name: "write", arguments: { path: "README.md" } },
]);
});
it("resumes legacy grammar tool JSON from initial arguments", () => {
const partial = seed();
const encoder = new AssistantMessageFrameEncoder();
const frames: AssistantMessageFrame[] = [frame(encoder, { type: "start", partial })];
const toolCall = { type: "toolCall" as const, id: "call", name: "bash", arguments: { input: "a" } };
partial.content.push(toolCall);
frames.push(frame(encoder, { type: "toolcall_start", contentIndex: 0, partial }));
toolCall.arguments = { input: "ab" };
frames.push(
frame(encoder, {
type: "toolcall_delta",
contentIndex: 0,
delta: '{"input":"ab',
partial,
}),
);
toolCall.arguments = { input: "abc" };
frames.push(
frame(encoder, {
type: "toolcall_delta",
contentIndex: 0,
delta: 'c"}',
partial,
}),
);
expect(frames.slice(2)).toEqual([
{ type: "toolcall_checkpoint", contentIndex: 0, json: '{"input":"ab' },
{ type: "toolcall_delta", contentIndex: 0, delta: 'c"}' },
]);
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([
{ type: "toolCall", id: "call", name: "bash", arguments: { input: "abc" } },
]);
});
it("streams tool JSON compactly from an empty argument start", () => {
const partial = seed();
const encoder = new AssistantMessageFrameEncoder();
const frames: AssistantMessageFrame[] = [frame(encoder, { type: "start", partial })];
const toolCall = { type: "toolCall" as const, id: "call", name: "bash", arguments: {} };
partial.content.push(toolCall);
frames.push(frame(encoder, { type: "toolcall_start", contentIndex: 0, partial }));
toolCall.arguments = { command: "ls -la /tmp" };
frames.push(
frame(encoder, {
type: "toolcall_delta",
contentIndex: 0,
delta: '{"command":"ls -la /tmp"}',
partial,
}),
);
expect(frames.at(-1)).toEqual({
type: "toolcall_delta",
contentIndex: 0,
delta: '{"command":"ls -la /tmp"}',
});
expect(reduceAssistantMessageFrames(frames)?.content[0]).toMatchObject({
type: "toolCall",
arguments: { command: "ls -la /tmp" },
});
});
it("accepts a pre-generation error but rejects success or updates before start", () => {
const failed = seed();
failed.stopReason = "error";
failed.errorMessage = "setup failed";
expect(
new AssistantMessageFrameEncoder().encode({ type: "error", reason: "error", error: failed }),
).toBeUndefined();
const completed = seed();
completed.stopReason = "stop";
expect(() =>
new AssistantMessageFrameEncoder().encode({ type: "done", reason: "stop", message: completed }),
).toThrow("done event appears before start");
expect(() =>
new AssistantMessageFrameEncoder().encode({
type: "text_delta",
contentIndex: 0,
delta: "x",
partial: seed(),
}),
).toThrow("text_delta event appears before start");
});
it("treats end signature metadata, including absence, as authoritative", () => {
const frames: AssistantMessageFrame[] = [
{ type: "start", partial: seed() },
{
type: "text_start",
contentIndex: 0,
content: { type: "text", text: "", textSignature: "stale-text" },
},
{ type: "text_end", contentIndex: 0, content: "" },
{
type: "thinking_start",
contentIndex: 1,
content: {
type: "thinking",
thinking: "",
thinkingSignature: "stale-thinking",
redacted: true,
},
},
{ type: "thinking_end", contentIndex: 1, content: "", thinkingSignature: "", redacted: false },
{
type: "toolcall_start",
contentIndex: 2,
toolCall: {
type: "toolCall",
id: "call",
name: "read",
arguments: {},
thoughtSignature: "stale-tool",
namespace: "stale-namespace",
},
},
{ type: "toolcall_end", contentIndex: 2, id: "call", name: "read", arguments: {} },
];
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([
{ type: "text", text: "" },
{ type: "thinking", thinking: "", thinkingSignature: "", redacted: false },
{ type: "toolCall", id: "call", name: "read", arguments: {} },
]);
});
it("stores authoritative final arguments in toolcall_end frames", () => {
const partial = seed();
const toolCall = {
type: "toolCall" as const,
id: "call-1",
name: "read",
arguments: { path: "README.md" },
thoughtSignature: "thought",
namespace: "files",
};
partial.content.push(toolCall);
const encoder = new AssistantMessageFrameEncoder();
frame(encoder, { type: "start", partial });
frame(encoder, { type: "toolcall_start", contentIndex: 0, partial });
const end = frame(encoder, { type: "toolcall_end", contentIndex: 0, toolCall, partial });
expect(end).toEqual({
type: "toolcall_end",
contentIndex: 0,
id: "call-1",
name: "read",
arguments: { path: "README.md" },
thoughtSignature: "thought",
namespace: "files",
});
});
it("whitelists public block fields from provider-shaped partials", () => {
const partial = seed();
const text = { type: "text" as const, text: "visible", textSignature: "text-sig", index: 4 };
const thinking = {
type: "thinking" as const,
thinking: "reasoning",
thinkingSignature: "thinking-sig",
redacted: false,
index: 5,
};
const toolCall = {
type: "toolCall" as const,
id: "call",
name: "run",
arguments: { value: 1 },
thoughtSignature: "tool-sig",
namespace: "tools",
partialJson: '{"value":',
streamIndex: 6,
};
partial.content.push(text, thinking, toolCall);
const partialWithScratch = partial as AssistantMessage & { outputIndex?: number };
partialWithScratch.outputIndex = 3;
const encoder = new AssistantMessageFrameEncoder();
const start = frame(encoder, { type: "start", partial });
const textStart = frame(encoder, { type: "text_start", contentIndex: 0, partial });
const thinkingStart = frame(encoder, { type: "thinking_start", contentIndex: 1, partial });
const toolStart = frame(encoder, { type: "toolcall_start", contentIndex: 2, partial });
expect(start.type === "start" && start.partial.content).toEqual([]);
expect(start).not.toHaveProperty("partial.outputIndex");
expect(textStart).not.toHaveProperty("content.index");
expect(thinkingStart).not.toHaveProperty("content.index");
expect(toolStart).not.toHaveProperty("toolCall.partialJson");
expect(toolStart).not.toHaveProperty("toolCall.streamIndex");
});
it("supports interleaved streams by contentIndex", () => {
const frames: AssistantMessageFrame[] = [
{ type: "start", partial: seed() },
{ type: "text_start", contentIndex: 0, content: { type: "text", text: "" } },
{
type: "toolcall_start",
contentIndex: 1,
toolCall: { type: "toolCall", id: "call", name: "lookup", arguments: {} },
},
{ type: "thinking_start", contentIndex: 2, content: { type: "thinking", thinking: "" } },
{ type: "text_delta", contentIndex: 0, delta: "answer" },
{ type: "toolcall_delta", contentIndex: 1, delta: '{"query":"pi"}' },
{ type: "thinking_delta", contentIndex: 2, delta: "check" },
{ type: "toolcall_end", contentIndex: 1, id: "call", name: "lookup", arguments: { query: "pi" } },
{ type: "text_end", contentIndex: 0, content: "answer" },
{ type: "thinking_end", contentIndex: 2, content: "check" },
];
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([
{ type: "text", text: "answer" },
{ type: "toolCall", id: "call", name: "lookup", arguments: { query: "pi" } },
{ type: "thinking", thinking: "check" },
]);
});
it("snapshots mutable event data and keeps reduction pure", () => {
const partial = seed();
partial.diagnostics = [{ type: "test", timestamp: 2, details: { value: "original" } }];
const encoder = new AssistantMessageFrameEncoder();
const start = frame(encoder, { type: "start", partial });
partial.diagnostics[0]!.details!.value = "mutated";
partial.usage.cost.total = 99;
partial.content.push({
type: "toolCall",
id: "call",
name: "run",
arguments: { nested: { value: "original" } },
});
const toolStart = frame(encoder, { type: "toolcall_start", contentIndex: 0, partial });
const sourceTool = partial.content[0];
if (sourceTool?.type !== "toolCall") throw new Error("Expected source tool call");
(sourceTool.arguments.nested as Record<string, unknown>).value = "mutated";
const reduced = reduceAssistantMessageFrames([start, toolStart]);
expect(reduced?.diagnostics?.[0]?.details?.value).toBe("original");
expect(reduced?.usage.cost.total).toBe(0);
expect(reduced?.content[0]).toMatchObject({ arguments: { nested: { value: "original" } } });
if (reduced?.content[0]?.type !== "toolCall") throw new Error("Expected reduced tool call");
reduced.content[0].arguments.nested = "changed-output";
expect(toolStart.type === "toolcall_start" && toolStart.toolCall.arguments.nested).toEqual({
value: "original",
});
});
it("omits terminal events because settlement is separate", () => {
const message = seed();
const completed = new AssistantMessageFrameEncoder();
completed.encode({ type: "start", partial: message });
message.stopReason = "stop";
expect(completed.encode({ type: "done", reason: "stop", message })).toBeUndefined();
message.stopReason = "error";
message.errorMessage = "failed";
expect(
new AssistantMessageFrameEncoder().encode({ type: "error", reason: "error", error: message }),
).toBeUndefined();
});
it("returns undefined when there is no start frame", () => {
expect(reduceAssistantMessageFrames([])).toBeUndefined();
expect(reduceAssistantMessageFrames([{ type: "text_delta", contentIndex: 0, delta: "x" }])).toBeUndefined();
});
it("rejects frames before start, wrong block kinds, duplicate ends, and index gaps", () => {
expect(() =>
reduceAssistantMessageFrames([
{ type: "text_delta", contentIndex: 0, delta: "x" },
{ type: "start", partial: seed() },
]),
).toThrow("before the start frame");
expect(() =>
reduceAssistantMessageFrames([
{ type: "start", partial: seed() },
{
type: "toolcall_start",
contentIndex: 0,
toolCall: { type: "toolCall", id: "call", name: "run", arguments: {} },
},
{ type: "text_delta", contentIndex: 0, delta: "wrong" },
]),
).toThrow("expected text block");
expect(() =>
reduceAssistantMessageFrames([
{ type: "start", partial: seed() },
{ type: "text_start", contentIndex: 0, content: { type: "text", text: "" } },
{ type: "text_end", contentIndex: 0, content: "" },
{ type: "text_end", contentIndex: 0, content: "" },
]),
).toThrow("follows the end");
expect(() =>
reduceAssistantMessageFrames([
{ type: "start", partial: seed() },
{ type: "text_start", contentIndex: 1, content: { type: "text", text: "" } },
]),
).toThrow("would leave a gap");
});
it("rejects conversion events whose contentIndex points to the wrong block kind", () => {
const partial = seed();
const encoder = new AssistantMessageFrameEncoder();
encoder.encode({ type: "start", partial });
partial.content.push({ type: "thinking", thinking: "" });
expect(() => encoder.encode({ type: "text_start", contentIndex: 0, partial })).toThrow(
"text_start event points to thinking block",
);
});
});

View File

@@ -0,0 +1,227 @@
import { arch, platform, release } from "node:os";
import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { stream as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts";
import { getModel } from "../src/compat.ts";
import type { Context, Model } from "../src/types.ts";
interface CapturedAzureClientOptions {
apiKey: string;
apiVersion: string;
dangerouslyAllowBrowser: boolean;
defaultHeaders?: Record<string, string>;
baseURL: string;
}
interface CapturedAzureResponsesPayload {
prompt_cache_key?: string;
store?: boolean;
tools?: Array<{ strict?: boolean }>;
}
const azureMock = vi.hoisted(() => ({
constructorCalls: [] as CapturedAzureClientOptions[],
lastParams: undefined as CapturedAzureResponsesPayload | undefined,
}));
vi.mock("openai", () => {
class AzureOpenAI {
responses = {
create: (params: CapturedAzureResponsesPayload) => {
azureMock.lastParams = params;
throw new Error("mock create");
},
};
constructor(config: CapturedAzureClientOptions) {
azureMock.constructorCalls.push(config);
}
}
return { AzureOpenAI };
});
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
const originalAzureOpenAIBaseUrl = process.env.AZURE_OPENAI_BASE_URL;
const originalAzureOpenAIResourceName = process.env.AZURE_OPENAI_RESOURCE_NAME;
const originalAzureOpenAIApiVersion = process.env.AZURE_OPENAI_API_VERSION;
const originalAzureOpenAIApiKey = process.env.AZURE_OPENAI_API_KEY;
beforeEach(() => {
azureMock.constructorCalls.length = 0;
azureMock.lastParams = undefined;
delete process.env.AZURE_OPENAI_BASE_URL;
delete process.env.AZURE_OPENAI_RESOURCE_NAME;
delete process.env.AZURE_OPENAI_API_VERSION;
delete process.env.AZURE_OPENAI_API_KEY;
});
afterEach(() => {
if (originalAzureOpenAIBaseUrl === undefined) {
delete process.env.AZURE_OPENAI_BASE_URL;
} else {
process.env.AZURE_OPENAI_BASE_URL = originalAzureOpenAIBaseUrl;
}
if (originalAzureOpenAIResourceName === undefined) {
delete process.env.AZURE_OPENAI_RESOURCE_NAME;
} else {
process.env.AZURE_OPENAI_RESOURCE_NAME = originalAzureOpenAIResourceName;
}
if (originalAzureOpenAIApiVersion === undefined) {
delete process.env.AZURE_OPENAI_API_VERSION;
} else {
process.env.AZURE_OPENAI_API_VERSION = originalAzureOpenAIApiVersion;
}
if (originalAzureOpenAIApiKey === undefined) {
delete process.env.AZURE_OPENAI_API_KEY;
} else {
process.env.AZURE_OPENAI_API_KEY = originalAzureOpenAIApiKey;
}
});
async function captureClientBaseUrl(baseUrl: string): Promise<string> {
process.env.AZURE_OPENAI_BASE_URL = baseUrl;
const model = getModel("azure-openai-responses", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, context, { apiKey: "test-api-key" }).result();
expect(azureMock.constructorCalls).toHaveLength(1);
return azureMock.constructorCalls[0].baseURL;
}
async function captureClientHeaders(headers?: Record<string, string>): Promise<Record<string, string>> {
const model = getModel("azure-openai-responses", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, context, {
apiKey: "test-api-key",
azureBaseUrl: "https://my-resource.openai.azure.com",
headers,
}).result();
expect(azureMock.constructorCalls).toHaveLength(1);
return azureMock.constructorCalls[0].defaultHeaders ?? {};
}
describe("azure-openai-responses base URL normalization", () => {
it("normalizes Cognitive Services root endpoints to /openai/v1", async () => {
const baseURL = await captureClientBaseUrl("https://marc-quicktests-resource.cognitiveservices.azure.com");
expect(baseURL).toBe("https://marc-quicktests-resource.cognitiveservices.azure.com/openai/v1");
});
it("normalizes Microsoft Foundry root endpoints to /openai/v1", async () => {
const baseURL = await captureClientBaseUrl("https://marc-quicktests-resource.ai.azure.com");
expect(baseURL).toBe("https://marc-quicktests-resource.ai.azure.com/openai/v1");
});
it("normalizes Azure OpenAI root endpoints to /openai/v1", async () => {
const baseURL = await captureClientBaseUrl("https://my-resource.openai.azure.com");
expect(baseURL).toBe("https://my-resource.openai.azure.com/openai/v1");
});
it("normalizes /openai to /openai/v1", async () => {
const baseURL = await captureClientBaseUrl("https://my-resource.cognitiveservices.azure.com/openai");
expect(baseURL).toBe("https://my-resource.cognitiveservices.azure.com/openai/v1");
});
it("preserves /openai/v1 endpoints", async () => {
const baseURL = await captureClientBaseUrl("https://my-resource.cognitiveservices.azure.com/openai/v1");
expect(baseURL).toBe("https://my-resource.cognitiveservices.azure.com/openai/v1");
});
it("normalizes /openai/v1/responses to /openai/v1", async () => {
const baseURL = await captureClientBaseUrl("https://my-resource.services.ai.azure.com/openai/v1/responses");
expect(baseURL).toBe("https://my-resource.services.ai.azure.com/openai/v1");
});
it("preserves explicit non-Azure proxy paths", async () => {
const baseURL = await captureClientBaseUrl("https://my-proxy.example.com/v1");
expect(baseURL).toBe("https://my-proxy.example.com/v1");
});
it("strips query params when normalizing Azure host URLs", async () => {
const baseURL = await captureClientBaseUrl("https://my-resource.openai.azure.com/openai?api-version=2024-12-01");
expect(baseURL).toBe("https://my-resource.openai.azure.com/openai/v1");
});
it("preserves query params on non-Azure proxy URLs", async () => {
const baseURL = await captureClientBaseUrl("https://my-proxy.example.com/v1?custom=true");
expect(baseURL).toBe("https://my-proxy.example.com/v1?custom=true");
});
it("throws on invalid URLs", async () => {
process.env.AZURE_OPENAI_BASE_URL = "not-a-url";
const model = getModel("azure-openai-responses", "gpt-4o-mini");
const result = await streamAzureOpenAIResponses(model, context, { apiKey: "test-api-key" }).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Invalid Azure OpenAI base URL");
});
it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => {
const model = getModel("azure-openai-responses", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, context, {
apiKey: "test-api-key",
azureBaseUrl: "https://my-resource.openai.azure.com",
sessionId: "x".repeat(67),
}).result();
expect(azureMock.lastParams?.prompt_cache_key).toBe("x".repeat(64));
});
it("disables server-side response storage", async () => {
const model = getModel("azure-openai-responses", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, context, {
apiKey: "test-api-key",
azureBaseUrl: "https://my-resource.openai.azure.com",
}).result();
expect(azureMock.lastParams?.store).toBe(false);
});
it("honors supportsStrictMode: false", async () => {
const baseModel = getModel("azure-openai-responses", "gpt-4o-mini");
const model: Model<"azure-openai-responses"> = {
...baseModel,
compat: { ...baseModel.compat, supportsStrictMode: false },
};
await streamAzureOpenAIResponses(
model,
{
...context,
tools: [
{
name: "preferred",
description: "Preferred constrained tool",
parameters: Type.Object({ value: Type.String() }),
constrainedSampling: { type: "json_schema", strict: "prefer" },
},
],
},
{ apiKey: "test-api-key", azureBaseUrl: "https://my-resource.openai.azure.com" },
).result();
expect(azureMock.lastParams?.tools?.[0]).not.toHaveProperty("strict");
});
it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => {
process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource";
const model = getModel("azure-openai-responses", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, context, { apiKey: "test-api-key" }).result();
expect(azureMock.constructorCalls).toHaveLength(1);
expect(azureMock.constructorCalls[0].baseURL).toBe("https://my-resource.openai.azure.com/openai/v1");
});
});
describe("azure-openai-responses user agent", () => {
it("uses pi's User-Agent by default", async () => {
expect((await captureClientHeaders())["User-Agent"]).toBe(PI_USER_AGENT);
});
it("lets explicit headers override the default User-Agent", async () => {
expect((await captureClientHeaders({ "User-Agent": "custom-agent" }))["User-Agent"]).toBe("custom-agent");
});
});

View File

@@ -0,0 +1,136 @@
import type { ResponseReasoningItem, ResponseStreamEvent } from "openai/resources/responses/responses.js";
import { describe, expect, it } from "vitest";
import { convertResponsesMessages, processResponsesStream } from "../src/api/openai-responses-shared.ts";
import type { AssistantMessage, Context, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function createModel(): Model<"azure-openai-responses"> {
return {
id: "gpt-5-mini",
name: "GPT-5 Mini",
api: "azure-openai-responses",
provider: "azure-openai-responses",
baseUrl: "https://example.invalid",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
}
function createOutput(model: Model<"azure-openai-responses">): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "pending",
timestamp: Date.now(),
};
}
async function* createEvents(
doneItem: ResponseReasoningItem,
completedItem: ResponseReasoningItem,
): AsyncIterable<ResponseStreamEvent> {
yield {
type: "response.output_item.added",
output_index: 0,
sequence_number: 0,
item: { type: "reasoning", id: doneItem.id, summary: [] },
} as ResponseStreamEvent;
yield {
type: "response.output_item.done",
output_index: 0,
sequence_number: 1,
item: doneItem,
} as ResponseStreamEvent;
yield {
type: "response.completed",
sequence_number: 2,
response: {
id: "resp_test",
status: "completed",
output: [completedItem],
},
} as ResponseStreamEvent;
}
function getReplayedReasoning(model: Model<"azure-openai-responses">, assistant: AssistantMessage) {
const context: Context = {
messages: [
{ role: "user", content: "first", timestamp: Date.now() - 1 },
assistant,
{ role: "user", content: "follow-up", timestamp: Date.now() },
],
};
const input = convertResponsesMessages(model, context, new Set(["azure-openai-responses"]));
return input.find((item) => item.type === "reasoning");
}
describe("Azure OpenAI Responses reasoning replay", () => {
it("preserves existing encrypted_content from output_item.done", async () => {
const model = createModel();
const output = createOutput(model);
const doneItem: ResponseReasoningItem = {
type: "reasoning",
id: "rs_done",
summary: [],
encrypted_content: "from-output-item-done",
};
const completedItem: ResponseReasoningItem = {
...doneItem,
encrypted_content: "from-response-completed",
};
await processResponsesStream(
createEvents(doneItem, completedItem),
output,
new AssistantMessageEventStream(),
model,
);
expect(getReplayedReasoning(model, output)).toMatchObject({
type: "reasoning",
id: "rs_done",
encrypted_content: "from-output-item-done",
});
});
it("fills encrypted_content when output_item.done omitted it", async () => {
const model = createModel();
const output = createOutput(model);
const doneItem: ResponseReasoningItem = {
type: "reasoning",
id: "rs_missing",
summary: [],
};
const completedItem: ResponseReasoningItem = {
...doneItem,
encrypted_content: "from-response-completed",
};
await processResponsesStream(
createEvents(doneItem, completedItem),
output,
new AssistantMessageEventStream(),
model,
);
expect(getReplayedReasoning(model, output)).toMatchObject({
type: "reasoning",
id: "rs_missing",
encrypted_content: "from-response-completed",
});
});
});

View File

@@ -0,0 +1,79 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { stream, streamSimple } from "../src/api/azure-openai-responses.ts";
import type { Model } from "../src/types.ts";
const model: Model<"azure-openai-responses"> = {
id: "test-deployment",
name: "Test Deployment",
api: "azure-openai-responses",
provider: "azure-openai-responses",
baseUrl: "http://127.0.0.1:9/openai/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 10_000,
maxTokens: 1_000,
};
describe("Azure OpenAI tool choice", () => {
it("forwards provider-specific tool choice while preserving tool definitions", async () => {
let payload: unknown;
const result = stream(
model,
{
messages: [{ role: "user", content: "Summarize this", timestamp: 1 }],
tools: [
{
name: "read",
description: "Read a file",
parameters: Type.Object({ path: Type.String() }),
},
],
},
{
apiKey: "test-key",
toolChoice: "required",
onPayload: (requestPayload) => {
payload = requestPayload;
throw new Error("payload captured");
},
},
);
await result.result();
expect(payload).toMatchObject({ tool_choice: "required" });
expect((payload as { tools?: unknown[] }).tools).toHaveLength(1);
});
it("forwards provider-neutral tool choice from simple options", async () => {
let payload: unknown;
const result = streamSimple(
model,
{
messages: [{ role: "user", content: "Summarize this", timestamp: 1 }],
tools: [
{
name: "read",
description: "Read a file",
parameters: Type.Object({ path: Type.String() }),
},
],
},
{
apiKey: "test-key",
toolChoice: "none",
onPayload: (requestPayload) => {
payload = requestPayload;
throw new Error("payload captured");
},
},
);
await result.result();
expect(payload).toMatchObject({ tool_choice: "none" });
expect((payload as { tools?: unknown[] }).tools).toHaveLength(1);
});
});

View File

@@ -0,0 +1,28 @@
/**
* Utility functions for Azure OpenAI tests
*/
function parseDeploymentNameMap(value: string | undefined): Map<string, string> {
const map = new Map<string, string>();
if (!value) return map;
for (const entry of value.split(",")) {
const trimmed = entry.trim();
if (!trimmed) continue;
const [modelId, deploymentName] = trimmed.split("=", 2);
if (!modelId || !deploymentName) continue;
map.set(modelId.trim(), deploymentName.trim());
}
return map;
}
export function hasAzureOpenAICredentials(): boolean {
const hasKey = !!process.env.AZURE_OPENAI_API_KEY;
const hasBaseUrl = !!(process.env.AZURE_OPENAI_BASE_URL || process.env.AZURE_OPENAI_RESOURCE_NAME);
return hasKey && hasBaseUrl;
}
export function resolveAzureDeploymentName(modelId: string): string | undefined {
const mapValue = process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP;
if (!mapValue) return undefined;
return parseDeploymentNameMap(mapValue).get(modelId);
}

View File

@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts";
import { getSupportedThinkingLevels } from "../src/models.ts";
const originalBasetenApiKey = process.env.BASETEN_API_KEY;
afterEach(() => {
if (originalBasetenApiKey === undefined) {
delete process.env.BASETEN_API_KEY;
} else {
process.env.BASETEN_API_KEY = originalBasetenApiKey;
}
});
describe("Baseten models", () => {
it("keeps both GLM 5.2 endpoints text-only", () => {
expect(getModel("baseten", "zai-org/GLM-5.2").input).toEqual(["text"]);
expect(getModel("baseten", "zai-org/GLM-5.2-Fast").input).toEqual(["text"]);
});
it("models Kimi K2.6 reasoning as an explicit off/on toggle", async () => {
const model = getModel("baseten", "moonshotai/Kimi-K2.6");
let payload: Record<string, unknown> | undefined;
expect(model.thinkingLevelMap).toEqual({
off: "off",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: null,
});
expect(model.compat).toMatchObject({
supportsReasoningEffort: false,
thinkingFormat: "baseten",
chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } },
});
expect(getSupportedThinkingLevels(model)).toEqual(["off", "high"]);
await streamSimple(
model,
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
{
apiKey: "test-baseten-key",
reasoning: "high",
onPayload: (value) => {
payload = value as Record<string, unknown>;
throw new Error("payload captured");
},
},
).result();
expect(payload?.chat_template_args).toEqual({ enable_thinking: true });
expect(payload?.reasoning_effort).toBeUndefined();
});
it("sends Baseten chat_template_args with reasoning effort", async () => {
const model = getModel("baseten", "zai-org/GLM-5.2");
let payload: Record<string, unknown> | undefined;
await streamSimple(
model,
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
{
apiKey: "test-baseten-key",
reasoning: "high",
onPayload: (value) => {
payload = value as Record<string, unknown>;
throw new Error("payload captured");
},
},
).result();
expect(payload?.chat_template_args).toEqual({ enable_thinking: true });
expect(payload?.reasoning_effort).toBe("high");
});
it("disables Baseten opt-in reasoning when thinking is off", async () => {
const model = getModel("baseten", "zai-org/GLM-5.2");
let payload: Record<string, unknown> | undefined;
await streamSimple(
model,
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
{
apiKey: "test-baseten-key",
onPayload: (value) => {
payload = value as Record<string, unknown>;
throw new Error("payload captured");
},
},
).result();
expect(payload?.chat_template_args).toEqual({ enable_thinking: false });
expect(payload?.reasoning_effort).toBe("none");
});
it("resolves BASETEN_API_KEY from the environment", () => {
process.env.BASETEN_API_KEY = "test-baseten-key";
expect(findEnvKeys("baseten")).toEqual(["BASETEN_API_KEY"]);
expect(getEnvApiKey("baseten")).toBe("test-baseten-key");
});
});

View File

@@ -0,0 +1,410 @@
import { Type } from "typebox";
import { describe, expect, it, vi } from "vitest";
const bedrockMock = vi.hoisted(() => ({
constructorCalls: [] as Array<Record<string, unknown>>,
streamEvents: undefined as unknown[] | undefined,
}));
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
class BedrockRuntimeServiceException extends Error {}
class BedrockRuntimeClient {
constructor(config: Record<string, unknown>) {
bedrockMock.constructorCalls.push(config);
}
send(): Promise<unknown> {
if (bedrockMock.streamEvents) {
const events = bedrockMock.streamEvents;
return Promise.resolve({
$metadata: { httpStatusCode: 200 },
stream: (async function* () {
yield* events;
})(),
});
}
return Promise.reject(new Error("mock send"));
}
}
class ConverseStreamCommand {
readonly input: unknown;
constructor(input: unknown) {
this.input = input;
}
}
return {
BedrockRuntimeClient,
BedrockRuntimeServiceException,
ConverseStreamCommand,
StopReason: {
END_TURN: "end_turn",
STOP_SEQUENCE: "stop_sequence",
MAX_TOKENS: "max_tokens",
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
TOOL_USE: "tool_use",
},
CachePointType: { DEFAULT: "default" },
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
};
});
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
import type { Context, Message, Model } from "../src/types.ts";
const baseModel: Model<"bedrock-converse-stream"> = {
id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
name: "Claude Sonnet 4.5 (US)",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: true,
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 64000,
compat: { supportsStrictMode: true },
};
const novaModel: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "amazon.nova-lite-v1:0",
name: "Nova Lite",
reasoning: false,
compat: undefined,
};
async function capturePayload(context: Context, model = baseModel): Promise<unknown> {
let capturedPayload: unknown;
const s = streamBedrock(model, context, {
cacheRetention: "none",
signal: AbortSignal.abort(),
onPayload: (payload) => {
capturedPayload = payload;
return payload;
},
});
for await (const event of s) {
if (event.type === "error") break;
}
return capturedPayload;
}
describe("Bedrock constrained sampling", () => {
it("gates native strict tool use by model capability", async () => {
const context: Context = {
messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }],
tools: [
{
name: "lookup",
description: "Look up a value",
parameters: Type.Object({ value: Type.String() }),
constrainedSampling: { type: "json_schema", strict: "require" },
},
],
};
const payload = await capturePayload(context);
const toolConfig = (payload as { toolConfig: { tools: Array<{ toolSpec: { strict?: boolean } }> } }).toolConfig;
expect(toolConfig.tools[0].toolSpec.strict).toBe(true);
context.tools![0].constrainedSampling = { type: "json_schema", strict: "prefer" };
const novaPayload = await capturePayload(context, novaModel);
const novaToolConfig = (
novaPayload as {
toolConfig: { tools: Array<{ toolSpec: { strict?: boolean } }> };
}
).toolConfig;
expect(novaToolConfig.tools[0].toolSpec.strict).toBeUndefined();
});
});
describe("Bedrock tool arguments", () => {
it("preserves empty property names in streamed tool arguments", async () => {
bedrockMock.streamEvents = [
{ messageStart: { role: "assistant" } },
{
contentBlockStart: {
contentBlockIndex: 0,
start: { toolUse: { toolUseId: "tool-1", name: "edit" } },
},
},
{
contentBlockDelta: {
contentBlockIndex: 0,
delta: {
toolUse: {
input: '{"path":"/workspace/foobar/file.js","edits":[{"oldText":"first","newText":"updated first"},{"oldText":"second","newText":"updated second","":""}]}',
},
},
},
},
{ contentBlockStop: { contentBlockIndex: 0 } },
{ messageStop: { stopReason: "tool_use" } },
];
try {
const message = await streamBedrock(
baseModel,
{ messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }] },
{ cacheRetention: "none" },
).result();
expect(message.content[0]).toEqual({
type: "toolCall",
id: "tool-1",
name: "edit",
arguments: {
path: "/workspace/foobar/file.js",
edits: [
{ oldText: "first", newText: "updated first" },
{ oldText: "second", newText: "updated second", "": "" },
],
},
});
} finally {
bedrockMock.streamEvents = undefined;
}
});
});
describe("bedrock convertMessages skips unknown content types", () => {
it("skips unknown user content blocks instead of throwing", async () => {
const messages: Message[] = [
{
role: "user",
content: [
{ type: "text", text: "hello" },
{ type: "unknown", data: "foo" },
] as any,
timestamp: Date.now(),
},
];
const payload = await capturePayload({ messages });
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toHaveLength(1);
expect(p.messages[0].content[0]).toEqual({ text: "hello" });
});
it("skips unknown assistant content blocks instead of throwing", async () => {
const messages: Message[] = [
{
role: "assistant",
content: [
{ type: "text", text: "hello" },
{ type: "unknown", data: "foo" },
] as any,
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: baseModel.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
];
const payload = await capturePayload({ messages });
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toHaveLength(1);
expect(p.messages[0].content[0]).toEqual({ text: "hello" });
});
it("replaces user messages with only unknown content blocks with a placeholder", async () => {
const messages: Message[] = [
{
role: "user",
content: [{ type: "unknown", data: "foo" }] as any,
timestamp: Date.now(),
},
];
const payload = await capturePayload({ messages });
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toEqual([{ text: "<empty>" }]);
});
it("replaces blank user string content with a placeholder", async () => {
const payload = await capturePayload({
messages: [{ role: "user", content: " ", timestamp: Date.now() }],
});
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toEqual([{ text: "<empty>" }]);
});
it("filters blank user text blocks when other content remains", async () => {
const payload = await capturePayload({
messages: [
{
role: "user",
content: [
{ type: "text", text: "" },
{ type: "text", text: "hello" },
],
timestamp: Date.now(),
},
],
});
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toEqual([{ text: "hello" }]);
});
it("replaces user content emptied by surrogate sanitization with a placeholder", async () => {
const payload = await capturePayload({
messages: [{ role: "user", content: String.fromCharCode(0xd83d), timestamp: Date.now() }],
});
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toEqual([{ text: "<empty>" }]);
});
it("skips assistant text blocks emptied by surrogate sanitization", async () => {
const messages: Message[] = [
{
role: "assistant",
content: [{ type: "text", text: String.fromCharCode(0xd83d) }],
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: baseModel.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
];
const payload = await capturePayload({ messages });
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(0);
});
it("replaces blank tool result content with a placeholder", async () => {
const messages: Message[] = [
{
role: "toolResult",
toolCallId: "tool-1",
toolName: "tool",
content: [{ type: "text", text: "" }],
isError: false,
timestamp: Date.now(),
},
];
const payload = await capturePayload({ messages });
expect(payload).toBeDefined();
const p = payload as {
messages: Array<{ role: string; content: Array<{ toolResult: { content: unknown[] } }> }>;
};
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content[0].toolResult.content).toEqual([{ text: "<empty>" }]);
});
it("skips assistant messages with only unknown content blocks", async () => {
const messages: Message[] = [
{
role: "assistant",
content: [{ type: "unknown", data: "foo" }] as any,
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: baseModel.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
];
const payload = await capturePayload({ messages });
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(0);
});
it("removes empty property names only from replayed Bedrock input", async () => {
const toolArguments = {
path: "/workspace/foobar/file.js",
edits: [
{ oldText: "first", newText: "updated first" },
{ oldText: "second", newText: "updated second", "": "" },
],
};
const messages: Message[] = [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "tool-1",
name: "edit",
arguments: toolArguments,
},
],
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: baseModel.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "tool-1",
toolName: "edit",
content: [{ type: "text", text: "done" }],
isError: false,
timestamp: Date.now(),
},
{ role: "user", content: "Continue", timestamp: Date.now() },
];
const payload = await capturePayload({ messages });
const p = payload as {
messages: Array<{ content: Array<{ toolUse?: { input: unknown } }> }>;
};
expect(p.messages[0].content[0].toolUse?.input).toEqual({
path: "/workspace/foobar/file.js",
edits: [
{ oldText: "first", newText: "updated first" },
{ oldText: "second", newText: "updated second" },
],
});
expect(toolArguments.edits[1]).toEqual({ oldText: "second", newText: "updated second", "": "" });
});
});

View File

@@ -0,0 +1,115 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const bedrockMock = vi.hoisted(() => ({
constructorCalls: [] as Array<Record<string, unknown>>,
}));
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
class BedrockRuntimeServiceException extends Error {}
class BedrockRuntimeClient {
constructor(config: Record<string, unknown>) {
bedrockMock.constructorCalls.push(config);
}
send(): Promise<never> {
return Promise.reject(new Error("mock send"));
}
}
class ConverseStreamCommand {
readonly input: unknown;
constructor(input: unknown) {
this.input = input;
}
}
return {
BedrockRuntimeClient,
BedrockRuntimeServiceException,
ConverseStreamCommand,
StopReason: {
END_TURN: "end_turn",
STOP_SEQUENCE: "stop_sequence",
MAX_TOKENS: "max_tokens",
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
TOOL_USE: "tool_use",
},
CachePointType: { DEFAULT: "default" },
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
};
});
import type { BedrockOptions } from "../src/api/bedrock-converse-stream.ts";
import { stream as streamBedrock } from "../src/compat.ts";
import { getBuiltinModel } from "../src/providers/all.ts";
import type { Context, Model } from "../src/types.ts";
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
async function captureClientConfig(
model: Model<"bedrock-converse-stream">,
options: BedrockOptions = {},
): Promise<Record<string, unknown>> {
bedrockMock.constructorCalls.length = 0;
await streamBedrock(model, context, { cacheRetention: "none", ...options }).result();
expect(bedrockMock.constructorCalls).toHaveLength(1);
return bedrockMock.constructorCalls[0];
}
describe("bedrock credential priority", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("prefers explicit and scoped profiles over ambient AWS access keys", async () => {
vi.stubEnv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE");
vi.stubEnv("AWS_SECRET_ACCESS_KEY", "secretexample");
const model = getBuiltinModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
let config = await captureClientConfig(model, { profile: "explicit-profile" });
expect(config.profile).toBe("explicit-profile");
expect(config.credentials).toBeUndefined();
config = await captureClientConfig(model, { env: { AWS_PROFILE: "scoped-profile" } });
expect(config.profile).toBe("scoped-profile");
expect(config.credentials).toBeUndefined();
});
it("uses ambient AWS access keys when no profile is configured", async () => {
vi.stubEnv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE");
vi.stubEnv("AWS_SECRET_ACCESS_KEY", "secretexample");
const model = getBuiltinModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const config = await captureClientConfig(model);
expect(config.profile).toBeUndefined();
expect(config.credentials).toEqual({
accessKeyId: "AKIAEXAMPLE",
secretAccessKey: "secretexample",
});
});
it("uses ambient AWS access keys when only an ambient profile is set", async () => {
vi.stubEnv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE");
vi.stubEnv("AWS_SECRET_ACCESS_KEY", "secretexample");
vi.stubEnv("AWS_PROFILE", "ambient-profile");
const model = getBuiltinModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const config = await captureClientConfig(model);
expect(config.profile).toBe("ambient-profile");
expect(config.credentials).toEqual({
accessKeyId: "AKIAEXAMPLE",
secretAccessKey: "secretexample",
});
});
});

View File

@@ -0,0 +1,202 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type MiddlewareHandler = (next: (args: unknown) => Promise<unknown>) => (args: unknown) => Promise<unknown>;
const bedrockMock = vi.hoisted(() => ({
middlewareRegistrations: [] as Array<{
handler: MiddlewareHandler;
opts: { step?: string; name?: string; priority?: string };
}>,
}));
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
class BedrockRuntimeServiceException extends Error {}
class BedrockRuntimeClient {
middlewareStack = {
add: (handler: MiddlewareHandler, opts: { step?: string; name?: string; priority?: string }) => {
bedrockMock.middlewareRegistrations.push({ handler, opts });
},
};
send(): Promise<never> {
return Promise.reject(new Error("mock send"));
}
}
class ConverseStreamCommand {
readonly input: unknown;
constructor(input: unknown) {
this.input = input;
}
}
return {
BedrockRuntimeClient,
BedrockRuntimeServiceException,
ConverseStreamCommand,
StopReason: {
END_TURN: "end_turn",
STOP_SEQUENCE: "stop_sequence",
MAX_TOKENS: "max_tokens",
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
TOOL_USE: "tool_use",
},
CachePointType: { DEFAULT: "default" },
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
};
});
import type { BedrockOptions } from "../src/api/bedrock-converse-stream.ts";
import { stream as streamBedrock, streamSimple as streamSimpleBedrock } from "../src/api/bedrock-converse-stream.ts";
import { getModel } from "../src/compat.ts";
import type { Context, Model } from "../src/types.ts";
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
const MIDDLEWARE_NAME = "pi-ai-custom-headers";
function getModelFixture(): Model<"bedrock-converse-stream"> {
return getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
}
/**
* Drive a stream to completion so the middleware (registered before `client.send`)
* is captured even though the mocked `send()` rejects. Errors are swallowed because
* the rejecting mock is expected — we only care about the recorded registrations.
*/
async function driveBedrock(options: BedrockOptions): Promise<void> {
await streamBedrock(getModelFixture(), context, options)
.result()
.catch(() => undefined);
}
function findCustomHeadersRegistration() {
const matches = bedrockMock.middlewareRegistrations.filter((r) => r.opts.name === MIDDLEWARE_NAME);
return matches;
}
beforeEach(() => {
bedrockMock.middlewareRegistrations.length = 0;
});
describe("bedrock custom headers middleware", () => {
it("VC1: registers a build-step middleware that injects the caller header (happy path)", async () => {
await driveBedrock({ cacheRetention: "none", headers: { "x-custom": "v" } });
const registrations = findCustomHeadersRegistration();
expect(registrations).toHaveLength(1);
const [reg] = registrations;
expect(reg.opts.step).toBe("build");
expect(reg.opts.priority).toBe("low");
expect(reg.opts.name).toBe(MIDDLEWARE_NAME);
const nextSpy = vi.fn(async (a: unknown) => a);
const fakeArgs = { request: { headers: {} as Record<string, string> } };
await reg.handler(nextSpy)(fakeArgs);
expect(fakeArgs.request.headers["x-custom"]).toBe("v");
expect(nextSpy).toHaveBeenCalledTimes(1);
expect(nextSpy).toHaveBeenCalledWith(fakeArgs);
});
it("VC2: skips reserved headers case-insensitively while applying allowed ones", async () => {
await driveBedrock({
cacheRetention: "none",
headers: {
authorization: "evil",
"x-amz-date": "evil",
"x-allowed": "ok",
Authorization: "evil2",
"X-Amz-Date": "evil2",
HOST: "evil3",
},
});
const [reg] = findCustomHeadersRegistration();
expect(reg).toBeDefined();
const nextSpy = vi.fn(async (a: unknown) => a);
const fakeArgs = {
request: {
headers: {
authorization: "real-auth",
"x-amz-date": "real-date",
host: "real-host",
} as Record<string, string>,
},
};
await reg.handler(nextSpy)(fakeArgs);
expect(fakeArgs.request.headers.authorization).toBe("real-auth");
expect(fakeArgs.request.headers["x-amz-date"]).toBe("real-date");
expect(fakeArgs.request.headers.host).toBe("real-host");
expect(fakeArgs.request.headers["x-allowed"]).toBe("ok");
// Mixed-case reserved keys must be skipped too: a case-sensitive guard would
// add them back as distinct capitalised keys. Assert no such leak occurred and
// that the only new key beyond the three pre-existing ones is `x-allowed`.
expect(fakeArgs.request.headers.Authorization).toBeUndefined();
expect(fakeArgs.request.headers["X-Amz-Date"]).toBeUndefined();
expect(fakeArgs.request.headers.HOST).toBeUndefined();
expect(Object.keys(fakeArgs.request.headers).sort()).toEqual(
["authorization", "host", "x-allowed", "x-amz-date"].sort(),
);
expect(nextSpy).toHaveBeenCalledTimes(1);
});
it("VC3: registers no middleware when headers is undefined", async () => {
await driveBedrock({ cacheRetention: "none" });
expect(findCustomHeadersRegistration()).toHaveLength(0);
});
it("VC3: registers no middleware when headers is empty", async () => {
await driveBedrock({ cacheRetention: "none", headers: {} });
expect(findCustomHeadersRegistration()).toHaveLength(0);
});
it("VC3 (structural guard): passes through unchanged when the request has no headers", async () => {
await driveBedrock({ cacheRetention: "none", headers: { "x-custom": "v" } });
const [reg] = findCustomHeadersRegistration();
expect(reg).toBeDefined();
const nextSpy = vi.fn(async (a: unknown) => a);
const argsNoHeaders = { request: {} };
await expect(reg.handler(nextSpy)(argsNoHeaders)).resolves.toBeDefined();
expect(nextSpy).toHaveBeenCalledWith(argsNoHeaders);
const argsUndefinedRequest = { request: undefined };
await expect(reg.handler(nextSpy)(argsUndefinedRequest)).resolves.toBeDefined();
expect(nextSpy).toHaveBeenCalledWith(argsUndefinedRequest);
expect(nextSpy).toHaveBeenCalledTimes(2);
});
it("VC4: streamSimpleBedrock forwards headers end-to-end (regression guard)", async () => {
await streamSimpleBedrock(getModelFixture(), context, { headers: { "x-custom": "v" } })
.result()
.catch(() => undefined);
const registrations = findCustomHeadersRegistration();
expect(registrations).toHaveLength(1);
const [reg] = registrations;
expect(reg.opts.step).toBe("build");
const nextSpy = vi.fn(async (a: unknown) => a);
const fakeArgs = { request: { headers: {} as Record<string, string> } };
await reg.handler(nextSpy)(fakeArgs);
expect(fakeArgs.request.headers["x-custom"]).toBe("v");
});
});

View File

@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const bedrockMock = vi.hoisted(() => ({
constructorCalls: [] as Array<Record<string, unknown>>,
}));
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
class BedrockRuntimeServiceException extends Error {}
class BedrockRuntimeClient {
constructor(config: Record<string, unknown>) {
bedrockMock.constructorCalls.push(config);
}
send(): Promise<never> {
return Promise.reject(new Error("mock send"));
}
}
class ConverseStreamCommand {
readonly input: unknown;
constructor(input: unknown) {
this.input = input;
}
}
return {
BedrockRuntimeClient,
BedrockRuntimeServiceException,
ConverseStreamCommand,
StopReason: {
END_TURN: "end_turn",
STOP_SEQUENCE: "stop_sequence",
MAX_TOKENS: "max_tokens",
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
TOOL_USE: "tool_use",
},
CachePointType: { DEFAULT: "default" },
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
};
});
import type { BedrockOptions } from "../src/api/bedrock-converse-stream.ts";
import { getModel, stream as streamBedrock } from "../src/compat.ts";
import type { Context, Model } from "../src/types.ts";
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
const originalAwsRegion = process.env.AWS_REGION;
const originalAwsDefaultRegion = process.env.AWS_DEFAULT_REGION;
const originalAwsProfile = process.env.AWS_PROFILE;
beforeEach(() => {
bedrockMock.constructorCalls.length = 0;
delete process.env.AWS_REGION;
delete process.env.AWS_DEFAULT_REGION;
delete process.env.AWS_PROFILE;
});
afterEach(() => {
if (originalAwsRegion === undefined) {
delete process.env.AWS_REGION;
} else {
process.env.AWS_REGION = originalAwsRegion;
}
if (originalAwsDefaultRegion === undefined) {
delete process.env.AWS_DEFAULT_REGION;
} else {
process.env.AWS_DEFAULT_REGION = originalAwsDefaultRegion;
}
if (originalAwsProfile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = originalAwsProfile;
}
});
async function captureClientConfig(
model: Model<"bedrock-converse-stream">,
options: BedrockOptions = {},
): Promise<Record<string, unknown>> {
bedrockMock.constructorCalls.length = 0;
await streamBedrock(model, context, { cacheRetention: "none", ...options }).result();
expect(bedrockMock.constructorCalls).toHaveLength(1);
return bedrockMock.constructorCalls[0];
}
describe("bedrock endpoint resolution", () => {
it("assigns eu-central-1 runtime URLs to built-in EU inference profiles", () => {
const model = getModel("amazon-bedrock", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0");
expect(model.baseUrl).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com");
});
it("does not pin standard AWS endpoints when AWS_REGION is configured", async () => {
process.env.AWS_REGION = "us-east-2";
const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const config = await captureClientConfig(model);
expect(config.region).toBe("us-east-2");
expect(config.endpoint).toBeUndefined();
});
it("derives region from a built-in EU endpoint when no region or profile is configured", async () => {
const model = getModel("amazon-bedrock", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0");
const config = await captureClientConfig(model);
expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com");
expect(config.region).toBe("eu-central-1");
});
it("handles missing regions for explicit, scoped, and ambient profiles", async () => {
const model = getModel("amazon-bedrock", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0");
let config = await captureClientConfig(model, { profile: "bedrock-profile" });
expect(config.profile).toBe("bedrock-profile");
expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com");
expect(config.region).toBe("eu-central-1");
config = await captureClientConfig(model, { env: { AWS_PROFILE: "scoped-bedrock-profile" } });
expect(config.profile).toBe("scoped-bedrock-profile");
expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com");
expect(config.region).toBe("eu-central-1");
process.env.AWS_PROFILE = "ambient-bedrock-profile";
config = await captureClientConfig(model);
expect(config.profile).toBe("ambient-bedrock-profile");
expect(config.endpoint).toBeUndefined();
expect(config.region).toBeUndefined();
});
it("still passes custom Bedrock endpoints through to the SDK client", async () => {
process.env.AWS_REGION = "us-west-2";
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
baseUrl: "https://bedrock-vpc.example.com",
};
const config = await captureClientConfig(model);
expect(config.endpoint).toBe("https://bedrock-vpc.example.com");
expect(config.region).toBe("us-west-2");
});
it("extracts region from inference profile ARN regardless of AWS_REGION", async () => {
process.env.AWS_REGION = "us-east-1";
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/abc123",
};
const config = await captureClientConfig(model);
expect(config.region).toBe("us-west-2");
});
it("extracts region from GovCloud inference profile ARN", async () => {
process.env.AWS_REGION = "us-east-1";
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:application-inference-profile/abc123",
};
const config = await captureClientConfig(model);
expect(config.region).toBe("us-gov-west-1");
});
it("preserves ambient AWS auth for custom model IDs through compat dispatch", async () => {
process.env.AWS_PROFILE = "bedrock-profile";
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/example",
};
const config = await captureClientConfig(model);
expect(config.profile).toBe("bedrock-profile");
expect(config.token).toBeUndefined();
expect(config.authSchemePreference).toBeUndefined();
});
it("uses the generic API key option as a Bedrock bearer token", async () => {
const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const config = await captureClientConfig(model, { apiKey: "bedrock-api-key" });
expect(config.token).toEqual({ token: "bedrock-api-key" });
expect(config.authSchemePreference).toEqual(["httpBearerAuth"]);
});
});

View File

@@ -0,0 +1,212 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type SendResult = { kind: "reject"; error: unknown } | { kind: "resolve"; response: unknown };
const bedrockMock = vi.hoisted(() => ({
send: undefined as SendResult | undefined,
// Exposed so tests can build errors that are real instances of the mocked base class.
ServiceException: undefined as unknown as new (message?: string) => Error,
}));
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
class BedrockRuntimeServiceException extends Error {}
bedrockMock.ServiceException = BedrockRuntimeServiceException;
class BedrockRuntimeClient {
middlewareStack = { add: () => {} };
send(): Promise<unknown> {
const outcome = bedrockMock.send;
if (!outcome) return Promise.reject(new Error("test did not configure a send outcome"));
return outcome.kind === "reject" ? Promise.reject(outcome.error) : Promise.resolve(outcome.response);
}
}
class ConverseStreamCommand {
readonly input: unknown;
constructor(input: unknown) {
this.input = input;
}
}
return {
BedrockRuntimeClient,
BedrockRuntimeServiceException,
ConverseStreamCommand,
StopReason: {
END_TURN: "end_turn",
STOP_SEQUENCE: "stop_sequence",
MAX_TOKENS: "max_tokens",
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
TOOL_USE: "tool_use",
},
CachePointType: { DEFAULT: "default" },
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
};
});
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
import { getModel } from "../src/compat.ts";
import type { AssistantMessage, Context, Model } from "../src/types.ts";
import type { AssistantMessageDiagnostic } from "../src/utils/diagnostics.ts";
const DIAGNOSTIC_TYPE = "bedrock_response_failure";
const VALIDATION_MESSAGE = "The provided model identifier is invalid.";
const REQUEST_ID = "11111111-2222-3333-4444-555555555555";
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
function getModelFixture(): Model<"bedrock-converse-stream"> {
return getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
}
/** What the SDK's `handleError` path throws for a non-2xx response; `name` is the modeled AWS error code. */
function makeServiceException(name: string, extra: Record<string, unknown> = {}): Error {
const error = new bedrockMock.ServiceException(VALIDATION_MESSAGE) as Error & Record<string, unknown>;
error.name = name;
Object.assign(error, extra);
return error;
}
/** Fails after `messageStart`, throwing from inside the iterator like `getMessageUnmarshaller` does. */
function respondWithFailingStream(thrown: unknown): SendResult {
return {
kind: "resolve",
response: {
$metadata: { httpStatusCode: 200, requestId: REQUEST_ID },
stream: (async function* () {
yield { messageStart: { role: "assistant" } };
throw thrown;
})(),
},
};
}
async function runBedrock(signal?: AbortSignal): Promise<AssistantMessage> {
return streamBedrock(getModelFixture(), context, { cacheRetention: "none", signal }).result();
}
function findDiagnostic(message: AssistantMessage): AssistantMessageDiagnostic | undefined {
return message.diagnostics?.find((d) => d.type === DIAGNOSTIC_TYPE);
}
beforeEach(() => {
bedrockMock.send = undefined;
});
describe("bedrock failure diagnostics", () => {
it("records status, error code and request id for a non-2xx from client.send()", async () => {
bedrockMock.send = {
kind: "reject",
error: makeServiceException("ValidationException", {
$metadata: { httpStatusCode: 400, requestId: REQUEST_ID },
}),
};
const message = await runBedrock();
const diagnostic = findDiagnostic(message);
expect(message.stopReason).toBe("error");
expect(diagnostic?.details).toEqual({ status: 400, errorCode: "ValidationException", requestId: REQUEST_ID });
expect(diagnostic?.error).toBeUndefined();
expect(Object.keys(diagnostic ?? {}).sort()).toEqual(["details", "timestamp", "type"]);
});
it("leaves errorMessage untouched so retry classification is unaffected", async () => {
bedrockMock.send = {
kind: "reject",
error: makeServiceException("ValidationException", {
$metadata: { httpStatusCode: 400, requestId: REQUEST_ID },
$response: { body: { pipe: () => {} } },
}),
};
expect((await runBedrock()).errorMessage).toBe(`Validation error: ${VALIDATION_MESSAGE}`);
});
it("reports only the request id for a modeled mid-stream exception", async () => {
// The SDK throws a bare object literal here, so the code is genuinely unavailable.
bedrockMock.send = respondWithFailingStream({ message: "Too many requests, please wait." });
const message = await runBedrock();
expect(message.stopReason).toBe("error");
expect(findDiagnostic(message)?.details).toEqual({ requestId: REQUEST_ID });
});
it("captures the error code for an unmodeled mid-stream error", async () => {
// This branch throws a real `Error` named after the frame's `:error-code`.
const unmodeled = new Error("Model stream terminated unexpectedly.");
unmodeled.name = "ModelStreamErrorException";
bedrockMock.send = respondWithFailingStream(unmodeled);
expect(findDiagnostic(await runBedrock())?.details).toEqual({
errorCode: "ModelStreamErrorException",
requestId: REQUEST_ID,
});
});
it("does not report a transport failure name as a provider error code", async () => {
// Real `Error`s with informative names, but not AWS codes; modeled ones end in "Exception".
const timeout = new Error("Connection timed out after 1000 ms");
timeout.name = "TimeoutError";
bedrockMock.send = respondWithFailingStream(timeout);
expect(findDiagnostic(await runBedrock())?.details).toEqual({ requestId: REQUEST_ID });
});
it("emits no diagnostic when the failure carries no provider metadata", async () => {
bedrockMock.send = { kind: "reject", error: new Error("socket hang up") };
const message = await runBedrock();
expect(message.stopReason).toBe("error");
expect(message.errorMessage).toBe("socket hang up");
expect(findDiagnostic(message)).toBeUndefined();
});
it("emits no diagnostic for an aborted turn", async () => {
const controller = new AbortController();
controller.abort();
bedrockMock.send = {
kind: "reject",
error: makeServiceException("ValidationException", {
$metadata: { httpStatusCode: 400, requestId: REQUEST_ID },
}),
};
const message = await runBedrock(controller.signal);
expect(message.stopReason).toBe("aborted");
expect(findDiagnostic(message)).toBeUndefined();
});
it("drops header-derived values that exceed the length bound", async () => {
bedrockMock.send = {
kind: "reject",
error: makeServiceException(`${"E".repeat(5000)}Exception`, {
$metadata: { httpStatusCode: 400, requestId: "R".repeat(5000) },
}),
};
expect(findDiagnostic(await runBedrock())?.details).toEqual({ status: 400 });
});
it("omits the SDK's Unknown placeholder instead of reporting it as a code", async () => {
// The SDK's fallback when the response carried no `x-amzn-errortype`.
bedrockMock.send = {
kind: "reject",
error: makeServiceException("Unknown", { $metadata: { httpStatusCode: 403, requestId: REQUEST_ID } }),
};
expect(findDiagnostic(await runBedrock())?.details).toEqual({
status: 403,
requestId: REQUEST_ID,
});
});
});

View File

@@ -0,0 +1,70 @@
/**
* A test suite to ensure all configured Amazon Bedrock models are usable.
*
* This is here to make sure we got correct model identifiers from models.dev and other sources.
* Because Amazon Bedrock requires cross-region inference in some models,
* plain model identifiers are not always usable and it requires tweaking of model identifiers to use cross-region inference.
* See https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system for more details.
*
* This test suite is not enabled by default unless AWS credentials and `BEDROCK_EXTENSIVE_MODEL_TEST` environment variables are set.
* This test suite takes ~2 minutes to run. Because not all models are available in all regions,
* it's recommended to use `us-west-2` region for best coverage for running this test suite.
*
* You can run this test suite with:
* ```bash
* $ AWS_REGION=us-west-2 BEDROCK_EXTENSIVE_MODEL_TEST=1 AWS_PROFILE=... npm test -- ./test/bedrock-models.test.ts
* ```
*/
import { describe, expect, it } from "vitest";
import { complete, getModels } from "../src/compat.ts";
import type { Context } from "../src/types.ts";
import { hasBedrockCredentials } from "./bedrock-utils.ts";
describe("Amazon Bedrock Models", () => {
const models = getModels("amazon-bedrock");
it("should get all available Bedrock models", () => {
expect(models.length).toBeGreaterThan(0);
console.log(`Found ${models.length} Bedrock models`);
});
it("exposes Claude Opus 5 through an inference profile only", () => {
expect(models.some((model) => model.id === "global.anthropic.claude-opus-5")).toBe(true);
expect(models.some((model) => model.id === "anthropic.claude-opus-5")).toBe(false);
});
if (hasBedrockCredentials() && process.env.BEDROCK_EXTENSIVE_MODEL_TEST) {
for (const model of models) {
it(`should make a simple request with ${model.id}`, { timeout: 10_000 }, async () => {
const context: Context = {
systemPrompt: "You are a helpful assistant. Be extremely concise.",
messages: [
{
role: "user",
content: "Reply with exactly: 'OK'",
timestamp: Date.now(),
},
],
};
const response = await complete(model, context);
expect(response.role).toBe("assistant");
expect(response.content).toBeTruthy();
expect(response.content.length).toBeGreaterThan(0);
expect(response.usage.input + response.usage.cacheRead).toBeGreaterThan(0);
expect(response.usage.output).toBeGreaterThan(0);
expect(response.errorMessage).toBeFalsy();
const textContent = response.content
.filter((b) => b.type === "text")
.map((b) => (b.type === "text" ? b.text : ""))
.join("")
.trim();
expect(textContent).toBeTruthy();
console.log(`${model.id}: ${textContent.substring(0, 100)}`);
});
}
}
});

View File

@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
const bedrockMock = vi.hoisted(() => ({
stopReason: "end_turn" as string,
}));
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
class BedrockRuntimeServiceException extends Error {}
class BedrockRuntimeClient {
middlewareStack = {
add: () => undefined,
};
async send(): Promise<unknown> {
return {
$metadata: { httpStatusCode: 200, requestId: "request-id" },
stream: (async function* () {
yield { messageStart: { role: "assistant" } };
yield { messageStop: { stopReason: bedrockMock.stopReason } };
})(),
};
}
}
class ConverseStreamCommand {
readonly input: unknown;
constructor(input: unknown) {
this.input = input;
}
}
return {
BedrockRuntimeClient,
BedrockRuntimeServiceException,
ConverseStreamCommand,
StopReason: {
END_TURN: "end_turn",
STOP_SEQUENCE: "stop_sequence",
MAX_TOKENS: "max_tokens",
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
TOOL_USE: "tool_use",
},
CachePointType: { DEFAULT: "default" },
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
};
});
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
import { getModel } from "../src/compat.ts";
import type { Context } from "../src/types.ts";
const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
describe("Bedrock raw stop reasons", () => {
it("preserves raw Bedrock stop reasons for successful stops", async () => {
bedrockMock.stopReason = "end_turn";
const message = await streamBedrock(model, context, { cacheRetention: "none" }).result();
expect(message.stopReason).toBe("stop");
expect(message.rawStopReason).toBe("end_turn");
expect(message.errorMessage).toBeUndefined();
});
it("preserves raw Bedrock stop reasons for provider error stops", async () => {
bedrockMock.stopReason = "guardrail_intervened";
const message = await streamBedrock(model, context, { cacheRetention: "none" }).result();
expect(message.stopReason).toBe("error");
expect(message.rawStopReason).toBe("guardrail_intervened");
expect(message.errorMessage).toBe("Provider stopped with: guardrail_intervened");
});
});

View File

@@ -0,0 +1,274 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* OpenAI models served through Bedrock Converse (e.g. `global.openai.gpt-5.6-terra`)
* return encrypted reasoning as the opaque `redactedContent` member of
* `reasoningContent`, not as `reasoningText`. The AWS SDK decodes the wire blob to
* `Uint8Array`.
* @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ReasoningContentBlockDelta.html
*/
const bedrockMock = vi.hoisted(() => {
const redactedBase64 = "cnNuXzVaVnJpZjRKMGJYSXFtV2RsZWRqN1FJRmVOaWtSUWJF";
return {
redactedBase64,
redactedBytes: new Uint8Array(Buffer.from(redactedBase64, "base64")),
streamEvents: undefined as unknown[] | undefined,
};
});
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
class BedrockRuntimeServiceException extends Error {}
class BedrockRuntimeClient {
send(): Promise<unknown> {
if (bedrockMock.streamEvents) {
const events = bedrockMock.streamEvents;
return Promise.resolve({
$metadata: { httpStatusCode: 200 },
stream: (async function* () {
yield* events;
})(),
});
}
return Promise.reject(new Error("mock send"));
}
}
class ConverseStreamCommand {
readonly input: unknown;
constructor(input: unknown) {
this.input = input;
}
}
return {
BedrockRuntimeClient,
BedrockRuntimeServiceException,
ConverseStreamCommand,
StopReason: {
END_TURN: "end_turn",
STOP_SEQUENCE: "stop_sequence",
MAX_TOKENS: "max_tokens",
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
TOOL_USE: "tool_use",
},
CachePointType: { DEFAULT: "default" },
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
};
});
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
import type { Context, Message, Model, ThinkingContent } from "../src/types.ts";
const gptModel: Model<"bedrock-converse-stream"> = {
id: "global.openai.gpt-5.6-terra",
name: "GPT-5.6 Terra (Global)",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.ap-northeast-1.amazonaws.com",
reasoning: true,
input: ["text"],
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const emptyUsage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
/** Mirrors the ConverseStream frames GPT-5.6 emits: encrypted reasoning, then text. */
function redactedReasoningEvents(): unknown[] {
return [
{ messageStart: { role: "assistant" } },
{
contentBlockDelta: {
contentBlockIndex: 0,
delta: { reasoningContent: { redactedContent: bedrockMock.redactedBytes } },
},
},
{ contentBlockStop: { contentBlockIndex: 0 } },
{ contentBlockDelta: { contentBlockIndex: 1, delta: { text: "done" } } },
{ contentBlockStop: { contentBlockIndex: 1 } },
{ messageStop: { stopReason: "end_turn" } },
];
}
interface BedrockRequestPayload {
messages: Array<{ role: string; content: Array<Record<string, unknown>> }>;
}
async function capturePayload(context: Context): Promise<BedrockRequestPayload> {
let capturedPayload: BedrockRequestPayload | undefined;
const s = streamBedrock(gptModel, context, {
cacheRetention: "none",
signal: AbortSignal.abort(),
onPayload: (payload) => {
capturedPayload = payload as BedrockRequestPayload;
return payload;
},
});
for await (const event of s) {
if (event.type === "error") break;
}
if (!capturedPayload) {
throw new Error("Expected Bedrock payload to be captured before request abort");
}
return capturedPayload;
}
describe("Bedrock redacted reasoning", () => {
beforeEach(() => {
bedrockMock.streamEvents = undefined;
});
it("does not fail the stream when reasoning arrives as redactedContent", async () => {
bedrockMock.streamEvents = redactedReasoningEvents();
const response = await streamBedrock(gptModel, {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
}).result();
expect(response.stopReason, response.errorMessage).not.toBe("error");
// Reasoning precedes the answer, matching the order Bedrock streamed it.
expect(response.content.map((c) => c.type)).toEqual(["thinking", "text"]);
expect(response.content[1]).toEqual({ type: "text", text: "done" });
});
it("preserves the encrypted reasoning payload on the assistant message", async () => {
bedrockMock.streamEvents = redactedReasoningEvents();
const response = await streamBedrock(gptModel, {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
}).result();
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
expect(thinking).toBeDefined();
// Same representation Anthropic redacted thinking already uses: the opaque
// payload rides in `thinkingSignature` with `redacted: true`.
expect(thinking?.redacted).toBe(true);
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
// The byte buffer is streaming scratch state: persisting it would bloat the
// session, since a Uint8Array serializes to an index-keyed object.
expect("redactedChunks" in thinking!).toBe(false);
});
it("encodes the payload when the stream never sends contentBlockStop", async () => {
bedrockMock.streamEvents = [
{ messageStart: { role: "assistant" } },
{
contentBlockDelta: {
contentBlockIndex: 0,
delta: { reasoningContent: { redactedContent: bedrockMock.redactedBytes } },
},
},
{ messageStop: { stopReason: "end_turn" } },
];
const response = await streamBedrock(gptModel, {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
}).result();
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
// Streaming scratch state must not survive into the persisted message.
expect("redactedChunks" in thinking!).toBe(false);
expect("index" in thinking!).toBe(false);
});
it("joins encrypted reasoning split across deltas", async () => {
const [head, tail] = [bedrockMock.redactedBytes.slice(0, 7), bedrockMock.redactedBytes.slice(7)];
bedrockMock.streamEvents = [
{ messageStart: { role: "assistant" } },
{ contentBlockDelta: { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: head } } } },
{ contentBlockDelta: { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: tail } } } },
{ contentBlockStop: { contentBlockIndex: 0 } },
{ messageStop: { stopReason: "end_turn" } },
];
const response = await streamBedrock(gptModel, {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
}).result();
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
// The placeholder marks the block once, not once per delta.
expect(thinking?.thinking).toBe("[Reasoning redacted]");
});
it("replays redacted reasoning as reasoningContent.redactedContent", async () => {
const messages: Message[] = [
{ role: "user", content: "hello", timestamp: Date.now() },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "", thinkingSignature: bedrockMock.redactedBase64, redacted: true },
{ type: "text", text: "done" },
],
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: gptModel.id,
usage: emptyUsage,
stopReason: "stop",
timestamp: Date.now(),
},
{ role: "user", content: "continue", timestamp: Date.now() },
];
const payload = await capturePayload({ messages });
const assistant = payload.messages.find((m: any) => m.role === "assistant");
expect(assistant).toBeDefined();
expect(assistant!.content).toEqual([
{ reasoningContent: { redactedContent: bedrockMock.redactedBytes } },
{ text: "done" },
]);
});
it("replays redacted reasoning before the toolUse block it belongs to", async () => {
// Bedrock rejects a tool continuation whose reasoning block is missing or
// reordered, so the opaque payload must land ahead of the matching toolUse.
const messages: Message[] = [
{ role: "user", content: "read the file", timestamp: Date.now() },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "", thinkingSignature: bedrockMock.redactedBase64, redacted: true },
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "/tmp/a.txt" } },
],
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: gptModel.id,
usage: emptyUsage,
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "tool-1",
toolName: "read",
content: [{ type: "text", text: "file body" }],
isError: false,
timestamp: Date.now(),
},
];
const payload = await capturePayload({ messages });
const assistant = payload.messages.find((m: any) => m.role === "assistant");
expect(assistant).toBeDefined();
expect(assistant!.content).toEqual([
{ reasoningContent: { redactedContent: bedrockMock.redactedBytes } },
{ toolUse: { toolUseId: "tool-1", name: "read", input: { path: "/tmp/a.txt" } } },
]);
});
});

View File

@@ -0,0 +1,63 @@
import { createServer, type Server } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
import { getModel } from "../src/compat.ts";
import type { Model, ProviderResponse } from "../src/types.ts";
const MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0";
let server: Server | undefined;
afterEach(async () => {
if (!server) return;
await new Promise<void>((resolve, reject) => {
server?.close((error) => (error ? reject(error) : resolve()));
});
server = undefined;
});
async function startBedrockResponseServer(): Promise<string> {
server = createServer((_req, res) => {
res.writeHead(200, {
"content-type": "application/vnd.amazon.eventstream",
"x-bifrost-provider": "bedrock",
"x-bifrost-resolved-model": MODEL_ID,
"x-amzn-requestid": "req-123",
});
res.end();
});
await new Promise<void>((resolve) => server?.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected TCP test server address");
return `http://127.0.0.1:${address.port}`;
}
describe("bedrock response headers", () => {
it("forwards raw Smithy response headers to onResponse", async () => {
const baseModel = getModel("amazon-bedrock", MODEL_ID) as Model<"bedrock-converse-stream">;
const model = { ...baseModel, baseUrl: await startBedrockResponseServer() };
const responses: ProviderResponse[] = [];
const result = await streamBedrock(
model,
{ messages: [{ role: "user", content: "hello", timestamp: Date.now() }] },
{
cacheRetention: "none",
env: { AWS_BEDROCK_FORCE_HTTP1: "1", AWS_BEDROCK_SKIP_AUTH: "1" },
onResponse: (response) => {
responses.push(response);
},
},
).result();
// The fake server intentionally returns an empty event stream; this assertion
// documents that the header callback still fires before stream consumption.
expect(result.stopReason).toBe("error");
expect(responses).toHaveLength(1);
expect(responses[0].status).toBe(200);
expect(responses[0].headers["x-amzn-requestid"]).toBe("req-123");
expect(responses[0].headers["x-bifrost-provider"]).toBe("bedrock");
expect(responses[0].headers["x-bifrost-resolved-model"]).toBe(MODEL_ID);
});
});

View File

@@ -0,0 +1,266 @@
import { describe, expect, it } from "vitest";
import { type BedrockOptions, stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
import { getModel } from "../src/compat.ts";
import type { Context, Model } from "../src/types.ts";
import { hasBedrockCredentials } from "./bedrock-utils.ts";
interface BedrockThinkingPayload {
additionalModelRequestFields?: {
thinking?: { type: string; budget_tokens?: number; display?: string };
output_config?: { effort?: string };
anthropic_beta?: string[];
};
}
class PayloadCaptured extends Error {
constructor() {
super("payload captured");
this.name = "PayloadCaptured";
}
}
function makeContext(): Context {
return {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
}
async function capturePayload(
model: Model<"bedrock-converse-stream">,
options?: BedrockOptions,
): Promise<BedrockThinkingPayload> {
let capturedPayload: BedrockThinkingPayload | undefined;
const s = streamBedrock(model, makeContext(), {
...options,
reasoning: options?.reasoning ?? "high",
onPayload: (payload) => {
capturedPayload = payload as BedrockThinkingPayload;
throw new PayloadCaptured();
},
});
for await (const event of s) {
if (event.type === "error") {
break;
}
}
if (!capturedPayload) {
throw new Error("Expected Bedrock payload to be captured before request abort");
}
return capturedPayload;
}
describe("Bedrock thinking payload", () => {
it("uses adaptive thinking for Claude Opus 4.8 when reasoning is enabled", async () => {
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "global.anthropic.claude-opus-4-8-v1",
name: "Claude Opus 4.8 (Global)",
};
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
});
it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.8", async () => {
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "global.anthropic.claude-opus-4-8-v1",
name: "Claude Opus 4.8 (Global)",
};
const payload = await capturePayload(model, { reasoning: "xhigh" });
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
});
it("uses adaptive thinking for Claude Fable 5 when reasoning is enabled", async () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5");
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
});
it("uses adaptive thinking for Claude Sonnet 5 when reasoning is enabled", async () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-5");
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
});
it("uses adaptive thinking for Claude Opus 5 when reasoning is enabled", async () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5");
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
});
it("maps xhigh reasoning to effort=xhigh for Claude Opus 5", async () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5");
const payload = await capturePayload(model, { reasoning: "xhigh" });
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
});
it("maps xhigh reasoning to effort=xhigh for Claude Fable 5", async () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5");
const payload = await capturePayload(model, { reasoning: "xhigh" });
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" });
});
it("omits display for GovCloud model ids on non-adaptive Claude thinking", async () => {
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0",
name: "Claude Sonnet 4.5 (GovCloud)",
};
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "enabled", budget_tokens: 16384 });
expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual(["interleaved-thinking-2025-05-14"]);
});
it("omits display for GovCloud regions on adaptive Claude thinking", async () => {
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "global.anthropic.claude-opus-4-8-v1",
name: "Claude Opus 4.8 (Global)",
};
const payload = await capturePayload(model, { region: "us-gov-west-1" });
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
});
});
describe.skipIf(!hasBedrockCredentials())("Bedrock Claude max tokens E2E", () => {
it(
"uses the model maxTokens cap instead of Bedrock's 4096-token default for adaptive Claude models",
{ retry: 2, timeout: 180000 },
async () => {
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-6");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
maxTokens: 6000,
};
const response = await streamBedrock(
model,
{
systemPrompt: "You are a deterministic text generator. Follow the requested output format exactly.",
messages: [
{
role: "user",
content:
"Output exactly 5200 repetitions of the token alpha, separated by single spaces. Do not number them. Do not use markdown. Do not add any other text.",
timestamp: Date.now(),
},
],
},
{ reasoning: "low" },
).result();
expect(response.stopReason, response.errorMessage).not.toBe("error");
expect(response.usage.output).toBeGreaterThan(4096);
},
);
});
describe("Application inference profile support", () => {
it("uses adaptive thinking when model.name contains the model name but ARN does not", async () => {
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile",
name: "Claude Opus 4.6",
};
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
});
it("injects cache points when model.name identifies a supported Claude model", async () => {
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile",
name: "Claude Sonnet 4.6",
};
let capturedPayload: any;
const s = streamBedrock(
model,
{
systemPrompt: "You are helpful.",
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
},
{
onPayload: (payload) => {
capturedPayload = payload;
throw new PayloadCaptured();
},
},
);
for await (const event of s) {
if (event.type === "error") break;
}
// System prompt should have a cache point
expect(capturedPayload.system).toHaveLength(2);
expect(capturedPayload.system[1]).toHaveProperty("cachePoint");
// Last user message should have a cache point
const lastMsg = capturedPayload.messages[capturedPayload.messages.length - 1];
const lastContent = lastMsg.content[lastMsg.content.length - 1];
expect(lastContent).toHaveProperty("cachePoint");
});
it("falls back to fixed-budget thinking for non-adaptive Claude via model.name", async () => {
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile",
name: "Claude Sonnet 4.5",
};
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toMatchObject({
type: "enabled",
budget_tokens: expect.any(Number),
});
expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual(["interleaved-thinking-2025-05-14"]);
});
});

View File

@@ -0,0 +1,18 @@
/**
* Utility functions for Amazon Bedrock tests
*/
/**
* Check if any valid AWS credentials are configured for Bedrock.
* Returns true if any of the following are set:
* - AWS_PROFILE (named profile from ~/.aws/credentials)
* - AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (IAM keys)
* - AWS_BEARER_TOKEN_BEDROCK (Bedrock API key)
*/
export function hasBedrockCredentials(): boolean {
return !!(
process.env.AWS_PROFILE ||
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
process.env.AWS_BEARER_TOKEN_BEDROCK
);
}

View File

@@ -0,0 +1,532 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
import { getModel, stream } from "../src/compat.ts";
import { MODELS } from "../src/models.generated.ts";
import type { Context, Model } from "../src/types.ts";
class PayloadCaptured extends Error {
constructor() {
super("payload captured");
this.name = "PayloadCaptured";
}
}
interface OpenAICompletionsCachePayload {
prompt_cache_key?: string;
prompt_cache_retention?: string;
}
interface OpenAIResponsesCachePayload extends OpenAICompletionsCachePayload {
prompt_cache_options?: { mode?: "explicit"; ttl?: "30m" };
}
function stopAfterPayload<TPayload>(capture: (payload: TPayload) => void): (payload: unknown) => never {
return (payload: unknown): never => {
capture(payload as TPayload);
throw new PayloadCaptured();
};
}
describe("Cache Retention (PI_CACHE_RETENTION)", () => {
const originalEnv = process.env.PI_CACHE_RETENTION;
beforeEach(() => {
delete process.env.PI_CACHE_RETENTION;
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.PI_CACHE_RETENTION = originalEnv;
} else {
delete process.env.PI_CACHE_RETENTION;
}
});
const context: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
describe("Anthropic Provider", () => {
it.skipIf(!process.env.ANTHROPIC_API_KEY)(
"should use default cache TTL (no ttl field) when PI_CACHE_RETENTION is not set",
async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
let capturedPayload: any = null;
const s = stream(model, context, {
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
// Consume the stream to trigger the request
for await (const _ of s) {
// Just consume
}
expect(capturedPayload).not.toBeNull();
// System prompt should have cache_control without ttl
expect(capturedPayload.system).toBeDefined();
expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral" });
},
);
it.skipIf(!process.env.ANTHROPIC_API_KEY)("should use 1h cache TTL when PI_CACHE_RETENTION=long", async () => {
process.env.PI_CACHE_RETENTION = "long";
const model = getModel("anthropic", "claude-haiku-4-5");
let capturedPayload: any = null;
const s = stream(model, context, {
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
// Consume the stream to trigger the request
for await (const _ of s) {
// Just consume
}
expect(capturedPayload).not.toBeNull();
// System prompt should have cache_control with ttl: "1h"
expect(capturedPayload.system).toBeDefined();
expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
});
it("should add ttl for non-api.anthropic.com baseUrl by default", async () => {
process.env.PI_CACHE_RETENTION = "long";
// Create a model with a different baseUrl (simulating a proxy)
const baseModel = getModel("anthropic", "claude-haiku-4-5");
const proxyModel = {
...baseModel,
baseUrl: "https://my-proxy.example.com/v1",
};
let capturedPayload: any = null;
// We can't actually make the request (no proxy), but we can verify the payload
// by using a mock or checking the logic directly
// For this test, we'll import the helper directly
// Since we can't easily test this without mocking, we'll skip the actual API call
// and just verify the helper logic works correctly
try {
const s = streamAnthropic(proxyModel, context, {
apiKey: "fake-key",
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
// This will fail since we're using a fake key and fake proxy, but the payload should be captured
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload).not.toBeNull();
expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
});
it("should omit ttl when supportsLongCacheRetention is false", async () => {
const baseModel = getModel("anthropic", "claude-haiku-4-5");
const proxyModel = {
...baseModel,
baseUrl: "https://my-proxy.example.com/v1",
compat: { supportsLongCacheRetention: false },
};
let capturedPayload: any = null;
try {
const s = streamAnthropic(proxyModel, context, {
apiKey: "fake-key",
cacheRetention: "long",
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload).not.toBeNull();
expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral" });
});
it("should omit cache_control when cacheRetention is none", async () => {
const baseModel = getModel("anthropic", "claude-haiku-4-5");
let capturedPayload: any = null;
try {
const s = streamAnthropic(baseModel, context, {
apiKey: "fake-key",
cacheRetention: "none",
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload).not.toBeNull();
expect(capturedPayload.system[0].cache_control).toBeUndefined();
});
it("should add cache_control to string user messages", async () => {
const baseModel = getModel("anthropic", "claude-haiku-4-5");
let capturedPayload: any = null;
try {
const s = streamAnthropic(baseModel, context, {
apiKey: "fake-key",
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload).not.toBeNull();
const lastMessage = capturedPayload.messages[capturedPayload.messages.length - 1];
expect(Array.isArray(lastMessage.content)).toBe(true);
const lastBlock = lastMessage.content[lastMessage.content.length - 1];
expect(lastBlock.cache_control).toEqual({ type: "ephemeral" });
});
it("should set 1h cache TTL when cacheRetention is long", async () => {
const baseModel = getModel("anthropic", "claude-haiku-4-5");
let capturedPayload: any = null;
try {
const s = streamAnthropic(baseModel, context, {
apiKey: "fake-key",
cacheRetention: "long",
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload).not.toBeNull();
expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
});
});
describe("OpenAI Responses Provider", () => {
it.skipIf(!process.env.OPENAI_API_KEY)(
"should not set prompt_cache_retention when PI_CACHE_RETENTION is not set",
async () => {
const model = getModel("openai", "gpt-4o-mini");
let capturedPayload: any = null;
const s = stream(model, context, {
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
// Consume the stream to trigger the request
for await (const _ of s) {
// Just consume
}
expect(capturedPayload).not.toBeNull();
expect(capturedPayload.prompt_cache_retention).toBeUndefined();
},
);
it.skipIf(!process.env.OPENAI_API_KEY)(
"should set prompt_cache_retention to 24h when PI_CACHE_RETENTION=long",
async () => {
process.env.PI_CACHE_RETENTION = "long";
const model = getModel("openai", "gpt-4o-mini");
let capturedPayload: any = null;
const s = stream(model, context, {
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
// Consume the stream to trigger the request
for await (const _ of s) {
// Just consume
}
expect(capturedPayload).not.toBeNull();
expect(capturedPayload.prompt_cache_retention).toBe("24h");
},
);
it("should set prompt_cache_retention for non-api.openai.com baseUrl by default", async () => {
process.env.PI_CACHE_RETENTION = "long";
// Create a model with a different baseUrl (simulating a proxy)
const baseModel = getModel("openai", "gpt-4o-mini");
const proxyModel = {
...baseModel,
baseUrl: "https://my-proxy.example.com/v1",
};
let capturedPayload: any = null;
try {
const s = streamOpenAIResponses(proxyModel, context, {
apiKey: "fake-key",
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
// This will fail since we're using a fake key and fake proxy, but the payload should be captured
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload).not.toBeNull();
expect(capturedPayload.prompt_cache_retention).toBe("24h");
});
it("should omit prompt_cache_retention when supportsLongCacheRetention is false", async () => {
const model = {
...getModel("openai", "gpt-4o-mini"),
compat: { supportsLongCacheRetention: false },
};
let capturedPayload: any = null;
try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
cacheRetention: "long",
sessionId: "session-compat-false",
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload).not.toBeNull();
expect(capturedPayload.prompt_cache_retention).toBeUndefined();
});
it("should omit prompt_cache_key and disable implicit writes when cacheRetention is none", async () => {
const model = getModel("openai", "gpt-5.6-sol");
let capturedPayload: OpenAIResponsesCachePayload | undefined;
try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
cacheRetention: "none",
sessionId: "session-1",
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload).toBeDefined();
expect(capturedPayload?.prompt_cache_key).toBeUndefined();
expect(capturedPayload?.prompt_cache_retention).toBeUndefined();
expect(capturedPayload?.prompt_cache_options).toEqual({ mode: "explicit" });
});
it("should omit prompt_cache_options for models that reject it", async () => {
const model = getModel("openai", "gpt-4o-mini");
let capturedPayload: OpenAIResponsesCachePayload | undefined;
try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
cacheRetention: "none",
sessionId: "session-1",
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload).toBeDefined();
expect(capturedPayload?.prompt_cache_key).toBeUndefined();
expect(capturedPayload?.prompt_cache_options).toBeUndefined();
});
it.each([
["gpt-4o-mini", "24h", undefined],
["gpt-6-astra", undefined, { ttl: "30m" }],
] as const)("should use the supported long cache field for %s", async (modelId, retention, cacheOptions) => {
const model = getModel("openai", modelId);
let capturedPayload: OpenAIResponsesCachePayload | undefined;
try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
cacheRetention: "long",
sessionId: "session-2",
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload?.prompt_cache_key).toBe("session-2");
expect(capturedPayload?.prompt_cache_retention).toBe(retention);
expect(capturedPayload?.prompt_cache_options).toEqual(cacheOptions);
});
});
describe("OpenAI Completions Provider", () => {
function createCompletionsModel(compat?: Model<"openai-completions">["compat"]): Model<"openai-completions"> {
return {
id: "test-model",
name: "Test Model",
api: "openai-completions",
provider: "test-openai-completions",
baseUrl: "https://my-proxy.example.com/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
compat,
};
}
it("should set prompt_cache_retention for non-api.openai.com baseUrl by default", async () => {
let capturedPayload: any = null;
try {
const s = streamOpenAICompletions(createCompletionsModel(), context, {
apiKey: "fake-key",
cacheRetention: "long",
sessionId: "session-completions",
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload).not.toBeNull();
expect(capturedPayload.prompt_cache_key).toBe("session-completions");
expect(capturedPayload.prompt_cache_retention).toBe("24h");
});
it("should omit prompt_cache_retention when supportsLongCacheRetention is false", async () => {
let capturedPayload: any = null;
try {
const s = streamOpenAICompletions(createCompletionsModel({ supportsLongCacheRetention: false }), context, {
apiKey: "fake-key",
cacheRetention: "long",
sessionId: "session-completions-false",
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload).not.toBeNull();
expect(capturedPayload.prompt_cache_key).toBeUndefined();
expect(capturedPayload.prompt_cache_retention).toBeUndefined();
});
it.each([
MODELS.opencode["deepseek-v4-flash"],
MODELS.opencode["deepseek-v4-pro"],
MODELS.opencode["kimi-k2.5"],
MODELS.opencode["kimi-k2.6"],
MODELS.opencode["minimax-m2.7"],
MODELS["opencode-go"]["kimi-k2.6"],
] as const)("should omit long cache retention for $provider/$id", async (metadata) => {
const model = metadata as Model<"openai-completions">;
let capturedPayload: OpenAICompletionsCachePayload | undefined;
try {
const s = streamOpenAICompletions(model, context, {
apiKey: "fake-key",
cacheRetention: "long",
sessionId: "session-opencode-long-cache-unsupported",
onPayload: stopAfterPayload<OpenAICompletionsCachePayload>((payload) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(model.compat?.supportsLongCacheRetention).toBe(false);
expect(capturedPayload).toBeDefined();
expect(capturedPayload?.prompt_cache_key).toBeUndefined();
expect(capturedPayload?.prompt_cache_retention).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import {
type AiBinding,
CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL,
createAiBindingFetch,
} from "../src/api/cloudflare-ai-binding.ts";
import { streamSimple as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import type { Model } from "../src/types.ts";
const BINDING_PREFIX = "https://workers-binding.ai/ai-gateway/gateways/my-gateway";
function fakeBinding(response?: Response) {
const requests: Request[] = [];
const binding: AiBinding = {
aiGatewayLogId: null,
fetch: (input: Request | string | URL, init?: RequestInit) => {
requests.push(input instanceof Request ? input : new Request(input, init));
return Promise.resolve(response ?? new Response("{}"));
},
};
return { binding, requests };
}
describe("createAiBindingFetch", () => {
it("passes requests to the binding untouched", async () => {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: {}\n\n"));
controller.close();
},
});
const bindingResponse = new Response(stream, {
headers: { "content-type": "text/event-stream", "cf-aig-log-id": "log-1" },
});
const { binding, requests } = fakeBinding(bindingResponse);
// No cast at the call site: an `Ai` binding is accepted as-is, the way `env.AI` is typed.
const fetchFn = createAiBindingFetch(binding);
const body = JSON.stringify({ model: "claude", messages: [{ role: "user", content: "hi" }] });
const response = await fetchFn(`${BINDING_PREFIX}/anthropic/v1/messages?beta=true`, {
method: "POST",
headers: {
"content-type": "application/json",
"cf-aig-authorization": `Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}`,
"anthropic-version": "2023-06-01",
},
body,
});
expect(requests[0].url).toBe(`${BINDING_PREFIX}/anthropic/v1/messages?beta=true`);
expect(requests[0].method).toBe("POST");
expect(Object.fromEntries(requests[0].headers)).toEqual({
"content-type": "application/json",
// The gateway recognizes and strips the sentinel itself; nothing here touches headers.
"cf-aig-authorization": `Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}`,
"anthropic-version": "2023-06-01",
});
expect(await requests[0].text()).toBe(body);
expect(response).toBe(bindingResponse);
expect(await response.text()).toBe("data: {}\n\n");
});
it("rejects a binding with no fetch() at construction, not on first request", () => {
// A well-typed binding on a runtime that predates `Ai#fetch`: `fetch` is optional on the
// type, so the check has to happen at runtime — early, with a message that names the cause.
expect(() => createAiBindingFetch({ aiGatewayLogId: null })).toThrow("does not expose fetch()");
});
it("keeps SDK placeholder auth off the wire when paired with null auth headers", async () => {
// The full header contract from the module docs, end to end through a real API impl: the
// sentinel satisfies pi's request-auth check, and the explicit nulls make the OpenAI SDK
// delete its own `Authorization: Bearer unused` placeholder before dispatch.
const { binding, requests } = fakeBinding(
Response.json({ error: { type: "bad_request", message: "stubbed" } }, { status: 400 }),
);
const model: Model<"openai-completions"> = {
id: "test-model",
name: "Test Model",
api: "openai-completions",
provider: "openai",
baseUrl: `${BINDING_PREFIX}/openai`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 10_000,
maxTokens: 1_000,
};
const result = await streamOpenAICompletions(
model,
{ messages: [{ role: "user", content: "hello", timestamp: 1 }] },
{
headers: {
"cf-aig-authorization": `Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}`,
Authorization: null,
"x-api-key": null,
},
fetch: createAiBindingFetch(binding),
maxRetries: 0,
},
).result();
expect(result.stopReason).toBe("error");
expect(requests).toHaveLength(1);
expect(requests[0].url).toBe(`${BINDING_PREFIX}/openai/chat/completions`);
const headerNames = Object.keys(Object.fromEntries(requests[0].headers));
expect(headerNames).not.toContain("authorization");
expect(headerNames).not.toContain("x-api-key");
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { cloudflareStreams } from "../src/providers/cloudflare-stream.ts";
import type { Api, Context, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
const model: Model<Api> = {
id: "model",
name: "model",
api: "openai-completions",
provider: "cloudflare-ai-gateway",
baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
const context: Context = { messages: [] };
describe("Cloudflare provider streams", () => {
it("materializes the model endpoint before dispatch", () => {
const captured: string[] = [];
const streams = cloudflareStreams({
stream: (requestModel) => {
captured.push(requestModel.baseUrl);
return new AssistantMessageEventStream();
},
streamSimple: (requestModel) => {
captured.push(requestModel.baseUrl);
return new AssistantMessageEventStream();
},
});
const env = {
CLOUDFLARE_ACCOUNT_ID: "account",
CLOUDFLARE_GATEWAY_ID: "gateway",
};
streams.stream(model, context, { env });
streams.streamSimple(model, context, { env });
expect(captured).toEqual([
"https://gateway.ai.cloudflare.com/v1/account/gateway/openai",
"https://gateway.ai.cloudflare.com/v1/account/gateway/openai",
]);
});
it("keeps placeholders when the provider env does not resolve them", () => {
let captured: string | undefined;
const streams = cloudflareStreams({
stream: (requestModel) => {
captured = requestModel.baseUrl;
return new AssistantMessageEventStream();
},
streamSimple: (requestModel) => {
captured = requestModel.baseUrl;
return new AssistantMessageEventStream();
},
});
streams.streamSimple(model, context, {});
expect(captured).toBe(model.baseUrl);
});
});

View File

@@ -0,0 +1,9 @@
export function hasCloudflareWorkersAICredentials(): boolean {
return !!process.env.CLOUDFLARE_API_KEY && !!process.env.CLOUDFLARE_ACCOUNT_ID;
}
export function hasCloudflareAiGatewayCredentials(): boolean {
return (
!!process.env.CLOUDFLARE_API_KEY && !!process.env.CLOUDFLARE_ACCOUNT_ID && !!process.env.CLOUDFLARE_GATEWAY_ID
);
}

View File

@@ -0,0 +1,299 @@
#!/usr/bin/env node
/**
* Live probe for OpenAI Codex Responses websocket-cached mode.
*
* Runs a simple tool loop directly against the pi-ai provider source so it does not
* depend on built dist packages or coding-agent SDK wiring.
*/
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { Type } from "typebox";
import { ModelRuntime } from "../../coding-agent/src/core/model-runtime.ts";
import {
closeOpenAICodexWebSocketSessions,
getOpenAICodexWebSocketDebugStats,
resetOpenAICodexWebSocketDebugStats,
stream as streamOpenAICodexResponses,
} from "../src/api/openai-codex-responses.ts";
import { getModel } from "../src/compat.ts";
import type { AssistantMessage, Context, Message, Model, Tool, ToolResultMessage, Transport } from "../src/types.ts";
type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
interface Args {
turns: number;
transport: Transport;
maxTokens: number;
reasoning: ThinkingLevel;
sessionId: string;
}
const DEFAULT_TURNS = 20;
const DEFAULT_MAX_TOKENS = 64;
function parseArgs(argv: string[]): Args {
let turns = DEFAULT_TURNS;
let transport: Transport = "websocket-cached";
let maxTokens = DEFAULT_MAX_TOKENS;
let reasoning: ThinkingLevel = "low";
let sessionId = `pi-ai-codex-ws-cached-probe-${Date.now()}`;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case "--turns":
turns = Number.parseInt(required(argv[++i], arg), 10);
break;
case "--transport": {
const value = required(argv[++i], arg);
if (value !== "sse" && value !== "websocket" && value !== "websocket-cached" && value !== "auto") {
throw new Error(`Invalid --transport: ${value}`);
}
transport = value;
break;
}
case "--max-tokens":
maxTokens = Number.parseInt(required(argv[++i], arg), 10);
break;
case "--reasoning": {
const value = required(argv[++i], arg);
if (
value !== "minimal" &&
value !== "low" &&
value !== "medium" &&
value !== "high" &&
value !== "xhigh" &&
value !== "max"
) {
throw new Error(`Invalid --reasoning: ${value}`);
}
reasoning = value;
break;
}
case "--session-id":
sessionId = required(argv[++i], arg);
break;
case "--help":
printHelp();
process.exit(0);
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return { turns, transport, maxTokens, reasoning, sessionId };
}
function required(value: string | undefined, flag: string): string {
if (!value) throw new Error(`Missing value for ${flag}`);
return value;
}
function printHelp(): void {
console.log(`Usage: node test/codex-websocket-cached-probe.ts [options]
Options:
--turns <n> Number of user turns. Default: ${DEFAULT_TURNS}
--transport <mode> sse | websocket | websocket-cached | auto. Default: websocket-cached
--reasoning <level> minimal | low | medium | high | xhigh | max. Default: low
--max-tokens <n> Max output tokens per model request. Default: ${DEFAULT_MAX_TOKENS}
--session-id <id> Session id for websocket/cache state
`);
}
function buildPrompt(turn: number): string {
const marker = `TURN-${String(turn).padStart(2, "0")}-MARKER-${(turn * 17 + 13) % 97}`;
const lines = [
"This is an automated OpenAI Codex Responses websocket cache probe.",
`Task for turn ${turn}: call deterministic_probe exactly once before your final answer.`,
`Use tool arguments: turn=${turn}, marker=${marker}`,
`After the tool result arrives, reply exactly: TURN ${turn} OK ${marker}`,
"The following repeated block is intentional benchmark padding.",
];
for (let i = 1; i <= 180; i++) {
lines.push(
`Turn ${turn} synthetic record ${String(i).padStart(3, "0")}: alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega.`,
);
}
return lines.join("\n");
}
function deterministicProbeTool(): Tool {
return {
name: "deterministic_probe",
description: "Mandatory benchmark tool. Call exactly once with the turn and marker from the user prompt.",
parameters: Type.Object({
turn: Type.Number(),
marker: Type.String(),
}),
};
}
function executeTool(call: Extract<AssistantMessage["content"][number], { type: "toolCall" }>): ToolResultMessage {
return {
role: "toolResult",
toolCallId: call.id,
toolName: call.name,
content: [{ type: "text", text: `deterministic_probe_result ${JSON.stringify(call.arguments)} fixed=OK` }],
details: { fixed: "OK" },
isError: false,
timestamp: Date.now(),
};
}
function textOf(message: AssistantMessage): string {
return message.content
.filter((block): block is Extract<AssistantMessage["content"][number], { type: "text" }> => block.type === "text")
.map((block) => block.text)
.join("\n")
.trim();
}
function average(values: number[]): number {
return values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
}
function percentile(values: number[], p: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))];
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const model = getModel("openai-codex", "gpt-5.5") as Model<"openai-codex-responses"> | undefined;
if (!model) throw new Error("Model openai-codex/gpt-5.5 not found");
const modelWithMaxTokens = { ...model, maxTokens: args.maxTokens };
const modelRuntime = await ModelRuntime.create();
const apiKey =
(await modelRuntime.getAuth("openai-codex"))?.auth.apiKey ?? (await modelRuntime.getAuth("openai"))?.auth.apiKey;
if (!apiKey) {
throw new Error("No OpenAI Codex API key found in coding-agent auth storage.");
}
const context: Context = {
systemPrompt:
"You are participating in a benchmark. For each benchmark turn, call deterministic_probe exactly once before the final answer. Keep final answers minimal.",
messages: [],
tools: [deterministicProbeTool()],
};
const elapsed: number[] = [];
resetOpenAICodexWebSocketDebugStats(args.sessionId);
console.log(`provider openai-codex, model gpt-5.5`);
console.log(`sessionId ${args.sessionId}`);
console.log(
`turns ${args.turns}, transport ${args.transport}, reasoning ${args.reasoning}, maxTokens ${args.maxTokens}`,
);
console.log(`scratch ${resolve(join(tmpdir(), args.sessionId))}`);
console.log("");
for (let turn = 1; turn <= args.turns; turn++) {
context.messages.push({ role: "user", content: buildPrompt(turn), timestamp: Date.now() });
const beforeStats = getOpenAICodexWebSocketDebugStats(args.sessionId);
const started = Date.now();
let requests = 0;
let assistantCount = 0;
let toolResults = 0;
let finalText = "";
let turnInput = 0;
let turnOutput = 0;
let turnCacheRead = 0;
let turnCacheWrite = 0;
while (true) {
requests++;
const message = await streamOpenAICodexResponses(modelWithMaxTokens, context, {
apiKey,
sessionId: args.sessionId,
transport: args.transport,
reasoningEffort: args.reasoning,
maxTokens: args.maxTokens,
}).result();
assistantCount++;
context.messages.push(message);
turnInput += message.usage.input;
turnOutput += message.usage.output;
turnCacheRead += message.usage.cacheRead;
turnCacheWrite += message.usage.cacheWrite;
const toolCalls = message.content.filter(
(block): block is Extract<AssistantMessage["content"][number], { type: "toolCall" }> =>
block.type === "toolCall",
);
console.log(
[
`turn ${String(turn).padStart(2, "0")}.${requests}`,
`stop ${message.stopReason}`,
`in ${message.usage.input}`,
`out ${message.usage.output}`,
`cache ${message.usage.cacheRead}/${message.usage.cacheWrite}`,
`tools ${toolCalls.length}`,
].join(" | "),
);
if (message.stopReason === "error" || message.stopReason === "aborted") {
throw new Error(message.errorMessage ?? `request failed on turn ${turn}.${requests}`);
}
if (toolCalls.length === 0) {
finalText = textOf(message);
break;
}
for (const call of toolCalls) {
context.messages.push(executeTool(call) as Message);
toolResults++;
}
if (requests > 4) throw new Error(`Too many requests for turn ${turn}`);
}
const elapsedMs = Date.now() - started;
elapsed.push(elapsedMs);
const afterStats = getOpenAICodexWebSocketDebugStats(args.sessionId);
const statLine = afterStats
? `ws requests ${afterStats.requests - (beforeStats?.requests ?? 0)} | new/reused ${afterStats.connectionsCreated - (beforeStats?.connectionsCreated ?? 0)}/${afterStats.connectionsReused - (beforeStats?.connectionsReused ?? 0)} | cached ${afterStats.cachedContextRequests - (beforeStats?.cachedContextRequests ?? 0)} | store ${afterStats.storeTrueRequests - (beforeStats?.storeTrueRequests ?? 0)} | full/delta ${afterStats.fullContextRequests - (beforeStats?.fullContextRequests ?? 0)}/${afterStats.deltaRequests - (beforeStats?.deltaRequests ?? 0)}`
: "ws none";
console.log(
[
`turn ${String(turn).padStart(2, "0")} agg`,
`elapsed ${(elapsedMs / 1000).toFixed(1)}s`,
`assistant ${assistantCount}`,
`toolResults ${toolResults}`,
`in ${turnInput}`,
`out ${turnOutput}`,
`cache ${turnCacheRead}/${turnCacheWrite}`,
statLine,
`final ${JSON.stringify(finalText).slice(0, 80)}`,
].join(" | "),
);
}
const stats = getOpenAICodexWebSocketDebugStats(args.sessionId);
console.log("");
console.log(
[
"timing",
`turns ${elapsed.length}`,
`total ${(elapsed.reduce((sum, value) => sum + value, 0) / 1000).toFixed(1)}s`,
`avg ${(average(elapsed) / 1000).toFixed(2)}s`,
`p50 ${(percentile(elapsed, 50) / 1000).toFixed(2)}s`,
`p95 ${(percentile(elapsed, 95) / 1000).toFixed(2)}s`,
`max ${(Math.max(...elapsed) / 1000).toFixed(2)}s`,
].join(" | "),
);
console.log(
[
"transport summary",
`requested ${args.transport}`,
`observed ${stats && stats.requests > 0 ? "websocket" : "sse/no-websocket"}`,
`storeTrue ${stats ? `${stats.storeTrueRequests}/${stats.requests}` : "0/0"}`,
`full/delta ${stats ? `${stats.fullContextRequests}/${stats.deltaRequests}` : "0/0"}`,
`connections created/reused ${stats ? `${stats.connectionsCreated}/${stats.connectionsReused}` : "0/0"}`,
`lastPreviousResponseId ${stats?.lastPreviousResponseId ?? "n/a"}`,
].join(" | "),
);
closeOpenAICodexWebSocketSessions(args.sessionId);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

View File

@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from "vitest";
import { complete, registerApiProvider, resetApiProviders } from "../src/compat.ts";
import type { AssistantMessage, Context, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] };
const model: Model<"openai-responses"> = {
id: "test-model",
name: "Test Model",
api: "openai-responses",
provider: "custom-openai",
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
function message(): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text: "ok" }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
describe("compat legacy API fallback", () => {
afterEach(() => {
resetApiProviders();
});
it("dispatches unknown providers through the legacy API registry", async () => {
let capturedApiKey: string | undefined;
registerApiProvider({
api: "openai-responses",
stream: (_model, _context, options) => {
capturedApiKey = options?.apiKey;
const stream = new AssistantMessageEventStream();
const output = message();
stream.push({ type: "start", partial: output });
stream.push({ type: "done", reason: "stop", message: output });
stream.end(output);
return stream;
},
streamSimple: (_model, _context, options) => {
capturedApiKey = options?.apiKey;
const stream = new AssistantMessageEventStream();
const output = message();
stream.push({ type: "start", partial: output });
stream.push({ type: "done", reason: "stop", message: output });
stream.end(output);
return stream;
},
});
await complete(model, context, { apiKey: "request-key" });
expect(capturedApiKey).toBe("request-key");
});
});

View File

@@ -0,0 +1,313 @@
import type { ResponseStreamEvent } from "openai/resources/responses/responses.js";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import {
appendGrammarToolInputJsonDelta,
makeStrictJsonSchema,
resolveJsonSchemaStrictSampling,
} from "../src/api/constrained-sampling.ts";
import {
convertResponsesMessages,
convertResponsesTools,
processResponsesStream,
} from "../src/api/openai-responses-shared.ts";
import type { AssistantMessage, Context, Model, Tool, ToolCall } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function makeModel(): Model<"openai-responses"> {
return {
id: "gpt-test",
name: "GPT Test",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
}
function makeUsage(): AssistantMessage["usage"] {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function makeOutput(): AssistantMessage {
return {
role: "assistant",
content: [],
api: "openai-responses",
provider: "openai",
model: "gpt-test",
usage: makeUsage(),
stopReason: "pending",
timestamp: Date.now(),
};
}
async function* iterateEvents(events: ResponseStreamEvent[]): AsyncGenerator<ResponseStreamEvent> {
yield* events;
}
function makeTool(overrides: Partial<Tool> = {}): Tool {
return {
name: "sample_tool",
description: "Sample tool",
parameters: Type.Object({ payload: Type.String() }, { additionalProperties: false }),
...overrides,
};
}
function captureToolCallEvents(stream: AssistantMessageEventStream): {
starts: ToolCall["arguments"][];
deltas: string[];
} {
const starts: ToolCall["arguments"][] = [];
const deltas: string[] = [];
const originalPush = stream.push.bind(stream);
stream.push = (event) => {
if (event.type === "toolcall_start") {
const block = event.partial.content[event.contentIndex];
if (block?.type === "toolCall") starts.push(structuredClone(block.arguments));
} else if (event.type === "toolcall_delta") {
deltas.push(event.delta);
}
originalPush(event);
};
return { starts, deltas };
}
describe("constrained tool sampling", () => {
it("converts supported constraints and falls back when unsupported", () => {
expect(
convertResponsesTools([makeTool({ constrainedSampling: { type: "json_schema", strict: "prefer" } })])[0],
).toMatchObject({ type: "function", name: "sample_tool", strict: true });
expect(() =>
convertResponsesTools([makeTool({ constrainedSampling: { type: "json_schema", strict: "require" } })], {
supportsStrictMode: false,
}),
).toThrow('Tool "sample_tool" requires JSON-schema constrained sampling');
const grammarTool = makeTool({
constrainedSampling: { type: "grammar", variants: { openai_lark: "start: /[a-z]+/" } },
});
expect(convertResponsesTools([grammarTool], { supportsOpenAIGrammarTools: true })[0]).toMatchObject({
type: "custom",
name: "sample_tool",
format: { type: "grammar", syntax: "lark", definition: "start: /[a-z]+/" },
});
expect(() =>
convertResponsesTools([makeTool({ constrainedSampling: { type: "grammar", variants: {} } })], {
supportsOpenAIGrammarTools: true,
}),
).toThrow(
'Tool "sample_tool" cannot use grammar constrained sampling: no supported grammar variant was provided',
);
const fallback = convertResponsesTools([grammarTool], {
supportsOpenAIGrammarTools: false,
supportsStrictMode: false,
})[0];
expect(fallback).toMatchObject({ type: "function", name: "sample_tool" });
expect("strict" in (fallback as object)).toBe(false);
expect(convertResponsesTools([makeTool({ constrainedSampling: false })])).toEqual(
convertResponsesTools([makeTool()]),
);
});
it("derives strict provider schemas without changing tool definitions", () => {
const parameters = Type.Object({
path: Type.String(),
offset: Type.Optional(Type.Number()),
metadata: Type.Object({ enabled: Type.Optional(Type.Boolean()) }),
nullable: Type.Optional(Type.Union([Type.String(), Type.Null()])),
});
const strict = makeStrictJsonSchema(parameters);
expect(parameters).not.toHaveProperty("additionalProperties");
expect(parameters.required).toEqual(["path", "metadata"]);
expect(strict).toMatchObject({
additionalProperties: false,
required: ["path", "offset", "metadata", "nullable"],
properties: {
offset: { anyOf: [{ type: "number" }, { type: "null" }] },
metadata: {
additionalProperties: false,
required: ["enabled"],
properties: { enabled: { anyOf: [{ type: "boolean" }, { type: "null" }] } },
},
nullable: { anyOf: [{ type: "string" }, { type: "null" }] },
},
});
});
it("falls back or rejects schemas that cannot be safely converted", () => {
const cases: Array<{ parameters: Tool["parameters"]; error: string }> = [
{
parameters: Type.Object({ metadata: Type.Object({}, { additionalProperties: Type.String() }) }),
error: "additionalProperties is unsupported",
},
{
parameters: Type.Intersect([Type.Object({ a: Type.String() }), Type.Object({ b: Type.Number() })]),
error: "allOf schemas are unsupported",
},
{
parameters: Type.Object({
value: Type.Union([Type.Object({ nested: Type.String() }), Type.Null()]),
}),
error: "object and array unions are unsupported",
},
{
parameters: {
type: "object",
properties: { child: { $ref: "https://example.com/child.json" } },
required: ["child"],
} as Tool["parameters"],
error: "$ref schemas are unsupported",
},
];
for (const { parameters, error } of cases) {
const tool: Tool = {
...makeTool(),
parameters,
constrainedSampling: { type: "json_schema", strict: "prefer" },
};
expect(() => makeStrictJsonSchema(parameters)).toThrow(error);
expect(resolveJsonSchemaStrictSampling(tool, true)).toBeUndefined();
expect(convertResponsesTools([tool], { supportsStrictMode: true })[0]).toMatchObject({
strict: false,
parameters,
});
tool.constrainedSampling = { type: "json_schema", strict: "require" };
expect(() => resolveJsonSchemaStrictSampling(tool, true)).toThrow(error);
}
});
it("replays grammar calls as custom Responses items", () => {
const replayedToolCall: ToolCall = {
type: "toolCall",
id: "call_1|ctc_1",
name: "sample_tool",
arguments: { payload: "abc" },
};
const context: Context = {
messages: [
{
role: "assistant",
api: "openai-responses",
provider: "openai",
model: "gpt-test",
content: [replayedToolCall],
usage: makeUsage(),
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "call_1|ctc_1",
toolName: "sample_tool",
content: [{ type: "text", text: "done" }],
isError: false,
timestamp: Date.now(),
},
],
};
for (const invalidArguments of [{}, { payload: 42 }]) {
replayedToolCall.arguments = invalidArguments;
expect(() =>
convertResponsesMessages(makeModel(), context, new Set(["openai"]), {
grammarToolInputProperties: new Map([["sample_tool", "payload"]]),
}),
).toThrow('Grammar tool call "sample_tool" requires argument "payload" to be a string');
}
replayedToolCall.arguments = { payload: "abc" };
const messages = convertResponsesMessages(makeModel(), context, new Set(["openai"]), {
grammarToolInputProperties: new Map([["sample_tool", "payload"]]),
});
expect(messages).toContainEqual({
type: "custom_tool_call",
id: "ctc_1",
call_id: "call_1",
name: "sample_tool",
input: "abc",
});
expect(messages).toContainEqual({
type: "custom_tool_call_output",
call_id: "call_1",
output: "done",
});
});
it("keeps grammar input JSON deltas append-only", () => {
const buffer = { input: "", started: false, closed: false };
const first = appendGrammarToolInputJsonDelta(buffer, "payload", 'a"', false);
const second = appendGrammarToolInputJsonDelta(buffer, "payload", 'a"\nb', true);
expect(JSON.parse(`${first}${second}`)).toEqual({ payload: 'a"\nb' });
expect(appendGrammarToolInputJsonDelta(buffer, "payload", 'a"\nb', true)).toBeUndefined();
expect(() => appendGrammarToolInputJsonDelta(buffer, "payload", "changed", true)).toThrow(
'grammar tool input for property "payload" changed after it was closed',
);
});
it("starts custom Responses tool calls with their initial input", async () => {
const output = makeOutput();
const stream = new AssistantMessageEventStream();
const { starts, deltas } = captureToolCallEvents(stream);
const events = [
{
type: "response.output_item.added",
output_index: 0,
item: { type: "custom_tool_call", call_id: "call_1", id: "ctc_1", name: "sample_tool", input: "a" },
},
{
type: "response.custom_tool_call_input.delta",
output_index: 0,
item_id: "ctc_1",
delta: "b",
},
{
type: "response.custom_tool_call_input.done",
output_index: 0,
item_id: "ctc_1",
input: "abc",
},
{
type: "response.output_item.done",
output_index: 0,
item: { type: "custom_tool_call", call_id: "call_1", id: "ctc_1", name: "sample_tool", input: "abc" },
},
{
type: "response.completed",
response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } },
},
] as ResponseStreamEvent[];
await processResponsesStream(iterateEvents(events), output, stream, makeModel(), {
grammarToolInputProperties: new Map([["sample_tool", "payload"]]),
});
expect(output.stopReason).toBe("toolUse");
expect(starts).toEqual([{ payload: "a" }]);
expect(output.content).toEqual([
{ type: "toolCall", id: "call_1|ctc_1", name: "sample_tool", arguments: { payload: "abc" } },
]);
expect(JSON.parse(deltas.join(""))).toEqual({ payload: "abc" });
});
});

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { buildBaseOptions } from "../src/api/simple-options.ts";
import type { AssistantMessage, Context, Model, Usage } from "../src/types.ts";
import { estimateContextTokens } from "../src/utils/estimate.ts";
function createUsage(totalTokens: number): Usage {
return {
input: totalTokens,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function createAssistant(timestamp: number, totalTokens: number): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text: "kept" }],
api: "openai-responses",
provider: "openai",
model: "test-model",
usage: createUsage(totalTokens),
stopReason: "stop",
timestamp,
};
}
const model: Model<"openai-responses"> = {
id: "test-model",
name: "Test Model",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 10_000,
maxTokens: 8_000,
};
describe("context token estimation", () => {
it("ignores stale assistant usage after a newer message is inserted before it", () => {
const context: Context = {
systemPrompt: "system",
messages: [
{ role: "user", content: "summary", timestamp: 200 },
createAssistant(100, 9_500),
{ role: "user", content: "x".repeat(4_000), timestamp: 300 },
],
};
expect(estimateContextTokens(context)).toEqual({
tokens: 1_005,
usageTokens: 0,
trailingTokens: 1_005,
lastUsageIndex: null,
});
expect(buildBaseOptions(model, context).maxTokens).toBe(4_899);
});
it("uses assistant usage again after a response to the inserted context", () => {
const context: Context = {
messages: [
{ role: "user", content: "summary", timestamp: 200 },
createAssistant(100, 9_500),
{ role: "user", content: "new prompt", timestamp: 300 },
createAssistant(400, 2_000),
{ role: "user", content: "tail", timestamp: 500 },
],
};
expect(estimateContextTokens(context)).toEqual({
tokens: 2_001,
usageTokens: 2_000,
trailingTokens: 1,
lastUsageIndex: 3,
});
});
});

View File

@@ -0,0 +1,771 @@
/**
* Test context overflow error handling across providers.
*
* Context overflow occurs when the input (prompt + history) exceeds
* the model's context window. This is different from output token limits.
*
* Expected behavior: All providers should return stopReason: "error"
* with an errorMessage that indicates the context was too large,
* OR (for z.ai) return successfully with usage.input > contextWindow.
*
* The isContextOverflow() function must return true for all providers.
*/
import type { ChildProcess } from "child_process";
import { execSync, spawn } from "child_process";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { complete, getModel, getModels } from "../src/compat.ts";
import type { AssistantMessage, Context, Model, Usage } from "../src/types.ts";
import { isContextOverflow } from "../src/utils/overflow.ts";
import { hasAzureOpenAICredentials } from "./azure-utils.ts";
import { hasBedrockCredentials } from "./bedrock-utils.ts";
import { resolveApiKey } from "./oauth.ts";
// Resolve OAuth tokens at module level (async, runs before tests)
const oauthTokens = await Promise.all([resolveApiKey("github-copilot"), resolveApiKey("openai-codex")]);
const [githubCopilotToken, openaiCodexToken] = oauthTokens;
// Lorem ipsum paragraph for realistic token estimation
const LOREM_IPSUM = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. `;
// Generate a string that will exceed the context window
// Using chars/4 as token estimate (works better with varied text than repeated chars)
function generateOverflowContent(contextWindow: number): string {
const targetTokens = contextWindow + 10000; // Exceed by 10k tokens
const targetChars = targetTokens * 4 * 1.5;
const repetitions = Math.ceil(targetChars / LOREM_IPSUM.length);
return LOREM_IPSUM.repeat(repetitions);
}
interface OverflowResult {
provider: string;
model: string;
contextWindow: number;
stopReason: string;
errorMessage: string | undefined;
usage: Usage;
hasUsageData: boolean;
response: AssistantMessage;
}
async function testContextOverflow(model: Model<any>, apiKey: string): Promise<OverflowResult> {
const overflowContent = generateOverflowContent(model.contextWindow);
const context: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [
{
role: "user",
content: overflowContent,
timestamp: Date.now(),
},
],
};
const response = await complete(model, context, { apiKey });
const hasUsageData = response.usage.input > 0 || response.usage.cacheRead > 0;
return {
provider: model.provider,
model: model.id,
contextWindow: model.contextWindow,
stopReason: response.stopReason,
errorMessage: response.errorMessage,
usage: response.usage,
hasUsageData,
response,
};
}
function logResult(result: OverflowResult) {
console.log(`\n${result.provider} / ${result.model}:`);
console.log(` contextWindow: ${result.contextWindow}`);
console.log(` stopReason: ${result.stopReason}`);
console.log(` errorMessage: ${result.errorMessage}`);
console.log(` usage: ${JSON.stringify(result.usage)}`);
console.log(` hasUsageData: ${result.hasUsageData}`);
}
// =============================================================================
// Anthropic
// Expected pattern: "prompt is too long: X tokens > Y maximum"
// =============================================================================
describe("Context overflow error handling", () => {
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic (API Key)", () => {
it("claude-haiku-4-5 - should detect overflow via isContextOverflow", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const result = await testContextOverflow(model, process.env.ANTHROPIC_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/prompt is too long/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("Anthropic (OAuth)", () => {
it("claude-sonnet-4 - should detect overflow via isContextOverflow", async () => {
const model = getModel("anthropic", "claude-sonnet-4-6");
const result = await testContextOverflow(model, process.env.ANTHROPIC_OAUTH_TOKEN!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/prompt is too long/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// GitHub Copilot (OAuth)
// Tests both Google and Anthropic models via Copilot
// =============================================================================
describe("GitHub Copilot (OAuth)", () => {
// Google model via Copilot
it.skipIf(!githubCopilotToken)(
"Google model - should detect overflow via isContextOverflow",
async () => {
const model = getModels("github-copilot").find((candidate) => candidate.id.startsWith("gemini-"));
if (!model) throw new Error("No Google models available through GitHub Copilot");
const result = await testContextOverflow(model, githubCopilotToken!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/exceeds the limit of \d+/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
},
120000,
);
// Anthropic model via Copilot
it.skipIf(!githubCopilotToken)(
"claude-sonnet-4 - should detect overflow via isContextOverflow",
async () => {
const model = getModel("github-copilot", "claude-sonnet-4.6");
const result = await testContextOverflow(model, githubCopilotToken!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/exceeds the limit of \d+|input is too long/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
},
120000,
);
});
// =============================================================================
// OpenAI
// Expected pattern: "exceeds the context window"
// =============================================================================
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions", () => {
it("gpt-4o-mini - should detect overflow via isContextOverflow", async () => {
const model = { ...getModel("openai", "gpt-4o-mini") };
model.api = "openai-completions" as any;
const result = await testContextOverflow(model, process.env.OPENAI_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/maximum context length/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses", () => {
it("gpt-4o - should detect overflow via isContextOverflow", async () => {
const model = getModel("openai", "gpt-4o");
const result = await testContextOverflow(model, process.env.OPENAI_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/exceeds the context window/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses", () => {
it("gpt-4o-mini - should detect overflow via isContextOverflow", async () => {
const model = getModel("azure-openai-responses", "gpt-4o-mini");
const result = await testContextOverflow(model, process.env.AZURE_OPENAI_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/context|maximum/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// Google
// Expected pattern: "input token count (X) exceeds the maximum"
// =============================================================================
describe.skipIf(!process.env.GEMINI_API_KEY)("Google", () => {
it("gemini-2.5-flash - should detect overflow via isContextOverflow", async () => {
const model = getModel("google", "gemini-2.5-flash");
const result = await testContextOverflow(model, process.env.GEMINI_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/input token count.*exceeds the maximum/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// Uses same API as Google, expects same error pattern
// =============================================================================
// =============================================================================
// =============================================================================
// =============================================================================
// OpenAI Codex (OAuth)
// Uses ChatGPT Plus/Pro subscription via OAuth
// =============================================================================
describe("OpenAI Codex (OAuth)", () => {
it.skipIf(!openaiCodexToken)(
"gpt-5.5 - should detect overflow via isContextOverflow",
async () => {
const model = getModel("openai-codex", "gpt-5.5");
const result = await testContextOverflow(model, openaiCodexToken!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
},
120000,
);
});
// =============================================================================
// Amazon Bedrock
// Expected pattern: "Input is too long for requested model"
// =============================================================================
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock", () => {
it("claude-sonnet-4-5 - should detect overflow via isContextOverflow", async () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
const result = await testContextOverflow(model, "");
logResult(result);
expect(result.stopReason).toBe("error");
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// xAI
// Expected pattern: "maximum prompt length is X but the request contains Y"
// =============================================================================
describe.skipIf(!process.env.XAI_API_KEY)("xAI", () => {
it("grok-4.3 - should detect overflow via isContextOverflow", async () => {
const model = getModel("xai", "grok-4.3");
const result = await testContextOverflow(model, process.env.XAI_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/maximum prompt length is \d+/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// Groq
// Expected pattern: "reduce the length of the messages"
// =============================================================================
describe.skipIf(!process.env.GROQ_API_KEY)("Groq", () => {
it("llama-3.3-70b-versatile - should detect overflow via isContextOverflow", async () => {
const model = getModel("groq", "llama-3.3-70b-versatile");
const result = await testContextOverflow(model, process.env.GROQ_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/reduce the length of the messages/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// Cerebras
// Expected: 400/413 status code with no body
// =============================================================================
describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras", () => {
it("available model - should detect overflow via isContextOverflow", async () => {
const preferredCerebrasModelIds: string[] = ["gpt-oss-120b", "zai-glm-4.7", "llama3.1-8b"];
const cerebrasModels = getModels("cerebras");
const model =
cerebrasModels.find((candidate) => preferredCerebrasModelIds.includes(candidate.id)) ?? cerebrasModels[0];
if (!model) {
throw new Error("No Cerebras models available");
}
const result = await testContextOverflow(model, process.env.CEREBRAS_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
// Cerebras returns status code with no body (400, 413, or 429 for token rate limit)
expect(result.errorMessage).toMatch(/4(00|13|29).*\(no body\)/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// Hugging Face
// Uses OpenAI-compatible Inference Router
// =============================================================================
describe.skipIf(!process.env.HF_TOKEN)("Hugging Face", () => {
it("Kimi-K2.5 - should detect overflow via isContextOverflow", async () => {
const model = getModel("huggingface", "moonshotai/Kimi-K2.5");
const result = await testContextOverflow(model, process.env.HF_TOKEN!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// Together AI
// Uses OpenAI-compatible Chat Completions API
// =============================================================================
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI", () => {
it("Kimi-K2.6 - should detect overflow via isContextOverflow", async () => {
const model = getModel("together", "moonshotai/Kimi-K2.6");
const result = await testContextOverflow(model, process.env.TOGETHER_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// z.ai
// Special case: may return explicit overflow error text, may accept overflow silently,
// or may rate limit instead
// =============================================================================
describe.skipIf(!process.env.ZAI_API_KEY)("z.ai", () => {
it("glm-5.2 - should detect overflow via isContextOverflow when z.ai reports it", async () => {
const model = getModel("zai", "glm-5.2");
const result = await testContextOverflow(model, process.env.ZAI_API_KEY!);
logResult(result);
// z.ai behavior is inconsistent:
// - Sometimes returns explicit overflow error text via non-standard finish_reason handling
// - Sometimes accepts overflow and returns successfully with usage.input > contextWindow
// - Sometimes returns rate limit error
if (result.stopReason === "error") {
if (result.errorMessage?.match(/model_context_window_exceeded/i)) {
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
} else {
console.log(" z.ai returned non-overflow error (possibly rate limited), skipping overflow detection");
}
} else if (result.stopReason === "stop") {
if (result.hasUsageData && result.usage.input > model.contextWindow) {
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
} else {
console.log(" z.ai returned stop without overflow usage data, skipping overflow detection");
}
}
}, 120000);
});
// =============================================================================
// Mistral
// =============================================================================
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral", () => {
it("devstral-medium-latest - should detect overflow via isContextOverflow", async () => {
const model = getModel("mistral", "devstral-medium-latest");
const result = await testContextOverflow(model, process.env.MISTRAL_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/too large for model with \d+ maximum context length/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// MiniMax
// Expected pattern: TBD - need to test actual error message
// =============================================================================
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax", () => {
it("MiniMax-M2.7 - should detect overflow via isContextOverflow", async () => {
const model = getModel("minimax", "MiniMax-M2.7");
const result = await testContextOverflow(model, process.env.MINIMAX_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// Xiaomi MiMo
// =============================================================================
describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing)", () => {
// Xiaomi silently truncates oversized input to fill the context window exactly,
// then returns finish_reason "length" with output=0 (no room left to generate).
// This is a detectable overflow signal but uses stopReason "length" rather than "error".
it("mimo-v2.5-pro - should detect overflow via isContextOverflow", async () => {
const model = getModel("xiaomi", "mimo-v2.5-pro");
const result = await testContextOverflow(model, process.env.XIAOMI_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("length");
expect(result.usage.output).toBe(0);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)("Xiaomi MiMo Token Plan (CN)", () => {
it("mimo-v2.5-pro - should detect overflow via isContextOverflow", async () => {
const model = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro");
const result = await testContextOverflow(model, process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("length");
expect(result.usage.output).toBe(0);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)("Xiaomi MiMo Token Plan (AMS)", () => {
it("mimo-v2.5-pro - should detect overflow via isContextOverflow", async () => {
const model = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro");
const result = await testContextOverflow(model, process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("length");
expect(result.usage.output).toBe(0);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)("Xiaomi MiMo Token Plan (SGP)", () => {
it("mimo-v2.5-pro - should detect overflow via isContextOverflow", async () => {
const model = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro");
const result = await testContextOverflow(model, process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("length");
expect(result.usage.output).toBe(0);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan", () => {
it("qwen3.7-max - should detect overflow via isContextOverflow", async () => {
const model = getModel("qwen-token-plan", "qwen3.7-max");
const result = await testContextOverflow(model, process.env.QWEN_TOKEN_PLAN_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/input length/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual", () => {
it("qwen3.8-max - should detect overflow via isContextOverflow", async () => {
const model = getModel("qwen-token-plan-individual", "qwen3.8-max");
const result = await testContextOverflow(model, process.env.QWEN_TOKEN_PLAN_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/input length/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN)", () => {
it("qwen3.7-max - should detect overflow via isContextOverflow", async () => {
const model = getModel("qwen-token-plan-cn", "qwen3.7-max");
const result = await testContextOverflow(model, process.env.QWEN_TOKEN_PLAN_CN_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/input length/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// Kimi For Coding
// =============================================================================
describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding", () => {
it("kimi-for-coding - should detect overflow via isContextOverflow", async () => {
const model = getModel("kimi-coding", "kimi-for-coding");
const result = await testContextOverflow(model, process.env.KIMI_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// Vercel AI Gateway - Unified API for multiple providers
// =============================================================================
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway", () => {
it("google/gemini-2.5-flash via AI Gateway - should detect overflow via isContextOverflow", async () => {
const model = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
const result = await testContextOverflow(model, process.env.AI_GATEWAY_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// OpenRouter - Multiple backend providers
// Expected pattern: "maximum context length is X tokens"
// =============================================================================
describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter", () => {
// Anthropic backend
it("anthropic/claude-sonnet-4 via OpenRouter - should detect overflow via isContextOverflow", async () => {
const model = getModel("openrouter", "anthropic/claude-sonnet-4");
const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/maximum context length is \d+ tokens/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
// DeepSeek backend
it("deepseek/deepseek-v3.2 via OpenRouter - should detect overflow via isContextOverflow", async () => {
const model = getModel("openrouter", "deepseek/deepseek-v3.2");
const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/maximum context length is \d+ tokens/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
// Mistral backend
it("mistralai/mistral-large-2512 via OpenRouter - should detect overflow via isContextOverflow", async () => {
const model = getModel("openrouter", "mistralai/mistral-large-2512");
const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/maximum context length is \d+ tokens/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
// Google backend
it("google/gemini-2.5-flash via OpenRouter - should detect overflow via isContextOverflow", async () => {
const model = getModel("openrouter", "google/gemini-2.5-flash");
const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/maximum context length is \d+ tokens/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
// Meta/Llama backend
it("meta-llama/llama-4-scout via OpenRouter - should detect overflow via isContextOverflow", async () => {
const model = getModel("openrouter", "meta-llama/llama-4-scout");
const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/maximum context length is \d+ tokens/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// Ollama (local)
// =============================================================================
// Check if ollama is installed and local LLM tests are enabled
let ollamaInstalled = false;
if (!process.env.PI_NO_LOCAL_LLM) {
try {
execSync("which ollama", { stdio: "ignore" });
ollamaInstalled = true;
} catch {
ollamaInstalled = false;
}
}
describe.skipIf(!ollamaInstalled)("Ollama (local)", () => {
let ollamaProcess: ChildProcess | null = null;
let model: Model<"openai-completions">;
beforeAll(async () => {
// Check if model is available, if not pull it
try {
execSync("ollama list | grep -q 'gpt-oss:20b'", { stdio: "ignore" });
} catch {
console.log("Pulling gpt-oss:20b model for Ollama overflow tests...");
try {
execSync("ollama pull gpt-oss:20b", { stdio: "inherit" });
} catch (_e) {
console.warn("Failed to pull gpt-oss:20b model, tests will be skipped");
return;
}
}
// Start ollama server
ollamaProcess = spawn("ollama", ["serve"], {
detached: false,
stdio: "ignore",
});
// Wait for server to be ready
await new Promise<void>((resolve) => {
const checkServer = async () => {
try {
const response = await fetch("http://localhost:11434/api/tags");
if (response.ok) {
resolve();
} else {
setTimeout(checkServer, 500);
}
} catch {
setTimeout(checkServer, 500);
}
};
setTimeout(checkServer, 1000);
});
model = {
id: "gpt-oss:20b",
api: "openai-completions",
provider: "ollama",
baseUrl: "http://localhost:11434/v1",
reasoning: true,
input: ["text"],
contextWindow: 128000,
maxTokens: 16000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
name: "Ollama GPT-OSS 20B",
};
}, 60000);
afterAll(() => {
if (ollamaProcess) {
ollamaProcess.kill("SIGTERM");
ollamaProcess = null;
}
});
it("gpt-oss:20b - should detect overflow via isContextOverflow (ollama silently truncates)", async () => {
const result = await testContextOverflow(model, "ollama");
logResult(result);
// Ollama silently truncates input instead of erroring
// It returns stopReason "stop" with truncated usage
// We cannot detect overflow via error message, only via usage comparison
if (result.stopReason === "stop" && result.hasUsageData) {
// Ollama truncated - check if reported usage is less than what we sent
// This is a "silent overflow" - we can detect it if we know expected input size
console.log(" Ollama silently truncated input to", result.usage.input, "tokens");
// For now, we accept this behavior - Ollama doesn't give us a way to detect overflow
} else if (result.stopReason === "error") {
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}
}, 300000); // 5 min timeout for local model
});
// =============================================================================
// LM Studio (local) - Skip if not running or local LLM tests disabled
// =============================================================================
let lmStudioRunning = false;
if (!process.env.PI_NO_LOCAL_LLM) {
try {
execSync("curl -s --max-time 1 http://localhost:1234/v1/models > /dev/null", { stdio: "ignore" });
lmStudioRunning = true;
} catch {
lmStudioRunning = false;
}
}
describe.skipIf(!lmStudioRunning)("LM Studio (local)", () => {
it("should detect overflow via isContextOverflow", async () => {
const model: Model<"openai-completions"> = {
id: "local-model",
api: "openai-completions",
provider: "lm-studio",
baseUrl: "http://localhost:1234/v1",
reasoning: false,
input: ["text"],
contextWindow: 8192,
maxTokens: 2048,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
name: "LM Studio Local Model",
};
const result = await testContextOverflow(model, "lm-studio");
logResult(result);
expect(result.stopReason).toBe("error");
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// llama.cpp server (local) - Skip if not running or not exposing /v1/completions
// =============================================================================
let llamaCppRunning = false;
if (!process.env.PI_NO_LOCAL_LLM) {
try {
execSync("curl -s --max-time 1 http://localhost:8081/health > /dev/null", { stdio: "ignore" });
const probeStatus = execSync(
'curl -s --max-time 1 -o /dev/null -w \'%{http_code}\' -X POST http://localhost:8081/v1/completions -H \'content-type: application/json\' -d \'{"model":"local-model","prompt":"ping","max_tokens":1}\'',
{ encoding: "utf8" },
).trim();
llamaCppRunning = probeStatus !== "404" && probeStatus !== "405" && probeStatus !== "000";
} catch {
llamaCppRunning = false;
}
}
describe.skipIf(!llamaCppRunning)("llama.cpp (local)", () => {
it("should detect overflow via isContextOverflow", async () => {
// Using small context (4096) to match server --ctx-size setting
const model: Model<"openai-completions"> = {
id: "local-model",
api: "openai-completions",
provider: "llama.cpp",
baseUrl: "http://localhost:8081/v1",
reasoning: false,
input: ["text"],
contextWindow: 4096,
maxTokens: 2048,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
name: "llama.cpp Local Model",
};
const result = await testContextOverflow(model, "llama.cpp");
logResult(result);
expect(result.stopReason).toBe("error");
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
});

View File

@@ -0,0 +1,522 @@
/**
* Cross-Provider Handoff Test
*
* Tests that contexts generated by one provider/model can be consumed by another.
* This catches issues like:
* - Tool call ID format incompatibilities (e.g., OpenAI Codex pipe characters)
* - Thinking block transformation issues
* - Message format incompatibilities
*
* Strategy:
* 1. beforeAll: For each provider/model, generate a "small context" (if not cached):
* - User message asking to use a tool
* - Assistant response with thinking + tool call
* - Tool result
* - Final assistant response
*
* 2. Test: For each target provider/model:
* - Concatenate ALL other contexts into one
* - Ask the model to "say hi"
* - If it fails, there's a compatibility issue
*
* Fixtures are generated fresh on each run.
*/
import { writeFileSync } from "fs";
import { Type } from "typebox";
import { beforeAll, describe, expect, it } from "vitest";
import { completeSimple, getEnvApiKey, getModel } from "../src/compat.ts";
import type { Api, AssistantMessage, Message, Model, Tool, ToolResultMessage } from "../src/types.ts";
import { hasAzureOpenAICredentials } from "./azure-utils.ts";
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts";
import { resolveApiKey } from "./oauth.ts";
// Simple tool for testing
const testToolSchema = Type.Object({
value: Type.Number({ description: "A number to double" }),
});
const testTool: Tool<typeof testToolSchema> = {
name: "double_number",
description: "Doubles a number and returns the result",
parameters: testToolSchema,
};
// Provider/model pairs to test
interface ProviderModelPair {
provider: string;
model: string;
label: string;
apiOverride?: Api;
upstreamApiKeyEnv?: string;
}
const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [
// Anthropic
{ provider: "anthropic", model: "claude-sonnet-4-5", label: "anthropic-claude-sonnet-4-5" },
// Google
{ provider: "google", model: "gemini-3-flash-preview", label: "google-gemini-3-flash-preview" },
// OpenAI
{
provider: "openai",
model: "gpt-4o-mini",
label: "openai-completions-gpt-4o-mini",
apiOverride: "openai-completions",
},
{ provider: "openai", model: "gpt-5-mini", label: "openai-responses-gpt-5-mini" },
{ provider: "azure-openai-responses", model: "gpt-4o-mini", label: "azure-openai-responses-gpt-4o-mini" },
// OpenAI Codex
{ provider: "openai-codex", model: "gpt-5.5", label: "openai-codex-gpt-5.5" },
// GitHub Copilot
{ provider: "github-copilot", model: "claude-sonnet-4.5", label: "copilot-claude-sonnet-4.5" },
{ provider: "github-copilot", model: "gpt-5.1-codex", label: "copilot-gpt-5.1-codex" },
{ provider: "github-copilot", model: "gemini-3-flash-preview", label: "copilot-gemini-3-flash-preview" },
{ provider: "github-copilot", model: "grok-code-fast-1", label: "copilot-grok-code-fast-1" },
// Amazon Bedrock
{
provider: "amazon-bedrock",
model: "global.anthropic.claude-sonnet-4-5-20250929-v1:0",
label: "bedrock-claude-sonnet-4-5",
},
// xAI
{ provider: "xai", model: "grok-4.3", label: "xai-grok-4.3" },
// Cerebras
{ provider: "cerebras", model: "zai-glm-4.7", label: "cerebras-zai-glm-4.7" },
// Cloudflare Workers AI
{ provider: "cloudflare-workers-ai", model: "@cf/moonshotai/kimi-k2.6", label: "cloudflare-kimi-k2.6" },
// Cloudflare AI Gateway
{
provider: "cloudflare-ai-gateway",
model: "workers-ai/@cf/moonshotai/kimi-k2.6",
label: "cloudflare-gateway-kimi-k2.6",
},
{
provider: "cloudflare-ai-gateway",
model: "claude-sonnet-4-5",
label: "cloudflare-gateway-claude-sonnet-4-5",
upstreamApiKeyEnv: "ANTHROPIC_API_KEY",
},
{
provider: "cloudflare-ai-gateway",
model: "gpt-5.1",
label: "cloudflare-gateway-gpt-5.1",
upstreamApiKeyEnv: "OPENAI_API_KEY",
},
// Groq
{ provider: "groq", model: "openai/gpt-oss-120b", label: "groq-gpt-oss-120b" },
// Hugging Face
{ provider: "huggingface", model: "moonshotai/Kimi-K2.5", label: "huggingface-kimi-k2.5" },
// Together AI
{ provider: "together", model: "moonshotai/Kimi-K2.6", label: "together-kimi-k2.6" },
// Baseten
{ provider: "baseten", model: "zai-org/GLM-5.2", label: "baseten-glm-5.2" },
// Kimi For Coding
{ provider: "kimi-coding", model: "kimi-for-coding", label: "kimi-for-coding" },
// Mistral
{ provider: "mistral", model: "devstral-medium-latest", label: "mistral-devstral-medium" },
// MiniMax
{ provider: "minimax", model: "MiniMax-M2.7", label: "minimax-m2.7" },
{ provider: "minimax-cn", model: "MiniMax-M2.7", label: "minimax-m2.7" },
// OpenCode Zen
{ provider: "opencode", model: "big-pickle", label: "zen-big-pickle" },
{ provider: "opencode", model: "claude-sonnet-4-5", label: "zen-claude-sonnet-4-5" },
{ provider: "opencode", model: "gemini-3-flash", label: "zen-gemini-3-flash" },
{ provider: "opencode", model: "glm-4.7-free", label: "zen-glm-4.7-free" },
{ provider: "opencode", model: "gpt-5.2-codex", label: "zen-gpt-5.2-codex" },
{ provider: "opencode", model: "minimax-m2.1-free", label: "zen-minimax-m2.1-free" },
// OpenCode Go
{ provider: "opencode-go", model: "kimi-k2.5", label: "go-kimi-k2.5" },
{ provider: "opencode-go", model: "minimax-m2.5", label: "go-minimax-m2.5" },
// Xiaomi MiMo
{ provider: "xiaomi", model: "mimo-v2.5-pro", label: "xiaomi-mimo-v2.5-pro" },
{ provider: "xiaomi-token-plan-cn", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-cn-mimo-v2.5-pro" },
{ provider: "xiaomi-token-plan-ams", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-ams-mimo-v2.5-pro" },
{ provider: "xiaomi-token-plan-sgp", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-sgp-mimo-v2.5-pro" },
// Qwen Token Plan
{ provider: "qwen-token-plan", model: "qwen3.7-max", label: "qwen-token-plan-qwen3.7-max" },
{ provider: "qwen-token-plan-cn", model: "qwen3.7-max", label: "qwen-token-plan-cn-qwen3.7-max" },
{
provider: "qwen-token-plan-individual",
model: "qwen3.8-max",
label: "qwen-token-plan-individual-qwen3.8-max",
},
{
provider: "qwen-token-plan-individual",
model: "deepseek-v4-flash-0731",
label: "qwen-token-plan-individual-deepseek-v4-flash-0731",
},
{
provider: "qwen-token-plan-individual",
model: "glm-5.2",
label: "qwen-token-plan-individual-glm-5.2",
},
];
// Cached context structure
interface CachedContext {
label: string;
provider: string;
model: string;
api: Api;
messages: Message[];
generatedAt: string;
}
/**
* Get API key for provider - checks OAuth storage first, then env vars
*/
async function getApiKey(provider: string): Promise<string | undefined> {
const oauthKey = await resolveApiKey(provider);
if (oauthKey) return oauthKey;
return getEnvApiKey(provider);
}
/**
* Synchronous check for API key availability (env vars only, for skipIf)
*/
function hasApiKey(pair: ProviderModelPair): boolean {
if (pair.provider === "azure-openai-responses") {
return hasAzureOpenAICredentials();
}
if (pair.provider === "cloudflare-workers-ai") {
return hasCloudflareWorkersAICredentials();
}
if (pair.provider === "cloudflare-ai-gateway") {
if (!hasCloudflareAiGatewayCredentials()) return false;
return pair.upstreamApiKeyEnv ? !!process.env[pair.upstreamApiKeyEnv] : true;
}
return !!getEnvApiKey(pair.provider);
}
function getHeaders(pair: ProviderModelPair): Record<string, string> | undefined {
if (!pair.upstreamApiKeyEnv) return undefined;
const upstreamApiKey = process.env[pair.upstreamApiKeyEnv];
return upstreamApiKey ? { Authorization: `Bearer ${upstreamApiKey}` } : undefined;
}
/**
* Check if any provider has API keys available (for skipIf at describe level)
*/
function hasAnyApiKey(): boolean {
return PROVIDER_MODEL_PAIRS.some((pair) => hasApiKey(pair));
}
function dumpFailurePayload(params: { label: string; error: string; payload?: unknown; messages: Message[] }): void {
const filename = `/tmp/pi-handoff-${params.label}-${Date.now()}.json`;
const body = {
label: params.label,
error: params.error,
payload: params.payload,
messages: params.messages,
};
writeFileSync(filename, JSON.stringify(body, null, 2));
console.log(`Wrote failure payload to ${filename}`);
}
/**
* Generate a context from a provider/model pair.
* Makes a real API call to get authentic tool call IDs and thinking blocks.
*/
async function generateContext(
pair: ProviderModelPair,
apiKey: string,
): Promise<{ messages: Message[]; api: Api } | null> {
const baseModel = (getModel as (p: string, m: string) => Model<Api> | undefined)(pair.provider, pair.model);
if (!baseModel) {
console.log(` Model not found: ${pair.provider}/${pair.model}`);
return null;
}
const model: Model<Api> = pair.apiOverride ? { ...baseModel, api: pair.apiOverride } : baseModel;
const userMessage: Message = {
role: "user",
content: "Please double the number 21 using the double_number tool.",
timestamp: Date.now(),
};
const supportsReasoning = model.reasoning === true;
const headers = getHeaders(pair);
let lastPayload: unknown;
let assistantResponse: AssistantMessage;
try {
assistantResponse = await completeSimple(
model,
{
systemPrompt: "You are a helpful assistant. Use the provided tool to complete the task.",
messages: [userMessage],
tools: [testTool],
},
{
apiKey,
reasoning: supportsReasoning ? "high" : undefined,
headers,
onPayload: (payload) => {
lastPayload = payload;
},
},
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(` Initial request failed: ${msg}`);
dumpFailurePayload({
label: `${pair.label}-initial`,
error: msg,
payload: lastPayload,
messages: [userMessage],
});
return null;
}
if (assistantResponse.stopReason === "error") {
console.log(` Initial request error: ${assistantResponse.errorMessage}`);
dumpFailurePayload({
label: `${pair.label}-initial`,
error: assistantResponse.errorMessage || "Unknown error",
payload: lastPayload,
messages: [userMessage],
});
return null;
}
const toolCall = assistantResponse.content.find((c) => c.type === "toolCall");
if (!toolCall || toolCall.type !== "toolCall") {
console.log(` No tool call in response (stopReason: ${assistantResponse.stopReason})`);
return {
messages: [userMessage, assistantResponse],
api: model.api,
};
}
console.log(` Tool call ID: ${toolCall.id}`);
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: "text", text: "42" }],
isError: false,
timestamp: Date.now(),
};
let finalResponse: AssistantMessage;
const messagesForFinal = [userMessage, assistantResponse, toolResult];
try {
finalResponse = await completeSimple(
model,
{
systemPrompt: "You are a helpful assistant.",
messages: messagesForFinal,
tools: [testTool],
},
{
apiKey,
reasoning: supportsReasoning ? "high" : undefined,
headers,
onPayload: (payload) => {
lastPayload = payload;
},
},
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(` Final request failed: ${msg}`);
dumpFailurePayload({
label: `${pair.label}-final`,
error: msg,
payload: lastPayload,
messages: messagesForFinal,
});
return null;
}
if (finalResponse.stopReason === "error") {
console.log(` Final request error: ${finalResponse.errorMessage}`);
dumpFailurePayload({
label: `${pair.label}-final`,
error: finalResponse.errorMessage || "Unknown error",
payload: lastPayload,
messages: messagesForFinal,
});
return null;
}
return {
messages: [userMessage, assistantResponse, toolResult, finalResponse],
api: model.api,
};
}
describe.skipIf(!hasAnyApiKey())("Cross-Provider Handoff", () => {
let contexts: Record<string, CachedContext>;
let availablePairs: ProviderModelPair[];
beforeAll(async () => {
contexts = {};
availablePairs = [];
console.log("\n=== Generating Fixtures ===\n");
for (const pair of PROVIDER_MODEL_PAIRS) {
const apiKey = await getApiKey(pair.provider);
if (!apiKey || !hasApiKey(pair)) {
console.log(`[${pair.label}] Skipping - no auth for ${pair.provider}`);
continue;
}
console.log(`[${pair.label}] Generating fixture...`);
const result = await generateContext(pair, apiKey);
if (!result || result.messages.length < 4) {
console.log(`[${pair.label}] Failed to generate fixture, skipping`);
continue;
}
contexts[pair.label] = {
label: pair.label,
provider: pair.provider,
model: pair.model,
api: result.api,
messages: result.messages,
generatedAt: new Date().toISOString(),
};
availablePairs.push(pair);
console.log(`[${pair.label}] Generated ${result.messages.length} messages`);
}
console.log(`\n=== ${availablePairs.length}/${PROVIDER_MODEL_PAIRS.length} contexts available ===\n`);
}, 300000);
it.skipIf(!hasAnyApiKey())("should have at least 2 fixtures to test handoffs", () => {
expect(Object.keys(contexts).length).toBeGreaterThanOrEqual(2);
});
it.skipIf(!hasAnyApiKey())(
"should handle cross-provider handoffs for each target",
async () => {
const contextLabels = Object.keys(contexts);
if (contextLabels.length < 2) {
console.log("Not enough fixtures for handoff test, skipping");
return;
}
console.log("\n=== Testing Cross-Provider Handoffs ===\n");
const results: { target: string; success: boolean; error?: string }[] = [];
for (const targetPair of availablePairs) {
const apiKey = await getApiKey(targetPair.provider);
if (!apiKey || !hasApiKey(targetPair)) {
console.log(`[Target: ${targetPair.label}] Skipping - no auth`);
continue;
}
// Collect messages from ALL OTHER contexts
const otherMessages: Message[] = [];
for (const [label, ctx] of Object.entries(contexts)) {
if (label === targetPair.label) continue;
otherMessages.push(...ctx.messages);
}
if (otherMessages.length === 0) {
console.log(`[Target: ${targetPair.label}] Skipping - no other contexts`);
continue;
}
const allMessages: Message[] = [
...otherMessages,
{
role: "user",
content:
"Great, thanks for all that help! Now just say 'Hello, handoff successful!' to confirm you received everything.",
timestamp: Date.now(),
},
];
const baseModel = (getModel as (p: string, m: string) => Model<Api> | undefined)(
targetPair.provider,
targetPair.model,
);
if (!baseModel) {
console.log(`[Target: ${targetPair.label}] Model not found`);
continue;
}
const model: Model<Api> = targetPair.apiOverride
? { ...baseModel, api: targetPair.apiOverride }
: baseModel;
const supportsReasoning = model.reasoning === true;
const headers = getHeaders(targetPair);
console.log(
`[Target: ${targetPair.label}] Testing with ${otherMessages.length} messages from other providers...`,
);
let lastPayload: unknown;
try {
const response = await completeSimple(
model,
{
systemPrompt: "You are a helpful assistant.",
messages: allMessages,
tools: [testTool],
},
{
apiKey,
reasoning: supportsReasoning ? "high" : undefined,
headers,
onPayload: (payload) => {
lastPayload = payload;
},
},
);
if (response.stopReason === "error") {
console.log(`[Target: ${targetPair.label}] FAILED: ${response.errorMessage}`);
dumpFailurePayload({
label: targetPair.label,
error: response.errorMessage || "Unknown error",
payload: lastPayload,
messages: allMessages,
});
results.push({ target: targetPair.label, success: false, error: response.errorMessage });
} else {
const text = response.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join(" ");
const preview = text.slice(0, 100).replace(/\n/g, " ");
console.log(`[Target: ${targetPair.label}] SUCCESS: ${preview}...`);
results.push({ target: targetPair.label, success: true });
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(`[Target: ${targetPair.label}] EXCEPTION: ${msg}`);
dumpFailurePayload({
label: targetPair.label,
error: msg,
payload: lastPayload,
messages: allMessages,
});
results.push({ target: targetPair.label, success: false, error: msg });
}
}
console.log("\n=== Results Summary ===\n");
const successes = results.filter((r) => r.success);
const failures = results.filter((r) => !r.success);
console.log(`Passed: ${successes.length}/${results.length}`);
if (failures.length > 0) {
console.log("\nFailures:");
for (const f of failures) {
console.log(` - ${f.target}: ${f.error}`);
}
}
expect(failures.length).toBe(0);
},
600000,
);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,550 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { convertMessages } from "../src/api/openai-completions.ts";
import { getModel, streamSimple } from "../src/compat.ts";
import type { Api, AssistantMessage, Context, Model, Tool, ToolResultMessage, UserMessage } from "../src/types.ts";
import { estimateContextTokens } from "../src/utils/estimate.ts";
interface AnthropicToolPayload {
name: string;
description?: string;
defer_loading?: boolean;
}
interface AnthropicContentBlock {
type: string;
text?: string;
tool_use_id?: string;
content?: string | Array<{ type: string; tool_name?: string }>;
source?: {
type: string;
media_type: string;
data: string;
};
}
interface AnthropicPayload {
tools?: AnthropicToolPayload[];
messages: Array<{
content: string | AnthropicContentBlock[];
}>;
}
interface OpenAIToolSearchCall {
type: "tool_search_call";
call_id?: string | null;
execution?: string;
status?: string | null;
}
interface OpenAIToolSearchOutput {
type: "tool_search_output";
call_id?: string | null;
execution?: string;
status?: string | null;
tools: Array<{ type: string; name: string; defer_loading?: boolean }>;
}
interface OpenAIAdditionalTools {
type: "additional_tools";
role: "developer";
tools: Array<{ type: string; name: string; defer_loading?: boolean }>;
}
interface OpenAIPayload {
tools?: Array<{ name?: string; function?: { name: string } }>;
input?: Array<
OpenAIAdditionalTools | OpenAIToolSearchCall | OpenAIToolSearchOutput | { type?: string; name?: string }
>;
}
interface KimiTool {
type: "function";
function: {
name: string;
description?: string;
parameters?: Record<string, unknown>;
};
}
interface KimiMessage {
role: string;
content?: unknown;
tools?: KimiTool[];
}
interface KimiPayload {
tools?: KimiTool[];
messages: KimiMessage[];
}
class PayloadCaptured extends Error {}
function makeTool(name: string): Tool {
return {
name,
description: `The ${name} tool`,
parameters: Type.Object({ value: Type.String() }),
};
}
function makeUserMessage(timestamp: number): UserMessage {
return { role: "user", content: "Hello", timestamp };
}
function makeAssistantToolCall(): AssistantMessage {
return {
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "base_tool", arguments: {} }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-opus-4-6",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 2,
};
}
function makeToolResult(addedToolNames: string[]): ToolResultMessage {
return {
role: "toolResult",
toolCallId: "call_1",
toolName: "base_tool",
content: [{ type: "text", text: "done" }],
addedToolNames,
isError: false,
timestamp: 3,
};
}
function makeContext(tools: Tool[], addedToolNames = ["late_tool"]): Context {
return {
messages: [makeUserMessage(1), makeAssistantToolCall(), makeToolResult(addedToolNames), makeUserMessage(4)],
tools,
};
}
function makeKimiModel(deferredToolsMode?: "kimi"): Model<"openai-completions"> {
return {
id: "deferred-tools-model",
name: "Deferred Tools Model",
api: "openai-completions",
provider: "moonshotai",
baseUrl: "http://127.0.0.1:9/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
compat: deferredToolsMode ? { deferredToolsMode } : undefined,
};
}
async function capturePayload<T>(model: Model<Api>, context: Context, apiKey = "fake-key"): Promise<T> {
let captured: T | undefined;
const stream = streamSimple({ ...model, baseUrl: "http://127.0.0.1:9" }, context, {
apiKey,
onPayload: (payload) => {
captured = payload as T;
throw new PayloadCaptured();
},
});
await stream.result();
if (!captured) throw new Error("Expected payload capture");
return captured;
}
function findAnthropicToolResultContent(payload: AnthropicPayload): AnthropicContentBlock[] {
for (const message of payload.messages) {
if (typeof message.content !== "string" && message.content.some((block) => block.type === "tool_result")) {
return message.content;
}
}
throw new Error("No tool result in payload");
}
function findAnthropicToolResult(payload: AnthropicPayload): AnthropicContentBlock {
const result = findAnthropicToolResultContent(payload).find((block) => block.type === "tool_result");
if (!result) throw new Error("No tool result in payload");
return result;
}
function openAIToolNames(payload: OpenAIPayload): string[] {
return (payload.tools ?? []).map((tool) => tool.name ?? tool.function?.name ?? "");
}
function makeCodexToken(): string {
return `header.${btoa(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "account" } }))}.signature`;
}
describe("deferred tools", () => {
it("loads an Anthropic tool at its tool-result marker", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
expect(payload.tools).toMatchObject([{ name: "base_tool" }, { name: "late_tool", defer_loading: true }]);
expect(findAnthropicToolResult(payload).content).toEqual([{ type: "tool_reference", tool_name: "late_tool" }]);
});
it("preserves tool output as sibling content after emitting references", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const assistant = context.messages[1] as AssistantMessage;
assistant.content = [
{ type: "toolCall", id: "call_1", name: "base_tool", arguments: {} },
{ type: "toolCall", id: "call_2", name: "base_tool", arguments: {} },
];
const firstResult = context.messages[2] as ToolResultMessage;
firstResult.content = [
{ type: "text", text: "work completed" },
{ type: "image", mimeType: "image/png", data: "aW1hZ2U=" },
];
context.messages.splice(3, 0, {
...makeToolResult([]),
toolCallId: "call_2",
content: [{ type: "text", text: "second result" }],
});
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
expect(findAnthropicToolResultContent(payload)).toMatchObject([
{
type: "tool_result",
tool_use_id: "call_1",
content: [{ type: "tool_reference", tool_name: "late_tool" }],
},
{ type: "tool_result", tool_use_id: "call_2", content: "second result" },
{ type: "text", text: "work completed" },
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" },
},
]);
});
it("loads a tool introduced by OpenAI history after switching to Anthropic", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const assistant = context.messages[1] as AssistantMessage;
assistant.api = "openai-responses";
assistant.provider = "openai";
assistant.model = "gpt-5.4";
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-8"), context);
expect(payload.tools).toMatchObject([{ name: "base_tool" }, { name: "late_tool", defer_loading: true }]);
expect(findAnthropicToolResult(payload).content).toEqual([{ type: "tool_reference", tool_name: "late_tool" }]);
});
it("does not resurrect a marked tool missing from Context.tools", async () => {
const context = makeContext([makeTool("base_tool")]);
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool"]);
const content = findAnthropicToolResult(payload).content;
expect(Array.isArray(content) && content.some((block) => block.type === "tool_reference")).toBe(false);
});
it("keeps a tool immediate when it was used before its marker", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const assistant = context.messages[1] as AssistantMessage;
assistant.content = [{ type: "toolCall", id: "call_1", name: "late_tool", arguments: {} }];
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool", "late_tool"]);
expect(payload.tools?.every((tool) => !tool.defer_loading)).toBe(true);
});
it("normalizes OAuth names before checking prior tool usage", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("read")], ["read"]);
const assistant = context.messages[1] as AssistantMessage;
assistant.content = [{ type: "toolCall", id: "call_1", name: "Read", arguments: {} }];
const payload = await capturePayload<AnthropicPayload>(
getModel("anthropic", "claude-opus-4-6"),
context,
"sk-ant-oat-fake",
);
expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool", "Read"]);
expect(payload.tools?.every((tool) => !tool.defer_loading)).toBe(true);
const content = findAnthropicToolResult(payload).content;
expect(Array.isArray(content) && content.some((block) => block.type === "tool_reference")).toBe(false);
});
it("matches OAuth-canonicalized markers to active tools", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("read")], ["Read"]);
const payload = await capturePayload<AnthropicPayload>(
getModel("anthropic", "claude-opus-4-6"),
context,
"sk-ant-oat-fake",
);
expect(payload.tools).toMatchObject([{ name: "base_tool" }, { name: "Read", defer_loading: true }]);
const content = findAnthropicToolResult(payload).content;
expect(
Array.isArray(content) &&
content.some((block) => block.type === "tool_reference" && block.tool_name === "Read"),
).toBe(true);
});
it("deduplicates active tools after OAuth canonicalization", async () => {
const context: Context = {
messages: [makeUserMessage(1)],
tools: [makeTool("read"), { ...makeTool("Read"), description: "Canonical definition" }],
};
const payload = await capturePayload<AnthropicPayload>(
getModel("anthropic", "claude-opus-4-6"),
context,
"sk-ant-oat-fake",
);
expect(payload.tools).toMatchObject([{ name: "Read", description: "Canonical definition" }]);
});
it("uses the normal tool list when Anthropic tool references are unsupported", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const models: Model<"anthropic-messages">[] = [
getModel("anthropic", "claude-haiku-4-5"),
{ ...getModel("anthropic", "claude-opus-4-6"), id: "claude-sonnet-4-20250514" },
];
for (const model of models) {
const payload = await capturePayload<AnthropicPayload>(model, context);
expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool", "late_tool"]);
expect(payload.tools?.every((tool) => !tool.defer_loading)).toBe(true);
}
});
it("keeps one immediate Anthropic tool when every current tool is marked", async () => {
const context = makeContext([makeTool("late_tool")]);
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
expect(payload.tools).toMatchObject([{ name: "late_tool" }]);
expect(payload.tools?.[0]?.defer_loading).toBeUndefined();
const content = findAnthropicToolResult(payload).content;
expect(Array.isArray(content) && content.some((block) => block.type === "tool_reference")).toBe(false);
});
it("supports explicit Anthropic compatibility overrides", async () => {
const model: Model<"anthropic-messages"> = {
...getModel("anthropic", "claude-opus-4-6"),
provider: "anthropic-proxy",
compat: { supportsToolReferences: true },
};
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const payload = await capturePayload<AnthropicPayload>(model, context);
expect(payload.tools?.find((tool) => tool.name === "late_tool")?.defer_loading).toBe(true);
});
it("serializes Kimi deferred tools as system tool definitions", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const payload = await capturePayload<KimiPayload>(makeKimiModel("kimi"), context);
expect(payload.tools?.map((tool) => tool.function.name)).toEqual(["base_tool"]);
const toolResultIndex = payload.messages.findIndex((message) => message.role === "tool");
const systemToolIndex = payload.messages.findIndex((message) => message.tools !== undefined);
expect(toolResultIndex).toBeGreaterThanOrEqual(0);
expect(systemToolIndex).toBeGreaterThan(toolResultIndex);
expect(payload.messages[systemToolIndex]?.tools?.map((tool) => tool.function.name)).toEqual(["late_tool"]);
});
it("emits Kimi deferred schemas after all tool results in a batch", () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool"), makeTool("later_tool")]);
context.messages.splice(3, 0, {
...makeToolResult(["later_tool"]),
toolCallId: "call_2",
});
const messages = convertMessages(makeKimiModel("kimi"), context, {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
supportsUsageInStreaming: true,
supportsFinishReason: true,
maxTokensField: "max_tokens",
requiresToolResultName: false,
requiresAssistantAfterToolResult: false,
requiresThinkingAsText: false,
requiresReasoningContentOnAssistantMessages: false,
thinkingFormat: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsStrictMode: false,
supportsOpenAIGrammarTools: false,
cacheControlFormat: undefined,
sendSessionAffinityHeaders: false,
deferredToolsMode: "kimi",
sessionAffinityFormat: "openai",
supportsLongCacheRetention: false,
});
expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "tool", "tool", "system", "user"]);
expect((messages[4] as { tools?: KimiTool[] }).tools?.map((tool) => tool.function.name)).toEqual([
"late_tool",
"later_tool",
]);
});
it("leaves OpenAI Completions tools unchanged without Kimi mode", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const payload = await capturePayload<KimiPayload>(makeKimiModel(), context);
expect(payload.tools?.map((tool) => tool.function.name)).toEqual(["base_tool", "late_tool"]);
expect(payload.messages.some((message) => message.tools !== undefined)).toBe(false);
});
it("loads an OpenAI Responses tool through additional_tools", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const payload = await capturePayload<OpenAIPayload>(getModel("openai", "gpt-5.4"), context);
const additionalTools = payload.input?.find(
(item): item is OpenAIAdditionalTools => item.type === "additional_tools",
);
expect(openAIToolNames(payload)).toEqual(["base_tool"]);
expect(additionalTools).toMatchObject({ role: "developer" });
expect(additionalTools?.tools).toMatchObject([{ type: "function", name: "late_tool" }]);
expect(additionalTools?.tools.every((tool) => tool.defer_loading === undefined)).toBe(true);
expect(payload.input?.some((item) => item.type === "tool_search_call")).toBe(false);
expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false);
});
it("preserves an additional_tools marker after the loaded tool is used", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const lateCall: AssistantMessage = {
...makeAssistantToolCall(),
content: [{ type: "toolCall", id: "call_late|fc_late", name: "late_tool", arguments: {} }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.4",
};
context.messages.splice(3, 0, lateCall, {
...makeToolResult(["late_tool"]),
toolCallId: "call_late|fc_late",
toolName: "late_tool",
});
const payload = await capturePayload<OpenAIPayload>(getModel("openai", "gpt-5.4"), context);
const additionalToolIndexes = (payload.input ?? []).flatMap((item, index) =>
item.type === "additional_tools" ? [index] : [],
);
const lateCallIndex = (payload.input ?? []).findIndex(
(item) => item.type === "function_call" && item.name === "late_tool",
);
expect(additionalToolIndexes).toHaveLength(1);
expect(additionalToolIndexes[0]).toBeLessThan(lateCallIndex);
expect(openAIToolNames(payload)).toEqual(["base_tool"]);
});
it("falls back to client tool search when additional_tools is unsupported", async () => {
const model: Model<"openai-responses"> = {
...getModel("openai", "gpt-5.4"),
provider: "openai-proxy",
compat: { supportsAdditionalTools: false, supportsToolSearch: true },
};
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const payload = await capturePayload<OpenAIPayload>(model, context);
const searchCall = payload.input?.find((item): item is OpenAIToolSearchCall => item.type === "tool_search_call");
const searchOutput = payload.input?.find(
(item): item is OpenAIToolSearchOutput => item.type === "tool_search_output",
);
expect(openAIToolNames(payload)).toEqual(["base_tool"]);
expect(searchCall).toMatchObject({ execution: "client", status: "completed" });
expect(searchOutput?.call_id).toBe(searchCall?.call_id);
expect(searchOutput?.tools).toMatchObject([{ type: "function", name: "late_tool", defer_loading: true }]);
expect(payload.input?.some((item) => item.type === "additional_tools")).toBe(false);
});
it.each(["gpt-5.2", "gpt-5.4-nano", "gpt-5.5-pro"] as const)(
"uses the normal tool list for unsupported OpenAI model %s",
async (modelId) => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const payload = await capturePayload<OpenAIPayload>(getModel("openai", modelId), context);
expect(openAIToolNames(payload)).toEqual(["base_tool", "late_tool"]);
expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false);
},
);
it("uses the normal tool list when OpenAI tool search is explicitly disabled", async () => {
const model: Model<"openai-responses"> = {
...getModel("openai", "gpt-5.4"),
provider: "openai-proxy",
compat: { supportsToolSearch: false },
};
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const payload = await capturePayload<OpenAIPayload>(model, context);
expect(openAIToolNames(payload)).toEqual(["base_tool", "late_tool"]);
expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false);
});
it("selects additional tools, tool search, or top-level tools for Codex models", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const additionalTools = await capturePayload<OpenAIPayload>(
getModel("openai-codex", "gpt-5.6-sol"),
context,
makeCodexToken(),
);
const toolSearch = await capturePayload<OpenAIPayload>(
getModel("openai-codex", "gpt-5.4"),
context,
makeCodexToken(),
);
const topLevel = await capturePayload<OpenAIPayload>(
getModel("openai-codex", "gpt-5.3-codex-spark"),
context,
makeCodexToken(),
);
expect(openAIToolNames(additionalTools)).toEqual(["base_tool"]);
expect(additionalTools.input?.some((item) => item.type === "additional_tools")).toBe(true);
expect(additionalTools.input?.some((item) => item.type === "tool_search_output")).toBe(false);
expect(openAIToolNames(toolSearch)).toEqual(["base_tool"]);
expect(toolSearch.input?.some((item) => item.type === "tool_search_output")).toBe(true);
expect(openAIToolNames(topLevel)).toEqual(["base_tool", "late_tool"]);
expect(topLevel.input?.some((item) => item.type === "additional_tools")).toBe(false);
expect(topLevel.input?.some((item) => item.type === "tool_search_output")).toBe(false);
});
it("leaves providers without deferred loading unchanged", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const payload = await capturePayload<OpenAIPayload>(getModel("groq", "llama-3.3-70b-versatile"), context);
expect(openAIToolNames(payload)).toEqual(["base_tool", "late_tool"]);
});
it("counts definitions marked after the latest usage checkpoint", () => {
const assistant: AssistantMessage = {
...makeAssistantToolCall(),
content: [{ type: "text", text: "done" }],
usage: {
input: 50,
output: 50,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 100,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
};
const plain = estimateContextTokens({ messages: [assistant, makeUserMessage(4)], tools: [] });
const lateTool = { ...makeTool("late_tool"), description: "x".repeat(4000) };
const marked = estimateContextTokens({
messages: [assistant, makeToolResult(["late_tool"])],
tools: [lateTool],
});
expect(marked.tokens).toBeGreaterThan(plain.tokens + 500);
expect(marked.trailingTokens).toBeGreaterThan(plain.trailingTokens + 500);
});
});

View File

@@ -0,0 +1,822 @@
import { describe, expect, it } from "vitest";
import { complete, getModel } from "../src/compat.ts";
import type { Api, AssistantMessage, Context, Model, StreamOptions, UserMessage } from "../src/types.ts";
type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts";
import { hasBedrockCredentials } from "./bedrock-utils.ts";
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts";
import { resolveApiKey } from "./oauth.ts";
// Resolve OAuth tokens at module level (async, runs before tests)
const oauthTokens = await Promise.all([
resolveApiKey("anthropic"),
resolveApiKey("github-copilot"),
resolveApiKey("openai-codex"),
]);
const [anthropicOAuthToken, githubCopilotToken, openaiCodexToken] = oauthTokens;
async function testEmptyMessage<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
// Test with completely empty content array
const emptyMessage: UserMessage = {
role: "user",
content: [],
timestamp: Date.now(),
};
const context: Context = {
messages: [emptyMessage],
};
const response = await complete(llm, context, options);
// Should either handle gracefully or return an error
expect(response).toBeDefined();
expect(response.role).toBe("assistant");
// Should handle empty string gracefully
if (response.stopReason === "error") {
expect(response.errorMessage).toBeDefined();
} else {
expect(response.content).toBeDefined();
}
}
async function testEmptyStringMessage<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
// Test with empty string content
const context: Context = {
messages: [
{
role: "user",
content: "",
timestamp: Date.now(),
},
],
};
const response = await complete(llm, context, options);
expect(response).toBeDefined();
expect(response.role).toBe("assistant");
// Should handle empty string gracefully
if (response.stopReason === "error") {
expect(response.errorMessage).toBeDefined();
} else {
expect(response.content).toBeDefined();
}
}
async function testWhitespaceOnlyMessage<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
// Test with whitespace-only content
const context: Context = {
messages: [
{
role: "user",
content: " \n\t ",
timestamp: Date.now(),
},
],
};
const response = await complete(llm, context, options);
expect(response).toBeDefined();
expect(response.role).toBe("assistant");
// Should handle whitespace-only gracefully
if (response.stopReason === "error") {
expect(response.errorMessage).toBeDefined();
} else {
expect(response.content).toBeDefined();
}
}
async function testEmptyAssistantMessage<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
// Test with empty assistant message in conversation flow
// User -> Empty Assistant -> User
const emptyAssistant: AssistantMessage = {
role: "assistant",
content: [],
api: llm.api,
provider: llm.provider,
model: llm.id,
usage: {
input: 10,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 10,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
const context: Context = {
messages: [
{
role: "user",
content: "Hello, how are you?",
timestamp: Date.now(),
},
emptyAssistant,
{
role: "user",
content: "Please respond this time.",
timestamp: Date.now(),
},
],
};
const response = await complete(llm, context, options);
expect(response).toBeDefined();
expect(response.role).toBe("assistant");
// Should handle empty assistant message in context gracefully
if (response.stopReason === "error") {
expect(response.errorMessage).toBeDefined();
} else {
expect(response.content).toBeDefined();
expect(response.content.length).toBeGreaterThan(0);
}
}
describe("AI Providers Empty Message Tests", () => {
describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider Empty Messages", () => {
const llm = getModel("google", "gemini-2.5-flash");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions Provider Empty Messages", () => {
const llm = getModel("openai", "gpt-4o-mini");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses Provider Empty Messages", () => {
const llm = getModel("openai", "gpt-5-mini");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider Empty Messages", () => {
const llm = getModel("azure-openai-responses", "gpt-4o-mini");
const azureDeploymentName = resolveAzureDeploymentName(llm.id);
const azureOptions = azureDeploymentName ? { azureDeploymentName } : {};
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm, azureOptions);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm, azureOptions);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm, azureOptions);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm, azureOptions);
});
});
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider Empty Messages", () => {
const llm = getModel("anthropic", "claude-haiku-4-5");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider Empty Messages", () => {
const llm = getModel("xai", "grok-4.3");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.GROQ_API_KEY)("Groq Provider Empty Messages", () => {
const llm = getModel("groq", "openai/gpt-oss-20b");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras Provider Empty Messages", () => {
const llm = getModel("cerebras", "gpt-oss-120b");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!hasCloudflareWorkersAICredentials())("Cloudflare Workers AI Provider Empty Messages", () => {
const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!hasCloudflareAiGatewayCredentials())("Cloudflare AI Gateway Provider Empty Messages", () => {
const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider Empty Messages", () => {
const llm = getModel("huggingface", "moonshotai/Kimi-K2.5");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider Empty Messages", () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider Empty Messages", () => {
const llm = getModel("baseten", "zai-org/GLM-5.2");
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm, options);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm, options);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm, options);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm, options);
});
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider Empty Messages", () => {
const llm = getModel("zai", "glm-5.2");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider Empty Messages", () => {
const llm = getModel("mistral", "devstral-medium-latest");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider Empty Messages", () => {
const llm = getModel("minimax", "MiniMax-M2.7");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider Empty Messages", () => {
const llm = getModel("xiaomi", "mimo-v2.5-pro");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)(
"Xiaomi MiMo Token Plan (CN) Provider Empty Messages",
() => {
const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
},
);
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)(
"Xiaomi MiMo Token Plan (AMS) Provider Empty Messages",
() => {
const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
},
);
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)(
"Xiaomi MiMo Token Plan (SGP) Provider Empty Messages",
() => {
const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
},
);
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider Empty Messages", () => {
const llm = getModel("qwen-token-plan", "qwen3.7-max");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Empty Messages", () => {
const llm = getModel("qwen-token-plan-individual", "qwen3.8-max");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Empty Messages", () => {
const llm = getModel("qwen-token-plan-cn", "qwen3.7-max");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Empty Messages", () => {
const llm = getModel("kimi-coding", "kimi-for-coding");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider Empty Messages", () => {
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider Empty Messages", () => {
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
// =========================================================================
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
// =========================================================================
describe("Anthropic OAuth Provider Empty Messages", () => {
const llm = getModel("anthropic", "claude-haiku-4-5");
it.skipIf(!anthropicOAuthToken)("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm, { apiKey: anthropicOAuthToken });
});
it.skipIf(!anthropicOAuthToken)("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm, { apiKey: anthropicOAuthToken });
});
it.skipIf(!anthropicOAuthToken)(
"should handle whitespace-only content",
{ retry: 3, timeout: 30000 },
async () => {
await testWhitespaceOnlyMessage(llm, { apiKey: anthropicOAuthToken });
},
);
it.skipIf(!anthropicOAuthToken)(
"should handle empty assistant message in conversation",
{ retry: 3, timeout: 30000 },
async () => {
await testEmptyAssistantMessage(llm, { apiKey: anthropicOAuthToken });
},
);
});
describe("GitHub Copilot Provider Empty Messages", () => {
it.skipIf(!githubCopilotToken)(
"claude-haiku-4.5 - should handle empty content array",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "claude-haiku-4.5");
await testEmptyMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"claude-haiku-4.5 - should handle empty string content",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "claude-haiku-4.5");
await testEmptyStringMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"claude-haiku-4.5 - should handle whitespace-only content",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "claude-haiku-4.5");
await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"claude-haiku-4.5 - should handle empty assistant message in conversation",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "claude-haiku-4.5");
await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"claude-sonnet-4 - should handle empty content array",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testEmptyMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"claude-sonnet-4 - should handle empty string content",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testEmptyStringMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"claude-sonnet-4 - should handle whitespace-only content",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"claude-sonnet-4 - should handle empty assistant message in conversation",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken });
},
);
});
describe("OpenAI Codex Provider Empty Messages", () => {
it.skipIf(!openaiCodexToken)(
"gpt-5.5 - should handle empty content array",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("openai-codex", "gpt-5.5");
await testEmptyMessage(llm, { apiKey: openaiCodexToken });
},
);
it.skipIf(!openaiCodexToken)(
"gpt-5.5 - should handle empty string content",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("openai-codex", "gpt-5.5");
await testEmptyStringMessage(llm, { apiKey: openaiCodexToken });
},
);
it.skipIf(!openaiCodexToken)(
"gpt-5.5 - should handle whitespace-only content",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("openai-codex", "gpt-5.5");
await testWhitespaceOnlyMessage(llm, { apiKey: openaiCodexToken });
},
);
it.skipIf(!openaiCodexToken)(
"gpt-5.5 - should handle empty assistant message in conversation",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("openai-codex", "gpt-5.5");
await testEmptyAssistantMessage(llm, { apiKey: openaiCodexToken });
},
);
});
});

View File

@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it } from "vitest";
import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts";
const originalCopilotGitHubToken = process.env.COPILOT_GITHUB_TOKEN;
const originalGhToken = process.env.GH_TOKEN;
const originalGitHubToken = process.env.GITHUB_TOKEN;
const originalZaiCodingCnApiKey = process.env.ZAI_CODING_CN_API_KEY;
const originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
const originalAnthropicOauthToken = process.env.ANTHROPIC_OAUTH_TOKEN;
const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY;
afterEach(() => {
if (originalCopilotGitHubToken === undefined) {
delete process.env.COPILOT_GITHUB_TOKEN;
} else {
process.env.COPILOT_GITHUB_TOKEN = originalCopilotGitHubToken;
}
if (originalGhToken === undefined) {
delete process.env.GH_TOKEN;
} else {
process.env.GH_TOKEN = originalGhToken;
}
if (originalGitHubToken === undefined) {
delete process.env.GITHUB_TOKEN;
} else {
process.env.GITHUB_TOKEN = originalGitHubToken;
}
if (originalZaiCodingCnApiKey === undefined) {
delete process.env.ZAI_CODING_CN_API_KEY;
} else {
process.env.ZAI_CODING_CN_API_KEY = originalZaiCodingCnApiKey;
}
if (originalAnthropicAuthToken === undefined) {
delete process.env.ANTHROPIC_AUTH_TOKEN;
} else {
process.env.ANTHROPIC_AUTH_TOKEN = originalAnthropicAuthToken;
}
if (originalAnthropicOauthToken === undefined) {
delete process.env.ANTHROPIC_OAUTH_TOKEN;
} else {
process.env.ANTHROPIC_OAUTH_TOKEN = originalAnthropicOauthToken;
}
if (originalAnthropicApiKey === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey;
}
});
describe("environment API keys", () => {
it("does not treat generic GitHub tokens as GitHub Copilot credentials", () => {
delete process.env.COPILOT_GITHUB_TOKEN;
process.env.GH_TOKEN = "gh-token";
process.env.GITHUB_TOKEN = "github-token";
expect(findEnvKeys("github-copilot")).toBeUndefined();
expect(getEnvApiKey("github-copilot")).toBeUndefined();
});
it("resolves GitHub Copilot credentials from COPILOT_GITHUB_TOKEN", () => {
process.env.COPILOT_GITHUB_TOKEN = "copilot-token";
process.env.GH_TOKEN = "gh-token";
process.env.GITHUB_TOKEN = "github-token";
expect(findEnvKeys("github-copilot")).toEqual(["COPILOT_GITHUB_TOKEN"]);
expect(getEnvApiKey("github-copilot")).toBe("copilot-token");
});
it("resolves ZAI China Coding Plan credentials from ZAI_CODING_CN_API_KEY", () => {
process.env.ZAI_CODING_CN_API_KEY = "zai-coding-cn-token";
expect(findEnvKeys("zai-coding-cn")).toEqual(["ZAI_CODING_CN_API_KEY"]);
expect(getEnvApiKey("zai-coding-cn")).toBe("zai-coding-cn-token");
});
it("reports ANTHROPIC_AUTH_TOKEN but preserves OAuth token API key lookup", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "auth-token";
process.env.ANTHROPIC_OAUTH_TOKEN = "oauth-token";
process.env.ANTHROPIC_API_KEY = "api-key";
expect(findEnvKeys("anthropic")).toEqual(["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]);
expect(getEnvApiKey("anthropic")).toBe("oauth-token");
});
it("does not return ANTHROPIC_AUTH_TOKEN as an API key", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "auth-token";
delete process.env.ANTHROPIC_OAUTH_TOKEN;
delete process.env.ANTHROPIC_API_KEY;
expect(findEnvKeys("anthropic")).toEqual(["ANTHROPIC_AUTH_TOKEN"]);
expect(getEnvApiKey("anthropic")).toBeUndefined();
});
it("preserves ANTHROPIC_OAUTH_TOKEN as an API key", () => {
delete process.env.ANTHROPIC_AUTH_TOKEN;
process.env.ANTHROPIC_OAUTH_TOKEN = "oauth-token";
delete process.env.ANTHROPIC_API_KEY;
expect(findEnvKeys("anthropic")).toEqual(["ANTHROPIC_OAUTH_TOKEN"]);
expect(getEnvApiKey("anthropic")).toBe("oauth-token");
});
it("falls back to ANTHROPIC_API_KEY for API key lookup", () => {
delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.ANTHROPIC_OAUTH_TOKEN;
process.env.ANTHROPIC_API_KEY = "api-key";
expect(getEnvApiKey("anthropic")).toBe("api-key");
});
});

View File

@@ -0,0 +1,226 @@
// Unit tests for the shared provider error-body normalizer.
//
// See issues/provider-error-body-passthrough. These cover one synthesized error
// object per SDK shape (Mistral, openai APIError, @google/genai ApiError, AWS
// Bedrock ServiceException), plus the non-Error fallback, truncation, the empty
// parsed-body edge case, and the formatProviderError compose helper.
import { describe, expect, it } from "vitest";
import { formatProviderError, MAX_PROVIDER_ERROR_BODY_CHARS, normalizeProviderError } from "../src/utils/error-body.ts";
describe("normalizeProviderError", () => {
it("extracts status and body from a Mistral-shaped error", () => {
const error = Object.assign(new Error("Mistral request failed"), {
statusCode: 403,
body: '{"error":"blocked by gateway WAF"}',
});
const norm = normalizeProviderError(error);
expect(norm.status).toBe(403);
expect(norm.body).toBe('{"error":"blocked by gateway WAF"}');
expect(norm.messageCarriesBody).toBe(false);
});
it("reads the parsed body off an openai APIError when the message is opaque", () => {
// makeMessage(status, error, message) yields "<status> status code (no body)"
// when the parsed body is unparsed, while the body stays on error.error.
const error = Object.assign(new Error("403 status code (no body)"), {
status: 403,
error: { error: "blocked by gateway WAF" },
});
const norm = normalizeProviderError(error);
expect(norm.status).toBe(403);
expect(norm.body).toBe('{"error":"blocked by gateway WAF"}');
expect(norm.messageCarriesBody).toBe(false);
});
it("preserves the message when @google/genai already folds the body into it", () => {
const body = { error: { code: 403, message: "Permission denied" } };
const error = Object.assign(new Error(JSON.stringify(body)), {
status: 403,
});
const norm = normalizeProviderError(error);
expect(norm.status).toBe(403);
expect(norm.messageCarriesBody).toBe(true);
expect(norm.message).toBe(JSON.stringify(body));
});
it("extracts status and body from a Bedrock-shaped ServiceException", () => {
const error = Object.assign(new Error("UnknownError"), {
name: "UnknownError",
$metadata: { httpStatusCode: 403 },
$response: { statusCode: 403, body: '{"message":"blocked by gateway WAF"}' },
});
const norm = normalizeProviderError(error);
expect(norm.status).toBe(403);
expect(norm.body).toBe('{"message":"blocked by gateway WAF"}');
expect(norm.messageCarriesBody).toBe(false);
});
it("ignores a Bedrock response stream instead of serializing its internals", () => {
const error = Object.assign(
new Error("Invocation of model ID anthropic.claude-opus-5 with on-demand throughput isn't supported."),
{
name: "ValidationException",
$metadata: { httpStatusCode: 400 },
$response: {
statusCode: 400,
body: { pipe: () => undefined, _events: { close: [null, null] } },
},
},
);
const norm = normalizeProviderError(error);
expect(norm.status).toBe(400);
expect(norm.body).toBeUndefined();
expect(norm.message).toContain("on-demand throughput isn't supported");
expect(norm.messageCarriesBody).toBe(true);
});
it("ignores a class-instance response body without a pipe method instead of serializing it", () => {
// Not every SDK response wrapper is a node stream: web ReadableStreams
// and SDK-specific wrapper classes have no `pipe`, but serializing them
// still yields internals-noise that would replace the real message.
class SdkHttpResponseBody {
locked = false;
state = { storedError: undefined };
}
const error = Object.assign(new Error("Input is too long for requested model."), {
name: "ValidationException",
$metadata: { httpStatusCode: 400 },
$response: { statusCode: 400, body: new SdkHttpResponseBody() },
});
const norm = normalizeProviderError(error);
expect(norm.status).toBe(400);
expect(norm.body).toBeUndefined();
expect(norm.message).toContain("Input is too long");
expect(norm.messageCarriesBody).toBe(true);
});
it("ignores a class-instance `error` field instead of serializing it", () => {
class SdkInnerError {
code = "EPROTO";
internalState = {};
}
const error = Object.assign(new Error("TLS handshake failed"), {
status: 502,
error: new SdkInnerError(),
});
const norm = normalizeProviderError(error);
expect(norm.body).toBeUndefined();
expect(norm.message).toBe("TLS handshake failed");
expect(norm.messageCarriesBody).toBe(true);
});
it("still surfaces a plain parsed JSON body object", () => {
const error = Object.assign(new Error("400 status code (no body)"), {
status: 400,
error: { message: "schema validation failed", field: "tools[0]" },
});
const norm = normalizeProviderError(error);
expect(norm.body).toBe('{"message":"schema validation failed","field":"tools[0]"}');
expect(norm.messageCarriesBody).toBe(false);
});
it("JSON-stringifies a non-Error thrown value", () => {
const norm = normalizeProviderError({ reason: "boom" });
expect(norm.status).toBeUndefined();
expect(norm.body).toBeUndefined();
expect(norm.message).toBe('{"reason":"boom"}');
expect(norm.messageCarriesBody).toBe(false);
});
it("treats an empty parsed body object as no body", () => {
const error = Object.assign(new Error("403 status code (no body)"), {
status: 403,
error: {},
});
const norm = normalizeProviderError(error);
expect(norm.body).toBeUndefined();
expect(norm.messageCarriesBody).toBe(true);
});
it("truncates the body at the cap", () => {
const longBody = "x".repeat(MAX_PROVIDER_ERROR_BODY_CHARS + 50);
const error = Object.assign(new Error("failed"), {
statusCode: 500,
body: longBody,
});
const norm = normalizeProviderError(error);
expect(norm.body).toContain("... [truncated 50 chars]");
expect(norm.body?.length).toBeLessThan(longBody.length);
});
it("sets messageCarriesBody when the message already contains the extracted body", () => {
const error = Object.assign(new Error("500: upstream exploded"), {
statusCode: 500,
body: "upstream exploded",
});
const norm = normalizeProviderError(error);
expect(norm.messageCarriesBody).toBe(true);
});
});
describe("formatProviderError", () => {
it("surfaces status and body without a prefix", () => {
const norm = normalizeProviderError(
Object.assign(new Error("403 status code (no body)"), {
status: 403,
error: { error: "blocked by gateway WAF" },
}),
);
const formatted = formatProviderError(norm);
expect(formatted).toContain("403");
expect(formatted).toContain("blocked by gateway WAF");
expect(formatted).not.toBe("403 status code (no body)");
});
it("applies a provider prefix with status and body", () => {
const norm = normalizeProviderError(
Object.assign(new Error("403 status code (no body)"), {
status: 403,
error: { error: "blocked by gateway WAF" },
}),
);
expect(formatProviderError(norm, "OpenAI API error")).toBe(
'OpenAI API error (403): {"error":"blocked by gateway WAF"}',
);
});
it("preserves the message (with prefix + status) when it already carries the body", () => {
const body = JSON.stringify({ error: { message: "Permission denied" } });
const norm = normalizeProviderError(Object.assign(new Error(body), { status: 403 }));
expect(formatProviderError(norm, "OpenAI API error")).toBe(`OpenAI API error (403): ${body}`);
});
it("returns the bare message for a non-Error value", () => {
const norm = normalizeProviderError({ reason: "boom" });
expect(formatProviderError(norm)).toBe('{"reason":"boom"}');
});
});

View File

@@ -0,0 +1,616 @@
import { afterEach, describe, expect, it } from "vitest";
import {
complete,
fauxAssistantMessage,
fauxText,
fauxThinking,
fauxToolCall,
registerFauxProvider,
stream,
Type,
} from "../src/compat.ts";
import type { AssistantMessageEvent, Context } from "../src/types.ts";
async function collectEvents(streamResult: ReturnType<typeof stream>): Promise<AssistantMessageEvent[]> {
const events: AssistantMessageEvent[] = [];
for await (const event of streamResult) {
events.push(event);
}
return events;
}
const registrations: Array<{ unregister: () => void }> = [];
afterEach(() => {
for (const registration of registrations.splice(0)) {
registration.unregister();
}
});
describe("faux provider", () => {
it("registers a custom provider and estimates usage", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([fauxAssistantMessage("hello world")]);
const context: Context = {
systemPrompt: "Be concise.",
messages: [{ role: "user", content: "hi there", timestamp: Date.now() }],
};
const response = await complete(registration.getModel(), context);
expect(response.content).toEqual([{ type: "text", text: "hello world" }]);
expect(response.usage.input).toBeGreaterThan(0);
expect(response.usage.output).toBeGreaterThan(0);
expect(response.usage.totalTokens).toBe(response.usage.input + response.usage.output);
expect(registration.state.callCount).toBe(1);
});
it("supports helper blocks for text, thinking, and tool calls", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
fauxAssistantMessage([fauxThinking("think"), fauxToolCall("echo", { text: "hi" }), fauxText("done")], {
stopReason: "toolUse",
}),
]);
const response = await complete(registration.getModel(), {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
});
expect(response.content).toEqual([
{ type: "thinking", thinking: "think" },
{ type: "toolCall", id: expect.any(String), name: "echo", arguments: { text: "hi" } },
{ type: "text", text: "done" },
]);
expect(response.stopReason).toBe("toolUse");
});
it("supports multiple models with per-model reasoning and model-aware factories", async () => {
const registration = registerFauxProvider({
models: [
{ id: "faux-fast", name: "Faux Fast", reasoning: false },
{ id: "faux-thinker", name: "Faux Thinker", reasoning: true },
],
});
registrations.push(registration);
registration.setResponses([
(_context, _options, _state, model) => fauxAssistantMessage(`${model.id}:${String(model.reasoning)}`),
(_context, _options, _state, model) => fauxAssistantMessage(`${model.id}:${String(model.reasoning)}`),
]);
expect(registration.models.map((model) => model.id)).toEqual(["faux-fast", "faux-thinker"]);
expect(registration.getModel()).toBe(registration.models[0]);
expect(registration.getModel("faux-fast")?.reasoning).toBe(false);
expect(registration.getModel("faux-thinker")?.reasoning).toBe(true);
const fast = await complete(registration.getModel("faux-fast")!, {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
});
const thinker = await complete(registration.getModel("faux-thinker")!, {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
});
expect(fast.content).toEqual([{ type: "text", text: "faux-fast:false" }]);
expect(thinker.content).toEqual([{ type: "text", text: "faux-thinker:true" }]);
});
it("rewrites api, provider, and model on returned messages", async () => {
const registration = registerFauxProvider({
api: "faux:test",
provider: "faux-provider",
models: [{ id: "faux-model" }],
});
registrations.push(registration);
registration.setResponses([fauxAssistantMessage("hello")]);
const response = await complete(registration.getModel(), {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
});
expect(response.api).toBe("faux:test");
expect(response.provider).toBe("faux-provider");
expect(response.model).toBe("faux-model");
});
it("consumes queued responses in order and errors when exhausted", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([fauxAssistantMessage("first"), fauxAssistantMessage("second")]);
const context: Context = {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
};
const first = await complete(registration.getModel(), context);
const second = await complete(registration.getModel(), context);
const exhausted = await complete(registration.getModel(), context);
expect(first.content).toEqual([{ type: "text", text: "first" }]);
expect(second.content).toEqual([{ type: "text", text: "second" }]);
expect(exhausted.stopReason).toBe("error");
expect(exhausted.errorMessage).toBe("No more faux responses queued");
expect(registration.getPendingResponseCount()).toBe(0);
expect(registration.state.callCount).toBe(3);
});
it("can replace and append queued responses", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([fauxAssistantMessage("first")]);
const context: Context = {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
};
expect((await complete(registration.getModel(), context)).content).toEqual([{ type: "text", text: "first" }]);
expect(registration.getPendingResponseCount()).toBe(0);
registration.setResponses([fauxAssistantMessage("second")]);
expect(registration.getPendingResponseCount()).toBe(1);
expect((await complete(registration.getModel(), context)).content).toEqual([{ type: "text", text: "second" }]);
registration.appendResponses([fauxAssistantMessage("third"), fauxAssistantMessage("fourth")]);
expect(registration.getPendingResponseCount()).toBe(2);
expect((await complete(registration.getModel(), context)).content).toEqual([{ type: "text", text: "third" }]);
expect((await complete(registration.getModel(), context)).content).toEqual([{ type: "text", text: "fourth" }]);
expect(registration.getPendingResponseCount()).toBe(0);
});
it("supports async response factories", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
async (context, _options, state) => fauxAssistantMessage(`${context.messages.length}:${state.callCount}`),
]);
const response = await complete(registration.getModel(), {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
});
expect(response.content).toEqual([{ type: "text", text: "1:1" }]);
});
it("emits an error when a response factory throws", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
() => {
throw new Error("boom");
},
]);
const events = await collectEvents(
stream(registration.getModel(), { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }),
);
expect(events).toHaveLength(1);
expect(events[0].type).toBe("error");
if (events[0].type === "error") {
expect(events[0].error.stopReason).toBe("error");
expect(events[0].error.errorMessage).toBe("boom");
}
});
it("rejects a queued response without a terminal stop reason", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([fauxAssistantMessage("partial", { stopReason: "pending" })]);
const events = await collectEvents(
stream(registration.getModel(), { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }),
);
expect(events.some((event) => event.type === "done")).toBe(false);
const terminal = events.at(-1);
expect(terminal?.type).toBe("error");
if (terminal?.type === "error") {
expect(terminal.error.stopReason).toBe("error");
expect(terminal.error.errorMessage).toBe("Faux response ended without a stop reason");
}
});
it("estimates prompt and output tokens from serialized context", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([fauxAssistantMessage("done")]);
const tool = {
name: "echo",
description: "Echo back text",
parameters: Type.Object({ text: Type.String() }),
};
const context: Context = {
systemPrompt: "sys",
messages: [
{
role: "user",
content: [
{ type: "text", text: "hello" },
{ type: "image", mimeType: "image/png", data: "abcd" },
],
timestamp: 1,
},
fauxAssistantMessage("prior"),
{
role: "toolResult",
toolCallId: "tool-1",
toolName: "echo",
content: [{ type: "text", text: "tool out" }],
isError: false,
timestamp: 2,
},
],
tools: [tool],
};
const response = await complete(registration.getModel(), context);
const promptText = [
"system:sys",
"user:hello\n[image:image/png:4]",
"assistant:prior",
"toolResult:echo\ntool out",
`tools:${JSON.stringify([tool])}`,
].join("\n\n");
const expectedPromptTokens = Math.ceil(promptText.length / 4);
const expectedOutputTokens = Math.ceil("done".length / 4);
expect(response.usage.input).toBe(expectedPromptTokens);
expect(response.usage.output).toBe(expectedOutputTokens);
expect(response.usage.cacheRead).toBe(0);
expect(response.usage.cacheWrite).toBe(0);
expect(response.usage.totalTokens).toBe(expectedPromptTokens + expectedOutputTokens);
});
it("does not share cache across sessions or requests without sessionId", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
fauxAssistantMessage("first"),
fauxAssistantMessage("second"),
fauxAssistantMessage("third"),
]);
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
const first = await complete(registration.getModel(), context, {
sessionId: "session-1",
cacheRetention: "short",
});
expect(first.usage.cacheWrite).toBeGreaterThan(0);
context.messages.push(first);
context.messages.push({ role: "user", content: "follow up", timestamp: Date.now() + 1 });
const second = await complete(registration.getModel(), context, {
sessionId: "session-2",
cacheRetention: "short",
});
expect(second.usage.cacheRead).toBe(0);
expect(second.usage.cacheWrite).toBeGreaterThan(0);
const third = await complete(registration.getModel(), context);
expect(third.usage.cacheRead).toBe(0);
expect(third.usage.cacheWrite).toBe(0);
});
it("simulates prompt caching per sessionId", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([fauxAssistantMessage("first"), fauxAssistantMessage("second")]);
const context: Context = {
systemPrompt: "Be concise.",
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
const first = await complete(registration.getModel(), context, {
sessionId: "session-1",
cacheRetention: "short",
});
expect(first.usage.cacheRead).toBe(0);
expect(first.usage.cacheWrite).toBeGreaterThan(0);
context.messages.push(first);
context.messages.push({ role: "user", content: "follow up", timestamp: Date.now() + 1 });
const second = await complete(registration.getModel(), context, {
sessionId: "session-1",
cacheRetention: "short",
});
expect(second.usage.cacheRead).toBeGreaterThan(0);
expect(second.usage.input + second.usage.cacheRead).toBeGreaterThan(second.usage.input);
});
it("does not simulate caching when cacheRetention is none", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([fauxAssistantMessage("first"), fauxAssistantMessage("second")]);
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
await complete(registration.getModel(), context, { sessionId: "session-1", cacheRetention: "none" });
context.messages.push(fauxAssistantMessage("first"));
context.messages.push({ role: "user", content: "follow up", timestamp: Date.now() + 1 });
const second = await complete(registration.getModel(), context, {
sessionId: "session-1",
cacheRetention: "none",
});
expect(second.usage.cacheRead).toBe(0);
expect(second.usage.cacheWrite).toBe(0);
});
it("streams thinking, text, and partial tool call deltas", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
fauxAssistantMessage(
[
fauxThinking("thinking text"),
fauxText("answer text"),
fauxToolCall("echo", { text: "hi", count: 12 }, { id: "tool-1" }),
],
{ stopReason: "toolUse" },
),
]);
const events: string[] = [];
const toolCallDeltas: string[] = [];
const s = stream(registration.getModel(), { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] });
for await (const event of s) {
events.push(event.type);
if (event.type === "toolcall_delta") {
toolCallDeltas.push(event.delta);
}
}
expect(events).toContain("thinking_start");
expect(events).toContain("thinking_delta");
expect(events).toContain("text_start");
expect(events).toContain("text_delta");
expect(events).toContain("toolcall_start");
expect(events).toContain("toolcall_delta");
expect(events).toContain("toolcall_end");
expect(toolCallDeltas.length).toBeGreaterThan(1);
expect(JSON.parse(toolCallDeltas.join(""))).toEqual({ text: "hi", count: 12 });
});
it("streams an exact event order for fixed-size chunks", async () => {
const registration = registerFauxProvider({ tokenSize: { min: 1, max: 1 } });
registrations.push(registration);
registration.setResponses([
fauxAssistantMessage([fauxThinking("go"), fauxText("ok"), fauxToolCall("echo", {}, { id: "tool-1" })], {
stopReason: "toolUse",
}),
]);
const events = await collectEvents(
stream(registration.getModel(), { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }),
);
expect(events[0]).toMatchObject({ type: "start", partial: { stopReason: "pending" } });
expect(events.map((event) => event.type)).toEqual([
"start",
"thinking_start",
"thinking_delta",
"thinking_end",
"text_start",
"text_delta",
"text_end",
"toolcall_start",
"toolcall_delta",
"toolcall_end",
"done",
]);
});
it("streams multiple tool calls in one message", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
fauxAssistantMessage(
[
fauxToolCall("echo", { text: "one" }, { id: "tool-1" }),
fauxToolCall("echo", { text: "two" }, { id: "tool-2" }),
],
{ stopReason: "toolUse" },
),
]);
const events = await collectEvents(
stream(registration.getModel(), { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }),
);
expect(events.filter((event) => event.type === "toolcall_start")).toHaveLength(2);
expect(events.filter((event) => event.type === "toolcall_end")).toHaveLength(2);
});
it("streams an explicit assistant error message as a terminal error", async () => {
const registration = registerFauxProvider({ tokenSize: { min: 2, max: 2 } });
registrations.push(registration);
registration.setResponses([
{
...fauxAssistantMessage("partial"),
stopReason: "error",
errorMessage: "upstream failed",
},
]);
const events = await collectEvents(
stream(registration.getModel(), { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }),
);
expect(events.map((event) => event.type)).toEqual(["start", "text_start", "text_delta", "text_end", "error"]);
const terminal = events[events.length - 1];
expect(terminal.type).toBe("error");
if (terminal.type === "error") {
expect(terminal.reason).toBe("error");
expect(terminal.error.stopReason).toBe("error");
expect(terminal.error.errorMessage).toBe("upstream failed");
}
});
it("streams an explicit assistant aborted message as a terminal error", async () => {
const registration = registerFauxProvider({ tokenSize: { min: 2, max: 2 } });
registrations.push(registration);
registration.setResponses([
{
...fauxAssistantMessage("partial"),
stopReason: "aborted",
errorMessage: "Request was aborted",
},
]);
const events = await collectEvents(
stream(registration.getModel(), { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }),
);
expect(events.map((event) => event.type)).toEqual(["start", "text_start", "text_delta", "text_end", "error"]);
const terminal = events[events.length - 1];
expect(terminal.type).toBe("error");
if (terminal.type === "error") {
expect(terminal.reason).toBe("aborted");
expect(terminal.error.stopReason).toBe("aborted");
expect(terminal.error.errorMessage).toBe("Request was aborted");
}
});
it("supports aborting before the first chunk", async () => {
const registration = registerFauxProvider({ tokensPerSecond: 50, tokenSize: { min: 3, max: 3 } });
registrations.push(registration);
registration.setResponses([fauxAssistantMessage("abcdefghijklmnopqrstuvwxyz")]);
const controller = new AbortController();
controller.abort();
const events = await collectEvents(
stream(
registration.getModel(),
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] },
{ signal: controller.signal },
),
);
expect(events).toHaveLength(1);
expect(events[0].type).toBe("error");
if (events[0].type === "error") {
expect(events[0].reason).toBe("aborted");
expect(events[0].error.stopReason).toBe("aborted");
}
});
it("supports aborting mid-text stream when paced", async () => {
const registration = registerFauxProvider({ tokensPerSecond: 100, tokenSize: { min: 3, max: 3 } });
registrations.push(registration);
registration.setResponses([fauxAssistantMessage("abcdefghijklmnopqrstuvwxyz")]);
const controller = new AbortController();
const events: string[] = [];
let textDeltaCount = 0;
const s = stream(
registration.getModel(),
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] },
{ signal: controller.signal },
);
for await (const event of s) {
events.push(event.type);
if (event.type === "text_delta") {
textDeltaCount++;
controller.abort();
}
}
expect(textDeltaCount).toBe(1);
expect(events).toContain("text_start");
expect(events).toContain("text_delta");
expect(events).toContain("error");
expect(events).not.toContain("text_end");
});
it("supports aborting mid-thinking stream when paced", async () => {
const registration = registerFauxProvider({ tokensPerSecond: 100, tokenSize: { min: 3, max: 3 } });
registrations.push(registration);
registration.setResponses([
{
...fauxAssistantMessage("ignored"),
content: [{ type: "thinking", thinking: "abcdefghijklmnopqrstuvwxyz" }],
},
]);
const controller = new AbortController();
const events: string[] = [];
let thinkingDeltaCount = 0;
const s = stream(
registration.getModel(),
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] },
{ signal: controller.signal },
);
for await (const event of s) {
events.push(event.type);
if (event.type === "thinking_delta") {
thinkingDeltaCount++;
controller.abort();
}
}
expect(thinkingDeltaCount).toBe(1);
expect(events).toContain("thinking_start");
expect(events).toContain("thinking_delta");
expect(events).toContain("error");
expect(events).not.toContain("thinking_end");
});
it("supports aborting mid-toolcall stream when paced", async () => {
const registration = registerFauxProvider({ tokensPerSecond: 100, tokenSize: { min: 3, max: 3 } });
registrations.push(registration);
registration.setResponses([
{
...fauxAssistantMessage("done"),
content: [
{
type: "toolCall",
id: "tool-1",
name: "echo",
arguments: { text: "abcdefghijklmnopqrstuvwxyz", count: 123456789 },
},
],
stopReason: "toolUse",
},
]);
const controller = new AbortController();
const events: string[] = [];
let toolCallDeltaCount = 0;
const s = stream(
registration.getModel(),
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] },
{ signal: controller.signal },
);
for await (const event of s) {
events.push(event.type);
if (event.type === "toolcall_delta") {
toolCallDeltaCount++;
controller.abort();
}
}
expect(toolCallDeltaCount).toBe(1);
expect(events).toContain("toolcall_start");
expect(events).toContain("toolcall_delta");
expect(events).toContain("error");
expect(events).not.toContain("toolcall_end");
});
it("unregisters the provider", async () => {
const registration = registerFauxProvider();
registration.setResponses([fauxAssistantMessage("hello")]);
registration.unregister();
await expect(
complete(registration.getModel(), { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }),
).rejects.toThrow(`No API provider registered for api: ${registration.api}`);
});
});

View File

@@ -0,0 +1,178 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { streamSimple as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { streamSimple as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts";
import { streamSimple as streamGoogleGenerativeAI } from "../src/api/google-generative-ai.ts";
import { streamSimple as streamGoogleVertex } from "../src/api/google-vertex.ts";
import { streamSimple as streamMistral } from "../src/api/mistral-conversations.ts";
import { streamSimple as streamOpenAICodexResponses } from "../src/api/openai-codex-responses.ts";
import { streamSimple as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import { streamSimple as streamOpenAIResponses } from "../src/api/openai-responses.ts";
import { generateImages } from "../src/api/openrouter-images.ts";
import { streamSimple as streamPiMessages } from "../src/api/pi-messages.ts";
import type { Api, Context, FetchFunction, ImagesModel, Model } from "../src/types.ts";
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
function createModel<TApi extends Api>(api: TApi): Model<TApi> {
return {
id: "test-model",
name: "Test Model",
api,
provider: "test-provider",
baseUrl: "https://upstream.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 10_000,
maxTokens: 1_000,
};
}
function mockFetches() {
const fallback = vi.fn<FetchFunction>(async () => {
throw new Error("ambient fetch must not be called");
});
const custom = vi.fn<FetchFunction>(
async () =>
new Response(JSON.stringify({ error: { message: "upstream rejected request" } }), {
status: 401,
headers: { "content-type": "application/json" },
}),
);
vi.stubGlobal("fetch", fallback);
return { custom, fallback };
}
function expectOnlyCustomFetch(
custom: ReturnType<typeof vi.fn<FetchFunction>>,
fallback: ReturnType<typeof vi.fn<FetchFunction>>,
) {
expect(custom).toHaveBeenCalled();
expect(fallback).not.toHaveBeenCalled();
expect(globalThis.fetch).toBe(fallback);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("fetch stream option", () => {
it("passes fetch through streamSimple to the Anthropic SDK", async () => {
const { custom, fallback } = mockFetches();
await streamAnthropic(createModel("anthropic-messages"), context, {
apiKey: "test-key",
fetch: custom,
maxRetries: 0,
}).result();
expectOnlyCustomFetch(custom, fallback);
});
it("passes fetch through streamSimple to OpenAI SDK adapters", async () => {
const adapters = [
() =>
streamOpenAICompletions(createModel("openai-completions"), context, {
apiKey: "test-key",
fetch: custom,
maxRetries: 0,
}).result(),
() =>
streamOpenAIResponses(createModel("openai-responses"), context, {
apiKey: "test-key",
fetch: custom,
maxRetries: 0,
}).result(),
() =>
streamAzureOpenAIResponses(createModel("azure-openai-responses"), context, {
apiKey: "test-key",
fetch: custom,
maxRetries: 0,
}).result(),
];
const { custom, fallback } = mockFetches();
for (const run of adapters) {
await run();
}
expect(custom).toHaveBeenCalledTimes(adapters.length);
expect(fallback).not.toHaveBeenCalled();
expect(globalThis.fetch).toBe(fallback);
});
it("uses fetch for Mistral, Codex SSE, and pi-messages HTTP requests", async () => {
const { custom, fallback } = mockFetches();
await streamMistral(createModel("mistral-conversations"), context, {
apiKey: "test-key",
fetch: custom,
}).result();
await streamOpenAICodexResponses(createModel("openai-codex-responses"), context, {
apiKey: `header.${btoa(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "account" } }))}.signature`,
fetch: custom,
transport: "sse",
maxRetries: 0,
}).result();
await streamPiMessages(createModel("pi-messages"), context, {
apiKey: "test-key",
fetch: custom,
}).result();
expect(custom).toHaveBeenCalledTimes(3);
expect(fallback).not.toHaveBeenCalled();
expect(globalThis.fetch).toBe(fallback);
});
it("rejects custom fetch for Google adapters instead of silently bypassing it", async () => {
const { custom, fallback } = mockFetches();
const google = await streamGoogleGenerativeAI(createModel("google-generative-ai"), context, {
apiKey: "test-key",
fetch: custom,
}).result();
const vertex = await streamGoogleVertex(createModel("google-vertex"), context, {
apiKey: "test-key",
fetch: custom,
}).result();
expect(google.errorMessage).toContain("Custom fetch is not supported by the Google Generative AI adapter");
expect(vertex.errorMessage).toContain("Custom fetch is not supported by the Google Vertex adapter");
expect(custom).not.toHaveBeenCalled();
expect(fallback).not.toHaveBeenCalled();
expect(globalThis.fetch).toBe(fallback);
});
it("allows Google adapters to receive globalThis.fetch explicitly", async () => {
const ambient = vi.fn<FetchFunction>(
async () =>
new Response(JSON.stringify({ error: { message: "upstream rejected request" } }), {
status: 401,
headers: { "content-type": "application/json" },
}),
);
vi.stubGlobal("fetch", ambient);
const result = await streamGoogleGenerativeAI(createModel("google-generative-ai"), context, {
apiKey: "test-key",
fetch: ambient,
}).result();
expect(ambient).toHaveBeenCalledOnce();
expect(result.errorMessage).not.toContain("Custom fetch is not supported");
expect(globalThis.fetch).toBe(ambient);
});
it("uses fetch for image generation", async () => {
const { custom, fallback } = mockFetches();
const model: ImagesModel<"openrouter-images"> = {
...createModel("openrouter-images"),
provider: "openrouter",
output: ["image"],
};
await generateImages(
model,
{ input: [{ type: "text", text: "draw" }] },
{
apiKey: "test-key",
fetch: custom,
maxRetries: 0,
},
);
expectOnlyCustomFetch(custom, fallback);
});
});

View File

@@ -0,0 +1,332 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { getModel, streamSimple } from "../src/compat.ts";
import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts";
import type { Context, Model, Tool } from "../src/types.ts";
const originalFireworksApiKey = process.env.FIREWORKS_API_KEY;
afterEach(() => {
if (originalFireworksApiKey === undefined) {
delete process.env.FIREWORKS_API_KEY;
} else {
process.env.FIREWORKS_API_KEY = originalFireworksApiKey;
}
});
describe("Fireworks models", () => {
it("registers the default Kimi K2.6 model via Anthropic-compatible Messages API", () => {
const model = getModel("fireworks", "accounts/fireworks/models/kimi-k2p6");
expect(model).toBeDefined();
expect(model.api).toBe("anthropic-messages");
expect(model.provider).toBe("fireworks");
expect(model.baseUrl).toBe("https://api.fireworks.ai/inference");
expect(model.reasoning).toBe(true);
expect(model.input).toEqual(["text", "image"]);
expect(model.contextWindow).toBe(262000);
expect(model.maxTokens).toBe(262000);
expect(model.cost).toEqual({
input: 0.95,
output: 4,
cacheRead: 0.16,
cacheWrite: 0,
});
});
it("aligns GLM 5.2 Fast with GLM 5.2's OpenAI-compatible config", () => {
const base = getModel("fireworks", "accounts/fireworks/models/glm-5p2");
const fast = getModel("fireworks", "accounts/fireworks/routers/glm-5p2-fast");
expect(fast.api).toBe(base.api);
expect(fast.baseUrl).toBe(base.baseUrl);
expect(fast.compat).toEqual(base.compat);
expect(fast.thinkingLevelMap).toEqual(base.thinkingLevelMap);
});
it.each(["accounts/fireworks/models/glm-5p2", "accounts/fireworks/routers/glm-5p2-fast"] as const)(
"omits unsupported long cache retention for %s",
async (modelId) => {
const model = getModel("fireworks", modelId);
let payload: Record<string, unknown> | undefined;
const response = streamSimple(
model,
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
{
apiKey: "test-fireworks-key",
cacheRetention: "long",
sessionId: "test-fireworks-session",
onPayload: (value) => {
payload = value as Record<string, unknown>;
throw new Error("payload captured");
},
},
);
await response.result();
expect(payload).toBeDefined();
expect(payload?.prompt_cache_retention).toBeUndefined();
},
);
it("routes Kimi K3 through the OpenAI-compatible API with native effort controls", async () => {
const base = getModel("fireworks", "accounts/fireworks/models/kimi-k3");
const fast = getModel("fireworks", "accounts/fireworks/routers/kimi-k3-fast");
const compat = {
supportsStore: false,
supportsDeveloperRole: false,
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "openai",
deferredToolsMode: "kimi",
sendSessionAffinityHeaders: true,
supportsLongCacheRetention: false,
};
const thinkingLevelMap = {
off: null,
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: null,
max: "max",
};
expect(base.api).toBe("openai-completions");
expect(base.baseUrl).toBe("https://api.fireworks.ai/inference/v1");
expect(base.compat).toEqual(compat);
expect(base.thinkingLevelMap).toEqual(thinkingLevelMap);
expect(fast.api).toBe(base.api);
expect(fast.baseUrl).toBe(base.baseUrl);
expect(fast.compat).toEqual(compat);
expect(fast.thinkingLevelMap).toEqual(thinkingLevelMap);
let payload: Record<string, unknown> | undefined;
const response = streamSimple(
base,
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
{
apiKey: "test-fireworks-key",
reasoning: "max",
onPayload: (value) => {
payload = value as Record<string, unknown>;
throw new Error("payload captured");
},
},
);
await response.result();
expect(payload?.reasoning_effort).toBe("max");
});
it("resolves FIREWORKS_API_KEY from the environment", () => {
process.env.FIREWORKS_API_KEY = "test-fireworks-key";
expect(findEnvKeys("fireworks")).toEqual(["FIREWORKS_API_KEY"]);
expect(getEnvApiKey("fireworks")).toBe("test-fireworks-key");
});
it("sets Fireworks-specific compat for session affinity and unsupported tool fields", () => {
const model = getModel("fireworks", "accounts/fireworks/models/kimi-k2p6");
expect(model.compat).toBeDefined();
expect(model.compat?.sendSessionAffinityHeaders).toBe(true);
expect(model.compat?.supportsEagerToolInputStreaming).toBe(false);
expect(model.compat?.supportsCacheControlOnTools).toBe(false);
expect(model.compat?.supportsLongCacheRetention).toBe(false);
});
});
// --- Integration tests for Fireworks Anthropic session affinity and tool compat ---
interface CapturedRequest {
headers: IncomingMessage["headers"];
body: Record<string, unknown>;
}
const tool: Tool = {
name: "lookup",
description: "Look up a value",
parameters: Type.Object({ value: Type.String() }),
};
const FIREWORKS_ANTHROPIC_COMPAT = {
sendSessionAffinityHeaders: true,
supportsEagerToolInputStreaming: false,
supportsCacheControlOnTools: false,
supportsLongCacheRetention: false,
} satisfies NonNullable<Model<"anthropic-messages">["compat"]>;
function createFireworksModel(
compat: Model<"anthropic-messages">["compat"] = FIREWORKS_ANTHROPIC_COMPAT,
): Model<"anthropic-messages"> {
return {
id: "accounts/fireworks/models/kimi-k2p6",
name: "Kimi K2.6",
api: "anthropic-messages",
provider: "fireworks",
baseUrl: "http://127.0.0.1:0", // overridden by captureAnthropicRequest
reasoning: true,
input: ["text", "image"],
cost: { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
contextWindow: 262000,
maxTokens: 262000,
compat,
};
}
function createAnthropicModel(): Model<"anthropic-messages"> {
return {
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "http://127.0.0.1:0", // overridden by captureAnthropicRequest
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
};
}
function createContext(tools: Tool[] = [tool]): Context {
return {
messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }],
...(tools.length > 0 ? { tools } : {}),
};
}
async function readRequestBody(request: IncomingMessage): Promise<Record<string, unknown>> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
}
function writeEmptySseResponse(response: ServerResponse): void {
response.writeHead(200, { "content-type": "text/event-stream" });
response.end();
}
async function captureAnthropicRequest(
model: Model<"anthropic-messages">,
context: Context,
options?: { sessionId?: string; cacheRetention?: string },
): Promise<CapturedRequest> {
let capturedRequest: CapturedRequest | undefined;
const server = createServer(async (request, response) => {
capturedRequest = {
headers: request.headers,
body: await readRequestBody(request),
};
writeEmptySseResponse(response);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
try {
// Override the model's baseUrl to point to the local test server
const localModel = { ...model, baseUrl: `http://127.0.0.1:${address.port}` };
const stream = streamAnthropic(localModel, context, {
apiKey: "test-key",
cacheRetention: (options?.cacheRetention as "none" | "short" | "long") ?? "short",
sessionId: options?.sessionId,
});
for await (const event of stream) {
if (event.type === "done" || event.type === "error") break;
}
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
if (!capturedRequest) {
throw new Error("Anthropic request was not captured");
}
return capturedRequest;
}
function getTools(body: Record<string, unknown>): Record<string, unknown>[] {
const tools = body.tools;
if (!Array.isArray(tools)) {
throw new Error("Expected tools in request body");
}
return tools as Record<string, unknown>[];
}
describe("Fireworks Anthropic session affinity and tool compat", () => {
it("sends x-session-affinity header for Fireworks models", async () => {
const model = createFireworksModel();
// Need a real port, capture will assign one
const request = await captureAnthropicRequest(model, createContext(), {
sessionId: "fireworks-session-1",
});
expect(request.headers["x-session-affinity"]).toBe("fireworks-session-1");
});
it("omits x-session-affinity header for native Anthropic models", async () => {
const model = createAnthropicModel();
const request = await captureAnthropicRequest(model, createContext(), {
sessionId: "anthropic-session-1",
});
expect(request.headers["x-session-affinity"]).toBeUndefined();
});
it("omits x-session-affinity header when cacheRetention is none", async () => {
const model = createFireworksModel();
const request = await captureAnthropicRequest(model, createContext(), {
sessionId: "fireworks-session-2",
cacheRetention: "none",
});
expect(request.headers["x-session-affinity"]).toBeUndefined();
});
it("omits cache_control on tools for Fireworks models", async () => {
const model = createFireworksModel();
const request = await captureAnthropicRequest(model, createContext());
const tools = getTools(request.body);
const lastTool = tools[tools.length - 1];
expect(lastTool.cache_control).toBeUndefined();
});
it("omits eager_input_streaming on tools for Fireworks models", async () => {
const model = createFireworksModel();
const request = await captureAnthropicRequest(model, createContext());
const tools = getTools(request.body);
for (const t of tools) {
expect(t.eager_input_streaming).toBeUndefined();
}
});
it("sends cache_control on tools for native Anthropic models", async () => {
const model = createAnthropicModel();
const request = await captureAnthropicRequest(model, createContext());
const tools = getTools(request.body);
const lastTool = tools[tools.length - 1];
expect(lastTool.cache_control).toBeDefined();
expect((lastTool.cache_control as { type: string }).type).toBe("ephemeral");
});
it("sends eager_input_streaming on tools for native Anthropic models", async () => {
const model = createAnthropicModel();
const request = await captureAnthropicRequest(model, createContext());
const tools = getTools(request.body);
expect(tools[0].eager_input_streaming).toBe(true);
});
});

View File

@@ -0,0 +1,87 @@
import { spawnSync } from "node:child_process";
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
const temporaryRoots: string[] = [];
afterEach(() => {
for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("strict model generation", () => {
it("fails before mutating generated data when an Individual model loses tool support", () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), "pi-generate-models-"));
temporaryRoots.push(fixtureRoot);
const isolatedPackageRoot = join(fixtureRoot, "package");
mkdirSync(isolatedPackageRoot);
for (const entry of ["package.json", "scripts", "src"]) {
cpSync(join(packageRoot, entry), join(isolatedPackageRoot, entry), { recursive: true });
}
const preloadPath = join(fixtureRoot, "mock-models-dev.mjs");
const modelIds = [
"deepseek-v4-flash-0731",
"deepseek-v4-pro",
"deepseek-v4-pro-0813",
"glm-5.2",
"qwen3.6-flash",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.8-flash",
"qwen3.8-max",
"qwen3.8-max-preview",
];
const sourceModels = Object.fromEntries(
modelIds.map((id) => [
id,
{
id,
name: id,
tool_call: id !== "deepseek-v4-flash-0731",
},
]),
);
const catalog = { "alibaba-token-plan": { models: sourceModels } };
writeFileSync(
preloadPath,
`const catalog = ${JSON.stringify(catalog)};\n` +
`globalThis.fetch = async (input) => {\n` +
` if (String(input) === "https://models.dev/api.json") {\n` +
` return new Response(JSON.stringify(catalog), { status: 200 });\n` +
` }\n` +
` throw new Error(\`Unexpected fetch: \${String(input)}\`);\n` +
`};\n`,
);
const generatedPaths = [
"src/models.generated.ts",
"src/providers/qwen-token-plan-individual.models.ts",
"src/providers/data/qwen-token-plan-individual.json",
"src/providers/data/.manifest.json",
];
const sourceBefore = generatedPaths.map((path) => readFileSync(join(packageRoot, path), "utf8"));
const isolatedBefore = generatedPaths.map((path) => readFileSync(join(isolatedPackageRoot, path), "utf8"));
const result = spawnSync(
process.execPath,
["--import", pathToFileURL(preloadPath).href, "scripts/generate-models.ts", "--strict"],
{
cwd: isolatedPackageRoot,
encoding: "utf8",
timeout: 10_000,
},
);
expect(result.status).toBe(1);
expect(`${result.stdout}\n${result.stderr}`).toContain(
"qwen-token-plan-individual model IDs do not match (missing: deepseek-v4-flash-0731)",
);
expect(generatedPaths.map((path) => readFileSync(join(isolatedPackageRoot, path), "utf8"))).toEqual(
isolatedBefore,
);
expect(generatedPaths.map((path) => readFileSync(join(packageRoot, path), "utf8"))).toEqual(sourceBefore);
});
});

View File

@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { getModel } from "../src/compat.ts";
import { getSupportedThinkingLevels } from "../src/models.ts";
import type { Context } from "../src/types.ts";
const mockState = vi.hoisted(() => ({
constructorOpts: undefined as Record<string, unknown> | undefined,
createParams: undefined as Record<string, unknown> | undefined,
}));
vi.mock("@anthropic-ai/sdk", () => {
function createSseResponse(): Response {
const body = [
`event: message_start\ndata: ${JSON.stringify({
type: "message_start",
message: {
id: "msg_test",
usage: { input_tokens: 10, output_tokens: 0 },
},
})}\n`,
`event: message_delta\ndata: ${JSON.stringify({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { output_tokens: 5 },
})}\n`,
].join("\n");
return new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
class FakeAnthropic {
constructor(opts: Record<string, unknown>) {
mockState.constructorOpts = opts;
}
beta = {
messages: {
create: (params: Record<string, unknown>) => {
mockState.createParams = params;
return {
asResponse: async () => createSseResponse(),
};
},
},
};
}
return { default: FakeAnthropic };
});
describe("Copilot Claude via Anthropic Messages", () => {
const context: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
it("applies Copilot-specific adaptive thinking effort overrides", () => {
const opus47 = getModel("github-copilot", "claude-opus-4.7");
expect(opus47.thinkingLevelMap).toMatchObject({ minimal: "low", xhigh: "xhigh", max: "max" });
expect(getSupportedThinkingLevels(opus47)).toContain("xhigh");
expect(getSupportedThinkingLevels(opus47)).toContain("max");
const opus5 = getModel("github-copilot", "claude-opus-5");
expect(opus5.api).toBe("anthropic-messages");
expect(opus5.contextWindow).toBe(1000000);
expect(opus5.thinkingLevelMap).toMatchObject({ minimal: "low", xhigh: "xhigh", max: "max" });
expect(getSupportedThinkingLevels(opus5)).toContain("xhigh");
expect(getSupportedThinkingLevels(opus5)).toContain("max");
const sonnet46 = getModel("github-copilot", "claude-sonnet-4.6");
expect(sonnet46.thinkingLevelMap).toMatchObject({ minimal: "low", max: "max" });
expect(getSupportedThinkingLevels(sonnet46)).toContain("max");
expect(getSupportedThinkingLevels(sonnet46)).not.toContain("xhigh");
});
it("uses Bearer auth, Copilot headers, and valid Anthropic Messages payload", async () => {
const model = getModel("github-copilot", "claude-sonnet-4.6");
expect(model.api).toBe("anthropic-messages");
const s = streamAnthropic(model, context, { apiKey: "tid_copilot_session_test_token" });
for await (const event of s) {
if (event.type === "error") break;
}
const opts = mockState.constructorOpts!;
expect(opts).toBeDefined();
// Auth: apiKey null, authToken for Bearer
expect(opts.apiKey).toBeNull();
expect(opts.authToken).toBe("tid_copilot_session_test_token");
const headers = opts.defaultHeaders as Record<string, string>;
// Copilot static headers from model.headers
expect(headers["User-Agent"]).toContain("GitHubCopilotChat");
expect(headers["Copilot-Integration-Id"]).toBe("vscode-chat");
// Dynamic headers
expect(headers["X-Initiator"]).toBe("user");
expect(headers["Openai-Intent"]).toBe("conversation-edits");
// Payload is valid Anthropic Messages format
const params = mockState.createParams!;
expect(params.betas ?? []).not.toContain("fine-grained-tool-streaming-2025-05-14");
expect(params.model).toBe("claude-sonnet-4.6");
expect(params.stream).toBe(true);
expect(params.max_tokens).toBe(model.maxTokens);
expect(Array.isArray(params.messages)).toBe(true);
});
it("omits interleaved-thinking beta for adaptive-thinking models", async () => {
const model = getModel("github-copilot", "claude-sonnet-4.6");
const s = streamAnthropic(model, context, {
apiKey: "tid_copilot_session_test_token",
interleavedThinking: true,
});
for await (const event of s) {
if (event.type === "error") break;
}
expect(mockState.createParams?.betas ?? []).not.toContain("interleaved-thinking-2025-05-14");
});
});

View File

@@ -0,0 +1,743 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
import { createModels } from "../src/models.ts";
import { githubCopilotProvider } from "../src/providers/github-copilot.ts";
const neverAbortedSignal = new AbortController().signal;
const testCopilotAccessToken = "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;";
const testCopilotModelsUrl = "https://api.individual.githubcopilot.com/models";
function jsonResponse(body: unknown, status: number = 200, headers?: Record<string, string>): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
...headers,
},
});
}
function getUrl(input: unknown): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
if (input instanceof Request) {
return input.url;
}
throw new Error(`Unsupported fetch input: ${String(input)}`);
}
function requireModelId(models: readonly { id: string }[], index: number): string {
const model = models[index];
if (!model) throw new Error(`Expected a GitHub Copilot model at index ${index}`);
return model.id;
}
function stubGitHubCopilotLoginFetch(options: {
models: () => Response;
policy?: (modelId: string) => Response;
}): void {
const fetchMock = vi.fn(async (input: string | URL | Request): Promise<Response> => {
const url = getUrl(input);
if (url.endsWith("/login/device/code")) {
return jsonResponse({
device_code: "device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://github.com/login/device",
interval: 1,
expires_in: 900,
});
}
if (url.endsWith("/login/oauth/access_token")) {
return jsonResponse({ access_token: "ghu_refresh_token" });
}
if (url.includes("/copilot_internal/v2/token")) {
return jsonResponse({ token: testCopilotAccessToken, expires_at: 9999999999 });
}
if (url === testCopilotModelsUrl) return options.models();
if (url.startsWith(`${testCopilotModelsUrl}/`) && url.endsWith("/policy")) {
if (!options.policy) throw new Error(`Unexpected policy request: ${url}`);
return options.policy(url.slice(`${testCopilotModelsUrl}/`.length, -"/policy".length));
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
}
function loginGitHubCopilotForTest(options: {
onDeviceCode(info: {
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}): void;
onPrompt(prompt: { message: string; placeholder?: string; allowEmpty?: boolean }): Promise<string>;
onProgress?(message: string): void;
signal?: AbortSignal;
}) {
return githubCopilotOAuth.login({
signal: options.signal ?? neverAbortedSignal,
prompt: (prompt) => {
if (prompt.type !== "text") throw new Error(`Unexpected prompt: ${prompt.type}`);
return options.onPrompt({ message: prompt.message, placeholder: prompt.placeholder, allowEmpty: true });
},
notify: (event) => {
if (event.type === "device_code") {
const { type: _, ...info } = event;
options.onDeviceCode(info);
}
if (event.type === "progress") options.onProgress?.(event.message);
},
});
}
async function refreshGitHubCopilotModelsForTest(
data: readonly unknown[],
proxyHost: string = "proxy.individual.githubcopilot.com",
) {
const accessToken = `tid=test;exp=9999999999;proxy-ep=${proxyHost};`;
const modelsUrl = `https://${proxyHost.replace(/^proxy\./, "api.")}/models`;
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
const url = getUrl(input);
if (url.includes("/copilot_internal/v2/token")) {
return jsonResponse({
token: accessToken,
expires_at: 9999999999,
});
}
if (url === modelsUrl) {
expect(init?.headers).toMatchObject({
Authorization: `Bearer ${accessToken}`,
});
return jsonResponse({ data });
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
return githubCopilotOAuth.refresh(
{
type: "oauth",
access: "old-access-token",
refresh: "ghu_refresh_token",
expires: 0,
},
neverAbortedSignal,
);
}
describe("GitHub Copilot OAuth device flow", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("filters models to the authenticated account picker catalog", async () => {
const provider = githubCopilotProvider();
const providerModels = provider.getModels();
const pickerModelId = requireModelId(providerModels, 0);
const disabledModelId = requireModelId(providerModels, 1);
const hiddenModelId = requireModelId(providerModels, 2);
const credentials = await refreshGitHubCopilotModelsForTest([
{
id: pickerModelId,
model_picker_enabled: true,
capabilities: { supports: { tool_calls: true } },
},
{
id: disabledModelId,
model_picker_enabled: true,
policy: { state: "disabled" },
capabilities: { supports: { tool_calls: true } },
},
{
id: hiddenModelId,
model_picker_enabled: false,
policy: { state: "enabled" },
capabilities: { supports: { tool_calls: true } },
},
]);
expect(credentials.availableModelIds).toEqual([pickerModelId]);
const store = new InMemoryCredentialStore();
await store.modify("github-copilot", async () => ({ ...credentials, type: "oauth" }));
const models = createModels({ credentials: store });
models.setProvider(provider);
expect((await models.getAvailable("github-copilot")).map((model) => model.id)).toEqual([pickerModelId]);
});
it("falls back to explicitly enabled policy models when the picker catalog is empty", async () => {
const provider = githubCopilotProvider();
const enabledModelId = requireModelId(provider.getModels(), 0);
const credentials = await refreshGitHubCopilotModelsForTest([
{
id: enabledModelId,
model_picker_enabled: false,
policy: { state: "enabled" },
capabilities: { supports: { tool_calls: true } },
},
{
id: "policy-disabled-model",
model_picker_enabled: false,
policy: { state: "disabled" },
capabilities: { supports: { tool_calls: true } },
},
{
id: "unconfigured-model",
model_picker_enabled: false,
capabilities: { supports: { tool_calls: true } },
},
{
id: "tool-incapable-model",
model_picker_enabled: false,
policy: { state: "enabled" },
capabilities: { supports: { tool_calls: false } },
},
]);
expect(credentials.availableModelIds).toEqual([enabledModelId]);
const store = new InMemoryCredentialStore();
await store.modify("github-copilot", async () => ({ ...credentials, type: "oauth" }));
const models = createModels({ credentials: store });
models.setProvider(provider);
expect((await models.getAvailable("github-copilot")).map((model) => model.id)).toEqual([enabledModelId]);
});
it("does not fall back to policy models for non-Individual accounts", async () => {
const credentials = await refreshGitHubCopilotModelsForTest(
[
{
id: "gpt-4.1",
model_picker_enabled: false,
policy: { state: "enabled" },
capabilities: { supports: { tool_calls: true } },
},
],
"proxy.business.githubcopilot.com",
);
expect(credentials.availableModelIds).toEqual([]);
});
it("does not retry model catalog throttling during credential refresh", async () => {
let catalogRequestCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url.includes("/copilot_internal/v2/token")) {
return jsonResponse({ token: testCopilotAccessToken, expires_at: 9999999999 });
}
if (url === testCopilotModelsUrl) {
catalogRequestCount += 1;
return jsonResponse({ error: "too many requests" }, 429, { "Retry-After": "0" });
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
await expect(
githubCopilotOAuth.refresh(
{
type: "oauth",
access: "old-access-token",
refresh: "ghu_refresh_token",
expires: 0,
},
neverAbortedSignal,
),
).rejects.toThrow("429");
expect(catalogRequestCount).toBe(1);
});
it("reports device-code details through onDeviceCode", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url.endsWith("/login/device/code")) {
return jsonResponse({
device_code: "device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://github.com/login/device",
interval: 1,
expires_in: 900,
});
}
if (url.endsWith("/login/oauth/access_token")) {
return jsonResponse({ access_token: "ghu_refresh_token" });
}
if (url.includes("/copilot_internal/v2/token")) {
return jsonResponse({
token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
expires_at: 9999999999,
});
}
if (url.endsWith("/models")) {
return jsonResponse({ data: [] });
}
if (url.includes("/models/") && url.endsWith("/policy")) {
return new Response("", { status: 200 });
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const onDeviceCode = vi.fn();
const loginPromise = loginGitHubCopilotForTest({
onDeviceCode,
onPrompt: async () => "",
});
await vi.advanceTimersByTimeAsync(0);
expect(onDeviceCode).toHaveBeenCalledWith({
userCode: "ABCD-EFGH",
verificationUri: "https://github.com/login/device",
intervalSeconds: 1,
expiresInSeconds: 900,
});
await vi.advanceTimersByTimeAsync(1000);
await loginPromise;
});
it("updates only known, tool-capable, unconfigured account model policies", async () => {
vi.useFakeTimers();
const providerModels = githubCopilotProvider().getModels();
const configuredModelId = requireModelId(providerModels, 0);
const unconfiguredModelId = requireModelId(providerModels, 1);
const toolIncapableModelId = requireModelId(providerModels, 2);
let catalogRequestCount = 0;
const policyModelIds: string[] = [];
stubGitHubCopilotLoginFetch({
models: () => {
catalogRequestCount += 1;
return jsonResponse({
data: [
{
id: configuredModelId,
model_picker_enabled: true,
policy: { state: "enabled" },
capabilities: { supports: { tool_calls: true } },
},
{
id: unconfiguredModelId,
model_picker_enabled: true,
policy: { state: "unconfigured" },
capabilities: { supports: { tool_calls: true } },
},
{
id: "remote-only-model",
model_picker_enabled: true,
policy: { state: "unconfigured" },
capabilities: { supports: { tool_calls: true } },
},
{
id: toolIncapableModelId,
model_picker_enabled: true,
policy: { state: "unconfigured" },
capabilities: { supports: { tool_calls: false } },
},
],
});
},
policy: (modelId) => {
policyModelIds.push(modelId);
return new Response("", { status: 200 });
},
});
const loginPromise = loginGitHubCopilotForTest({
onDeviceCode: () => {},
onPrompt: async () => "",
});
await vi.advanceTimersByTimeAsync(1000);
await loginPromise;
expect(catalogRequestCount).toBe(1);
expect(policyModelIds).toEqual([unconfiguredModelId]);
});
it("retries a throttled policy update after Retry-After", async () => {
vi.useFakeTimers();
const modelId = requireModelId(githubCopilotProvider().getModels(), 0);
let policyRequestCount = 0;
stubGitHubCopilotLoginFetch({
models: () =>
jsonResponse({
data: [{ id: modelId, model_picker_enabled: true, policy: { state: "unconfigured" } }],
}),
policy: () => {
policyRequestCount += 1;
return policyRequestCount === 1
? jsonResponse({ error: "too many requests" }, 429, { "Retry-After": "1" })
: new Response("", { status: 200 });
},
});
const loginPromise = loginGitHubCopilotForTest({
onDeviceCode: () => {},
onPrompt: async () => "",
});
await vi.advanceTimersByTimeAsync(1000);
expect(policyRequestCount).toBe(1);
await vi.advanceTimersByTimeAsync(999);
expect(policyRequestCount).toBe(1);
await vi.advanceTimersByTimeAsync(1);
await loginPromise;
expect(policyRequestCount).toBe(2);
});
it("continues policy updates after a transport failure", async () => {
vi.useFakeTimers();
const providerModels = githubCopilotProvider().getModels();
const modelIds = [requireModelId(providerModels, 0), requireModelId(providerModels, 1)];
const policyModelIds: string[] = [];
stubGitHubCopilotLoginFetch({
models: () =>
jsonResponse({
data: modelIds.map((id) => ({ id, model_picker_enabled: true, policy: { state: "unconfigured" } })),
}),
policy: (modelId) => {
policyModelIds.push(modelId);
if (policyModelIds.length === 1) throw new Error("fetch failed");
return new Response("", { status: 200 });
},
});
const loginPromise = loginGitHubCopilotForTest({
onDeviceCode: () => {},
onPrompt: async () => "",
});
await vi.advanceTimersByTimeAsync(1000);
await loginPromise;
expect(policyModelIds).toEqual(modelIds);
});
it("stops policy updates and persists authentication when the retry delay exceeds the login budget", async () => {
vi.useFakeTimers();
const providerModels = githubCopilotProvider().getModels();
const firstModelId = requireModelId(providerModels, 0);
const secondModelId = requireModelId(providerModels, 1);
const policyModelIds: string[] = [];
stubGitHubCopilotLoginFetch({
models: () =>
jsonResponse({
data: [
{ id: firstModelId, model_picker_enabled: true, policy: { state: "unconfigured" } },
{ id: secondModelId, model_picker_enabled: true, policy: { state: "unconfigured" } },
],
}),
policy: (modelId) => {
policyModelIds.push(modelId);
return jsonResponse({ error: "too many requests" }, 429, { "Retry-After": "5" });
},
});
const store = new InMemoryCredentialStore();
const models = createModels({ credentials: store });
models.setProvider(githubCopilotProvider());
const loginPromise = models.login("github-copilot", "oauth", {
signal: neverAbortedSignal,
prompt: async () => "",
notify: () => {},
});
await vi.advanceTimersByTimeAsync(1000);
const credential = await loginPromise;
expect(credential).toMatchObject({ type: "oauth", access: testCopilotAccessToken });
expect(policyModelIds).toEqual([firstModelId]);
expect(await store.read("github-copilot")).toEqual(credential);
});
it("rejects a non-http(s) verification_uri before it reaches onDeviceCode", async () => {
// A malicious enterprise OAuth server could return a verification_uri that
// the browser launcher would otherwise hand to the OS. Ensure such values
// are rejected at the deserialization boundary.
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url.endsWith("/login/device/code")) {
return jsonResponse({
device_code: "device-code",
user_code: "ABCD-EFGH",
verification_uri: "$(id>/tmp/pwned)",
interval: 1,
expires_in: 900,
});
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const onDeviceCode = vi.fn();
await expect(
loginGitHubCopilotForTest({
onDeviceCode,
onPrompt: async () => "",
}),
).rejects.toThrow(/Untrusted verification_uri/);
expect(onDeviceCode).not.toHaveBeenCalled();
});
it("normalizes verification_uri before it reaches onDeviceCode", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const rawVerificationUri = "https://github.com/login/\x1b]8;;evil";
const normalizedVerificationUri = new URL(rawVerificationUri).href;
expect(normalizedVerificationUri).not.toBe(rawVerificationUri);
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url.endsWith("/login/device/code")) {
return jsonResponse({
device_code: "device-code",
user_code: "ABCD-EFGH",
verification_uri: rawVerificationUri,
interval: 1,
expires_in: 900,
});
}
if (url.endsWith("/login/oauth/access_token")) {
return jsonResponse({ access_token: "ghu_refresh_token" });
}
if (url.includes("/copilot_internal/v2/token")) {
return jsonResponse({
token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
expires_at: 9999999999,
});
}
if (url.endsWith("/models")) {
return jsonResponse({ data: [] });
}
if (url.includes("/models/") && url.endsWith("/policy")) {
return new Response("", { status: 200 });
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const onDeviceCode = vi.fn();
const loginPromise = loginGitHubCopilotForTest({
onDeviceCode,
onPrompt: async () => "",
});
await vi.advanceTimersByTimeAsync(0);
expect(onDeviceCode).toHaveBeenCalledWith({
userCode: "ABCD-EFGH",
verificationUri: normalizedVerificationUri,
intervalSeconds: 1,
expiresInSeconds: 900,
});
expect(onDeviceCode).not.toHaveBeenCalledWith(expect.objectContaining({ verificationUri: rawVerificationUri }));
await vi.advanceTimersByTimeAsync(1000);
await loginPromise;
});
it("waits before polling and increases the interval after slow_down", async () => {
vi.useFakeTimers();
const startTime = new Date("2026-03-09T00:00:00Z");
vi.setSystemTime(startTime);
const accessTokenPollTimes: number[] = [];
const accessTokenResponses = [
jsonResponse({ error: "authorization_pending", error_description: "pending" }),
jsonResponse({ error: "slow_down", error_description: "slow down", interval: 7 }),
jsonResponse({ access_token: "ghu_refresh_token" }),
];
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
const url = getUrl(input);
if (url.endsWith("/login/device/code")) {
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
});
expect(String(init?.body)).toContain("client_id=");
expect(String(init?.body)).toContain("scope=read%3Auser");
return jsonResponse({
device_code: "device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://github.com/login/device",
interval: 5,
expires_in: 900,
});
}
if (url.endsWith("/login/oauth/access_token")) {
accessTokenPollTimes.push(Date.now());
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
});
expect(String(init?.body)).toContain("client_id=");
expect(String(init?.body)).toContain("device_code=device-code");
expect(String(init?.body)).toContain("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code");
const response = accessTokenResponses.shift();
if (!response) {
throw new Error("Unexpected extra access token poll");
}
return response;
}
if (url.includes("/copilot_internal/v2/token")) {
return jsonResponse({
token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
expires_at: 9999999999,
});
}
if (url.endsWith("/models")) {
return jsonResponse({ data: [] });
}
if (url.includes("/models/") && url.endsWith("/policy")) {
return new Response("", { status: 200 });
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const loginPromise = loginGitHubCopilotForTest({
onDeviceCode: () => {},
onPrompt: async () => "",
onProgress: () => {},
});
await vi.advanceTimersByTimeAsync(0);
expect(accessTokenPollTimes).toHaveLength(0);
await vi.advanceTimersByTimeAsync(4999);
expect(accessTokenPollTimes).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(accessTokenPollTimes).toHaveLength(1);
await vi.advanceTimersByTimeAsync(4999);
expect(accessTokenPollTimes).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
expect(accessTokenPollTimes).toHaveLength(2);
// slow_down carried a server-provided interval of 7 seconds.
await vi.advanceTimersByTimeAsync(6999);
expect(accessTokenPollTimes).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1);
await loginPromise;
expect(accessTokenPollTimes).toEqual([
startTime.getTime() + 5000,
startTime.getTime() + 10000,
startTime.getTime() + 17000,
]);
});
it("times out after repeated slow_down responses", async () => {
vi.useFakeTimers();
const startTime = new Date("2026-03-09T00:00:00Z");
vi.setSystemTime(startTime);
const accessTokenPollTimes: number[] = [];
const accessTokenResponses = [
jsonResponse({ error: "slow_down", error_description: "slow down" }),
jsonResponse({ error: "slow_down", error_description: "still too fast" }),
jsonResponse({ error: "authorization_pending", error_description: "pending" }),
];
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url.endsWith("/login/device/code")) {
return jsonResponse({
device_code: "device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://github.com/login/device",
interval: 5,
expires_in: 25,
});
}
if (url.endsWith("/login/oauth/access_token")) {
accessTokenPollTimes.push(Date.now());
const response = accessTokenResponses.shift();
if (!response) {
throw new Error("Unexpected extra access token poll");
}
return response;
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const loginPromise = loginGitHubCopilotForTest({
onDeviceCode: () => {},
onPrompt: async () => "",
});
const rejection = expect(loginPromise).rejects.toThrow(
/Device flow timed out after one or more slow_down responses/,
);
await vi.advanceTimersByTimeAsync(4999);
expect(accessTokenPollTimes).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]);
await vi.advanceTimersByTimeAsync(9999);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]);
await vi.advanceTimersByTimeAsync(1);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]);
await vi.advanceTimersByTimeAsync(9999);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]);
await vi.advanceTimersByTimeAsync(1);
await rejection;
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]);
});
});

View File

@@ -0,0 +1,194 @@
import { arch, platform, release } from "node:os";
import { describe, expect, it, vi } from "vitest";
const googleGenAiMock = vi.hoisted(() => ({
constructorCalls: [] as Array<Record<string, unknown>>,
finishReason: "MALFORMED_FUNCTION_CALL",
includeFunctionCall: false,
}));
vi.mock("@google/genai", () => {
class GoogleGenAI {
constructor(config: Record<string, unknown>) {
googleGenAiMock.constructorCalls.push(config);
}
models = {
generateContentStream: async function* () {
yield {
responseId: "google-response-id",
candidates: [
{
finishReason: googleGenAiMock.finishReason,
...(googleGenAiMock.includeFunctionCall && {
content: {
parts: [
{
functionCall: {
id: "call-1",
name: "echo",
args: { value: "truncated" },
},
},
],
},
}),
},
],
usageMetadata: {
promptTokenCount: 1,
candidatesTokenCount: 0,
totalTokenCount: 1,
},
};
},
};
}
return {
FinishReason: {
STOP: "STOP",
MAX_TOKENS: "MAX_TOKENS",
BLOCKLIST: "BLOCKLIST",
PROHIBITED_CONTENT: "PROHIBITED_CONTENT",
SPII: "SPII",
SAFETY: "SAFETY",
IMAGE_SAFETY: "IMAGE_SAFETY",
IMAGE_PROHIBITED_CONTENT: "IMAGE_PROHIBITED_CONTENT",
IMAGE_RECITATION: "IMAGE_RECITATION",
IMAGE_OTHER: "IMAGE_OTHER",
RECITATION: "RECITATION",
FINISH_REASON_UNSPECIFIED: "FINISH_REASON_UNSPECIFIED",
OTHER: "OTHER",
LANGUAGE: "LANGUAGE",
MALFORMED_FUNCTION_CALL: "MALFORMED_FUNCTION_CALL",
UNEXPECTED_TOOL_CALL: "UNEXPECTED_TOOL_CALL",
NO_IMAGE: "NO_IMAGE",
},
FunctionCallingConfigMode: {
AUTO: "AUTO",
NONE: "NONE",
ANY: "ANY",
VALIDATED: "VALIDATED",
},
GoogleGenAI,
ResourceScope: {
COLLECTION: "COLLECTION",
},
ThinkingLevel: {
THINKING_LEVEL_UNSPECIFIED: "THINKING_LEVEL_UNSPECIFIED",
MINIMAL: "MINIMAL",
LOW: "LOW",
MEDIUM: "MEDIUM",
HIGH: "HIGH",
},
};
});
import { stream as streamGoogleGenerativeAi } from "../src/api/google-generative-ai.ts";
import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts";
import { getModel } from "../src/compat.ts";
import type { Context } from "../src/types.ts";
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
async function captureGoogleHeaders(headers?: Record<string, string>): Promise<Record<string, string>> {
googleGenAiMock.constructorCalls.length = 0;
googleGenAiMock.finishReason = "STOP";
googleGenAiMock.includeFunctionCall = false;
await streamGoogleGenerativeAi(getModel("google", "gemini-2.5-flash"), context, {
apiKey: "test-api-key",
headers,
}).result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
const httpOptions = googleGenAiMock.constructorCalls[0].httpOptions as { headers?: Record<string, string> };
return httpOptions.headers ?? {};
}
describe("Google raw stop reasons", () => {
it("preserves raw Gemini finish reasons for Google Generative AI errors", async () => {
googleGenAiMock.finishReason = "MALFORMED_FUNCTION_CALL";
googleGenAiMock.includeFunctionCall = false;
const stream = streamGoogleGenerativeAi(getModel("google", "gemini-2.5-flash"), context, {
apiKey: "test-api-key",
});
const message = await stream.result();
expect(message.stopReason).toBe("error");
expect(message.rawStopReason).toBe("MALFORMED_FUNCTION_CALL");
expect(message.errorMessage).toBe("Provider stopped with: MALFORMED_FUNCTION_CALL");
});
it("preserves raw Gemini finish reasons for Google Vertex errors", async () => {
googleGenAiMock.finishReason = "SAFETY";
googleGenAiMock.includeFunctionCall = false;
const stream = streamGoogleVertex(getModel("google-vertex", "gemini-3-flash-preview"), context, {
project: "test-project",
location: "us-central1",
});
const message = await stream.result();
expect(message.stopReason).toBe("error");
expect(message.rawStopReason).toBe("SAFETY");
expect(message.errorMessage).toBe("Provider stopped with: SAFETY");
});
const adapters = [
{
name: "Google Generative AI",
createStream: () =>
streamGoogleGenerativeAi(getModel("google", "gemini-2.5-flash"), context, {
apiKey: "test-api-key",
}),
},
{
name: "Google Vertex",
createStream: () =>
streamGoogleVertex(getModel("google-vertex", "gemini-3-flash-preview"), context, {
project: "test-project",
location: "us-central1",
}),
},
];
it.each(adapters)("preserves MAX_TOKENS with a tool call as length for $name", async ({ createStream }) => {
googleGenAiMock.finishReason = "MAX_TOKENS";
googleGenAiMock.includeFunctionCall = true;
const message = await createStream().result();
expect(message.stopReason).toBe("length");
expect(message.rawStopReason).toBe("MAX_TOKENS");
expect(message.content.some((block) => block.type === "toolCall")).toBe(true);
});
it.each(adapters)("maps STOP with a tool call to toolUse for $name", async ({ createStream }) => {
googleGenAiMock.finishReason = "STOP";
googleGenAiMock.includeFunctionCall = true;
const message = await createStream().result();
expect(message.stopReason).toBe("toolUse");
expect(message.rawStopReason).toBe("STOP");
expect(message.content.some((block) => block.type === "toolCall")).toBe(true);
});
});
describe("Google Generative AI user agent", () => {
it("uses pi's User-Agent by default", async () => {
expect((await captureGoogleHeaders())["User-Agent"]).toBe(PI_USER_AGENT);
});
it("lets explicit headers override the default User-Agent", async () => {
expect((await captureGoogleHeaders({ "User-Agent": "custom-agent" }))["User-Agent"]).toBe("custom-agent");
});
});

View File

@@ -0,0 +1,203 @@
import { describe, expect, it } from "vitest";
import {
convertTools,
resolveGoogleFunctionCallingMode,
supportsGoogleStrictToolSampling,
} from "../src/api/google-shared.ts";
import type { Tool } from "../src/types.ts";
function makeTool(parameters: Record<string, unknown>): Tool {
return {
name: "test_tool",
description: "A test tool",
parameters: parameters as Tool["parameters"],
};
}
describe("google-shared convertTools", () => {
it("strips JSON Schema meta keys from parameters when useParameters=true", () => {
const tools = [
makeTool({
$schema: "http://json-schema.org/draft-07/schema#",
$id: "urn:bash-tool",
$comment: "A bash tool for demonstration",
$defs: {
commandDef: { type: "string" },
},
definitions: {
legacyDef: { type: "number" },
},
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
}),
];
const result = convertTools(tools, true);
const decl = result?.[0]?.functionDeclarations?.[0];
expect(decl).toBeDefined();
expect(decl?.parameters).toEqual({
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
});
expect(decl?.parameters).not.toHaveProperty("$schema");
expect(decl?.parameters).not.toHaveProperty("$id");
expect(decl?.parameters).not.toHaveProperty("$comment");
expect(decl?.parameters).not.toHaveProperty("$defs");
expect(decl?.parameters).not.toHaveProperty("definitions");
});
it("recursively strips nested JSON Schema meta keys", () => {
const tools = [
makeTool({
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
deep: {
$schema: "http://json-schema.org/draft-07/schema#",
$id: "urn:nested",
type: "string",
},
},
}),
];
const result = convertTools(tools, true);
const decl = result?.[0]?.functionDeclarations?.[0];
expect(decl).toBeDefined();
expect(decl?.parameters).toEqual({
type: "object",
properties: {
deep: {
type: "string",
},
},
});
});
it("preserves $ref while stripping meta keys", () => {
const tools = [
makeTool({
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
refProp: {
$ref: "#/$defs/someDef",
type: "string",
},
},
}),
];
const result = convertTools(tools, true);
const decl = result?.[0]?.functionDeclarations?.[0];
expect(decl).toBeDefined();
expect(decl?.parameters).toEqual({
type: "object",
properties: {
refProp: {
$ref: "#/$defs/someDef",
type: "string",
},
},
});
});
it("does not mutate the original Tool.parameters object", () => {
const originalParameters = {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
};
const tools = [makeTool(originalParameters)];
convertTools(tools, true);
expect(originalParameters).toEqual({
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
});
});
it("preserves $schema in parametersJsonSchema when useParameters=false", () => {
const tools = [
makeTool({
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
}),
];
const result = convertTools(tools, false);
const decl = result?.[0]?.functionDeclarations?.[0];
expect(decl).toBeDefined();
expect(decl?.parametersJsonSchema).toEqual({
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
});
});
it("handles tools without $schema gracefully", () => {
const tools = [
makeTool({
type: "object",
properties: {
path: { type: "string" },
},
required: ["path"],
}),
];
const result = convertTools(tools, true);
const decl = result?.[0]?.functionDeclarations?.[0];
expect(decl).toBeDefined();
expect(decl?.parameters).toEqual({
type: "object",
properties: {
path: { type: "string" },
},
required: ["path"],
});
});
it("uses validated function calling for strict tools on Gemini 3", () => {
const tool = makeTool({ type: "object", properties: {} });
tool.constrainedSampling = { type: "json_schema", strict: "require" };
expect(supportsGoogleStrictToolSampling("gemini-3.1-pro-preview")).toBe(true);
expect(supportsGoogleStrictToolSampling("gemini-2.5-pro")).toBe(false);
expect(resolveGoogleFunctionCallingMode([tool], undefined, true)).toBe("VALIDATED");
expect(() => resolveGoogleFunctionCallingMode([tool], undefined, false)).toThrow(
'Tool "test_tool" requires JSON-schema constrained sampling',
);
});
it("returns undefined for empty tool list", () => {
expect(convertTools([])).toBeUndefined();
expect(convertTools([], true)).toBeUndefined();
});
});

View File

@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import { convertMessages, requiresToolCallId } from "../src/api/google-shared.ts";
import type { Context, Model } from "../src/types.ts";
function makeGemini3Model<TApi extends "google-generative-ai" | "google-vertex">(
api: TApi,
provider: Model<TApi>["provider"],
id = "gemini-3-pro-preview",
): Model<TApi> {
return {
id,
name: "Gemini 3 Pro Preview",
api,
provider,
baseUrl: "https://example.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
};
}
function makeContext(model: { api: string; provider: string; id: string }, thoughtSignature?: string): Context {
const now = Date.now();
return {
messages: [
{ role: "user", content: "Hi", timestamp: now },
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_1",
name: "bash",
arguments: { command: "echo hi" },
...(thoughtSignature && { thoughtSignature }),
},
{
type: "toolCall",
id: "call_2",
name: "bash",
arguments: { command: "ls -la" },
},
],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: now,
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "bash",
content: [{ type: "text", text: "hi" }],
isError: false,
timestamp: now,
},
{
role: "toolResult",
toolCallId: "call_2",
toolName: "bash",
content: [{ type: "text", text: "files" }],
isError: false,
timestamp: now,
},
],
};
}
describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () => {
it.each([
makeGemini3Model("google-generative-ai", "google"),
makeGemini3Model("google-generative-ai", "google", "gemini-3.6-flash"),
makeGemini3Model("google-vertex", "google-vertex"),
])("preserves tool call IDs for $id via $api history", (model) => {
const context = makeContext(model);
const contents = convertMessages(model, context);
const functionCallIds = contents
.flatMap((content) => content.parts ?? [])
.flatMap((part) => (part.functionCall?.id ? [part.functionCall.id] : []));
const functionResponseIds = contents
.flatMap((content) => content.parts ?? [])
.flatMap((part) => (part.functionResponse?.id ? [part.functionResponse.id] : []));
expect(functionCallIds).toEqual(["call_1", "call_2"]);
expect(functionResponseIds).toEqual(["call_1", "call_2"]);
});
it("does not add skip_thought_signature_validator for unsigned Google Gen AI tool calls", () => {
const model = makeGemini3Model("google-generative-ai", "google");
const contents = convertMessages(model, makeContext({ ...model, id: "other-model" }));
const modelTurn = contents.find((c) => c.role === "model");
expect(modelTurn).toBeTruthy();
const functionCallParts = modelTurn?.parts?.filter((p) => p.functionCall !== undefined) ?? [];
expect(functionCallParts).toHaveLength(2);
expect(functionCallParts[0]?.thoughtSignature).toBeUndefined();
expect(functionCallParts[1]?.thoughtSignature).toBeUndefined();
expect(JSON.stringify(modelTurn)).not.toContain("skip_thought_signature_validator");
const textParts = modelTurn?.parts?.filter((p) => p.text !== undefined) ?? [];
const historicalText = textParts.filter((p) => p.text?.includes("Historical context"));
expect(historicalText).toHaveLength(0);
});
it("does not add skip_thought_signature_validator for unsigned Vertex tool calls", () => {
const model = makeGemini3Model("google-vertex", "google-vertex");
const contents = convertMessages(model, makeContext(model));
const modelTurn = contents.find((c) => c.role === "model");
const functionCallParts = modelTurn?.parts?.filter((p) => p.functionCall !== undefined) ?? [];
expect(functionCallParts).toHaveLength(2);
expect(functionCallParts[0]?.thoughtSignature).toBeUndefined();
expect(functionCallParts[1]?.thoughtSignature).toBeUndefined();
expect(JSON.stringify(modelTurn)).not.toContain("skip_thought_signature_validator");
});
it("preserves valid thoughtSignature when present for the same provider and model", () => {
const model = makeGemini3Model("google-generative-ai", "google");
const validSig = "AAAAAAAAAAAAAAAAAAAAAA==";
const contents = convertMessages(model, makeContext(model, validSig));
const modelTurn = contents.find((c) => c.role === "model");
const functionCallParts = modelTurn?.parts?.filter((p) => p.functionCall !== undefined) ?? [];
expect(functionCallParts).toHaveLength(2);
expect(functionCallParts[0]?.thoughtSignature).toBe(validSig);
expect(functionCallParts[1]?.thoughtSignature).toBeUndefined();
});
it("does not add a thoughtSignature for non-Gemini-3 models", () => {
const model = makeGemini3Model("google-generative-ai", "google", "gemini-2.5-flash");
const contents = convertMessages(model, makeContext({ ...model, id: "other-model" }));
const modelTurn = contents.find((c) => c.role === "model");
const functionCallParts = modelTurn?.parts?.filter((part) => part.functionCall !== undefined) ?? [];
const functionResponseParts = contents
.flatMap((content) => content.parts ?? [])
.filter((part) => part.functionResponse !== undefined);
expect(functionCallParts).toHaveLength(2);
expect(functionCallParts.every((part) => part.functionCall?.id === undefined)).toBe(true);
expect(functionCallParts.every((part) => part.thoughtSignature === undefined)).toBe(true);
expect(functionResponseParts).toHaveLength(2);
expect(functionResponseParts.every((part) => part.functionResponse?.id === undefined)).toBe(true);
});
});
describe("requiresToolCallId", () => {
it.each([
[false, "gemini-2.5-flash"],
[true, "gemini-3.6-flash"],
[true, "claude-sonnet-4-5"],
[true, "gpt-oss-120b"],
] as const)("returns %s for %s", (expected, modelId) => {
expect(requiresToolCallId(modelId)).toBe(expected);
});
});

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { convertMessages } from "../src/api/google-shared.ts";
import type { Context, Model } from "../src/types.ts";
function makeModel<TApi extends "google-generative-ai">(
api: TApi,
provider: Model<TApi>["provider"],
id: string,
): Model<TApi> {
return {
id,
name: id,
api,
provider,
baseUrl: "https://example.com",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
};
}
function makeContext(model: { api: string; provider: string; id: string }): Context {
const now = Date.now();
return {
messages: [
{ role: "user", content: "read the files", timestamp: now },
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_a", name: "read", arguments: { path: "a.txt" } },
{ type: "toolCall", id: "call_img", name: "read", arguments: { path: "image.png" } },
{ type: "toolCall", id: "call_b", name: "read", arguments: { path: "b.txt" } },
],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: now,
},
{
role: "toolResult",
toolCallId: "call_a",
toolName: "read",
content: [{ type: "text", text: "alpha text" }],
isError: false,
timestamp: now,
},
{
role: "toolResult",
toolCallId: "call_img",
toolName: "read",
content: [{ type: "image", data: "abc", mimeType: "image/png" }],
isError: false,
timestamp: now,
},
{
role: "toolResult",
toolCallId: "call_b",
toolName: "read",
content: [{ type: "text", text: "beta text" }],
isError: false,
timestamp: now,
},
],
};
}
describe("google-shared image tool result routing", () => {
it("keeps separate synthetic image turn for Gemini 2.x Google API models", () => {
const model = makeModel("google-generative-ai", "google", "gemini-2.5-flash");
const contents = convertMessages(model, makeContext(model));
expect(contents).toHaveLength(5);
expect(contents[2].parts?.every((part) => part.functionResponse)).toBe(true);
expect(contents[3].parts?.[0]?.text).toBe("Tool result image:");
expect(contents[3].parts?.[1]?.inlineData).toBeTruthy();
expect(contents[4].parts?.[0]?.functionResponse).toBeTruthy();
});
it("nests image tool results for Gemini 3 Google API models", () => {
const model = makeModel("google-generative-ai", "google", "gemini-3-pro-preview");
const contents = convertMessages(model, makeContext(model));
expect(contents).toHaveLength(3);
const toolResultTurn = contents[2];
expect(toolResultTurn.parts).toHaveLength(3);
const imageResponse = toolResultTurn.parts?.[1]?.functionResponse;
expect(imageResponse).toBeTruthy();
expect(imageResponse?.parts).toHaveLength(1);
expect(imageResponse?.parts?.[0]?.inlineData).toBeTruthy();
});
});

View File

@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { retryGoogleRequest } from "../src/api/google-shared.ts";
/** Shaped like @google/genai's ApiError: has `status`, but no `headers`. */
function googleApiError(status: number): Error {
return Object.assign(new Error(`got status: ${status}`), { status });
}
describe("google request retries", () => {
afterEach(() => {
vi.useRealTimers();
});
it("retries a headers-less SDK error with a retryable status", async () => {
vi.useFakeTimers();
const request = vi.fn<() => Promise<string>>().mockRejectedValueOnce(googleApiError(429)).mockResolvedValue("ok");
const result = retryGoogleRequest(request, { maxRetries: 1 });
await vi.advanceTimersByTimeAsync(500);
await expect(result).resolves.toBe("ok");
expect(request).toHaveBeenCalledTimes(2);
});
it("does not retry when maxRetries is unset", async () => {
const error = googleApiError(429);
const request = vi.fn<() => Promise<string>>().mockRejectedValue(error);
await expect(retryGoogleRequest(request)).rejects.toBe(error);
expect(request).toHaveBeenCalledTimes(1);
});
it("does not retry a non-retryable status", async () => {
const error = googleApiError(400);
const request = vi.fn<() => Promise<string>>().mockRejectedValue(error);
await expect(retryGoogleRequest(request, { maxRetries: 2 })).rejects.toBe(error);
expect(request).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { convertMessages } from "../src/api/google-shared.ts";
import type { AssistantMessage, Context, Model } from "../src/types.ts";
// Gemini can attach `thoughtSignature` to a response part whose visible text is empty (e.g. a
// thought burst preceding a function call) and requires the signature echoed back on the next
// request. Dropping such blocks while rebuilding history silently breaks the reasoning chain:
// the model then intermittently ends a mid-task turn with a thought-only STOP and no tool call.
// These tests pin the rule: an empty text/thinking block is skipped only when it is UNSIGNED.
function makeModel(id = "gemini-3-pro-preview"): Model<"google-generative-ai"> {
return {
id,
name: id,
api: "google-generative-ai",
provider: "google",
baseUrl: "https://example.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
};
}
const VALID_SIG = "AAAAAAAAAAAAAAAAAAAAAA==";
function makeContext(
model: { api: string; provider: string; id: string },
content: AssistantMessage["content"],
): Context {
const now = Date.now();
return {
messages: [
{ role: "user", content: "Hi", timestamp: now },
{
role: "assistant",
content,
api: model.api as AssistantMessage["api"],
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: now,
},
],
};
}
describe("google-shared convertMessages — signed empty blocks", () => {
it("keeps a signed empty thinking block so its signature is echoed back", () => {
const model = makeModel();
const contents = convertMessages(
model,
makeContext(model, [
{ type: "thinking", thinking: "", thinkingSignature: VALID_SIG },
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
]),
);
const modelTurn = contents.find((c) => c.role === "model");
const signed = modelTurn?.parts?.filter((p) => p.thoughtSignature === VALID_SIG) ?? [];
expect(signed).toHaveLength(1);
expect(signed[0]?.thought).toBe(true);
});
it("keeps a signed empty text block the same way", () => {
const model = makeModel();
const contents = convertMessages(
model,
makeContext(model, [
{ type: "text", text: "", textSignature: VALID_SIG },
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
]),
);
const modelTurn = contents.find((c) => c.role === "model");
const signed = modelTurn?.parts?.filter((p) => p.thoughtSignature === VALID_SIG) ?? [];
expect(signed).toHaveLength(1);
});
it("still drops unsigned empty blocks", () => {
const model = makeModel();
const contents = convertMessages(
model,
makeContext(model, [
{ type: "thinking", thinking: "" },
{ type: "text", text: " " },
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
]),
);
const modelTurn = contents.find((c) => c.role === "model");
expect(modelTurn?.parts).toHaveLength(1);
expect(modelTurn?.parts?.[0]?.functionCall).toBeTruthy();
});
it("still drops signed empty blocks from a different provider/model (signature unusable)", () => {
const model = makeModel();
const contents = convertMessages(
model,
makeContext({ ...model, id: "other-model" }, [
{ type: "thinking", thinking: "", thinkingSignature: VALID_SIG },
{ type: "text", text: "", textSignature: VALID_SIG },
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
]),
);
const modelTurn = contents.find((c) => c.role === "model");
expect(modelTurn?.parts).toHaveLength(1);
expect(modelTurn?.parts?.[0]?.functionCall).toBeTruthy();
expect(JSON.stringify(modelTurn)).not.toContain(VALID_SIG);
});
});

View File

@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import type { Api, Context, Model, SimpleStreamOptions } from "../src/types.ts";
type SimpleOptionsWithExtras = SimpleStreamOptions & Record<string, unknown>;
interface RunResult {
thinkingEventCount: number;
thinkingCharCount: number;
text: string;
outputTokens: number;
contentTypes: string[];
}
interface DisableExpectations {
requestOptions?: SimpleOptionsWithExtras;
minPongs?: number;
maxOutputTokens?: number;
}
function makeContext(): Context {
return {
systemPrompt: "You are a precise assistant. Follow the requested output format exactly.",
messages: [
{
role: "user",
content:
"Before replying, carefully solve 36863 * 5279 internally. Then reply with the word pong repeated exactly 40 times, separated by single spaces. Do not add any other text.",
timestamp: Date.now(),
},
],
};
}
function countPongs(text: string): number {
return text.match(/\bpong\b/gi)?.length ?? 0;
}
async function runWithoutReasoning<TApi extends Api>(
model: Model<TApi>,
options: SimpleOptionsWithExtras = {},
): Promise<RunResult> {
const s = streamSimple(model, makeContext(), {
maxTokens: 160,
temperature: 0,
...options,
});
let thinkingEventCount = 0;
let thinkingCharCount = 0;
for await (const event of s) {
if (event.type === "thinking_start" || event.type === "thinking_end") {
thinkingEventCount += 1;
}
if (event.type === "thinking_delta") {
thinkingEventCount += 1;
thinkingCharCount += event.delta.length;
}
}
const response = await s.result();
expect(response.stopReason, response.errorMessage).toBe("stop");
const text = response.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("")
.trim();
return {
thinkingEventCount,
thinkingCharCount,
text,
outputTokens: response.usage.output,
contentTypes: response.content.map((block) => block.type),
};
}
async function expectThinkingDisabledE2E<TApi extends Api>(model: Model<TApi>, expectations: DisableExpectations = {}) {
const result = await runWithoutReasoning(model, expectations.requestOptions);
expect(result.thinkingEventCount).toBe(0);
expect(result.thinkingCharCount).toBe(0);
expect(result.contentTypes).not.toContain("thinking");
expect(countPongs(result.text)).toBeGreaterThanOrEqual(expectations.minPongs ?? 35);
if (expectations.maxOutputTokens !== undefined) {
expect(result.outputTokens).toBeLessThan(expectations.maxOutputTokens);
}
}
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic thinking disable E2E", () => {
it("disables thinking for budget-based reasoning models", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("anthropic", "claude-sonnet-4-5"), {
requestOptions: { maxTokens: 320, temperature: 0 },
});
});
it("disables thinking for adaptive reasoning models", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("anthropic", "claude-sonnet-4-6"), {
requestOptions: { maxTokens: 320, temperature: 0 },
});
});
});
describe.skipIf(!process.env.GEMINI_API_KEY)("Google thinking disable E2E", () => {
it("disables thinking for Gemini 2.5", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("google", "gemini-2.5-flash"));
});
it("disables thinking for Gemini 3.x", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("google", "gemini-3-flash-preview"));
});
it("does not error when thinking is off for Gemini 3.1 Pro", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("google", "gemini-3.1-pro-preview"), {
requestOptions: { maxTokens: 512 },
minPongs: 20,
});
});
});
describe("Google Vertex thinking disable E2E", () => {
const vertexProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT;
const vertexLocation = process.env.GOOGLE_CLOUD_LOCATION;
const vertexApiKey = process.env.GOOGLE_CLOUD_API_KEY;
const vertexOptions = vertexApiKey
? ({ apiKey: vertexApiKey } satisfies SimpleOptionsWithExtras)
: vertexProject && vertexLocation
? ({ project: vertexProject, location: vertexLocation } satisfies SimpleOptionsWithExtras)
: undefined;
it.skipIf(!vertexOptions)("disables thinking for Gemini 2.5", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("google-vertex", "gemini-2.5-flash"), {
requestOptions: vertexOptions,
});
});
it.skipIf(!vertexOptions)("disables thinking for Gemini 3.x", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("google-vertex", "gemini-3-flash-preview"), {
requestOptions: vertexOptions,
});
});
});
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI thinking disable E2E", () => {
it("disables thinking for Responses reasoning models", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("openai", "gpt-5.4-mini"), {
requestOptions: { temperature: undefined },
});
});
});
describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter thinking disable E2E", () => {
it("disables thinking for Qwen 3.5 reasoning models", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("openrouter", "qwen/qwen3.5-plus-02-15"), {
maxOutputTokens: 100,
});
});
});

View File

@@ -0,0 +1,170 @@
import type { GenerateContentParameters } from "@google/genai";
import { describe, expect, it } from "vitest";
import { streamSimple as streamSimpleGoogle } from "../src/api/google-generative-ai.ts";
import { resolveGoogleThinkingLevel } from "../src/api/google-shared.ts";
import { streamSimple as streamSimpleVertex } from "../src/api/google-vertex.ts";
import type {
Context,
Model,
ModelThinkingLevel,
ThinkingBudgets,
ThinkingLevel,
ThinkingLevelMap,
} from "../src/types.ts";
const context: Context = {
messages: [{ role: "user", content: "Hello", timestamp: 0 }],
};
function googleModel(id: string, thinkingLevelMap: ThinkingLevelMap): Model<"google-generative-ai"> {
return {
id,
name: id,
api: "google-generative-ai",
provider: "test-google",
baseUrl: "https://example.invalid/v1beta",
reasoning: true,
thinkingLevelMap,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
}
function vertexModel(id: string, thinkingLevelMap: ThinkingLevelMap): Model<"google-vertex"> {
return {
id,
name: id,
api: "google-vertex",
provider: "test-vertex",
baseUrl: "https://example.invalid/v1",
reasoning: true,
thinkingLevelMap,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
}
async function captureGooglePayload(
model: Model<"google-generative-ai">,
reasoning: ThinkingLevel,
thinkingBudgets?: ThinkingBudgets,
): Promise<GenerateContentParameters> {
let payload: GenerateContentParameters | undefined;
const result = await streamSimpleGoogle(model, context, {
apiKey: "test",
reasoning,
thinkingBudgets,
onPayload: (request) => {
payload = request as GenerateContentParameters;
throw new Error("payload captured");
},
}).result();
expect(result.errorMessage).toContain("payload captured");
if (!payload) throw new Error("Google payload was not captured");
return payload;
}
async function captureVertexPayload(
model: Model<"google-vertex">,
reasoning: ThinkingLevel,
thinkingBudgets?: ThinkingBudgets,
): Promise<GenerateContentParameters> {
let payload: GenerateContentParameters | undefined;
const result = await streamSimpleVertex(model, context, {
apiKey: "test",
reasoning,
thinkingBudgets,
onPayload: (request) => {
payload = request as GenerateContentParameters;
throw new Error("payload captured");
},
}).result();
expect(result.errorMessage).toContain("payload captured");
if (!payload) throw new Error("Vertex payload was not captured");
return payload;
}
describe("Google thinking level maps", () => {
it("exhaustively resolves supported logical levels and mapping values", () => {
const defaultExpectations = {
off: "high",
minimal: "minimal",
low: "low",
medium: "medium",
high: "high",
} as const satisfies Partial<Record<ModelThinkingLevel, ThinkingLevel>>;
for (const [level, expected] of Object.entries(defaultExpectations)) {
expect(resolveGoogleThinkingLevel(googleModel("gemini-3.7-flash", {}), level as ModelThinkingLevel)).toBe(
expected,
);
}
const mappedExpectations = {
minimal: "minimal",
low: "low",
medium: "medium",
high: "high",
MINIMAL: "minimal",
LOW: "low",
MEDIUM: "medium",
HIGH: "high",
} as const;
for (const [mapped, expected] of Object.entries(mappedExpectations)) {
const model = googleModel("gemini-3.7-flash", { high: mapped, xhigh: mapped, max: mapped });
expect(resolveGoogleThinkingLevel(model, "high")).toBe(expected);
expect(resolveGoogleThinkingLevel(model, "xhigh")).toBe(expected);
expect(resolveGoogleThinkingLevel(model, "max")).toBe(expected);
}
const invalidModel = googleModel("gemini-3.7-flash", { xhigh: "extreme" });
expect(() => resolveGoogleThinkingLevel(invalidModel, "xhigh")).toThrow(
"Unsupported Google thinking level mapping for test-google/gemini-3.7-flash: xhigh -> extreme",
);
expect(() => resolveGoogleThinkingLevel(googleModel("gemini-3.7-flash", {}), "max")).toThrow(
"Unsupported Google thinking level mapping for test-google/gemini-3.7-flash: max -> undefined",
);
});
it.each(["xhigh", "max"] as const)("maps Google Generative AI %s to a supported level", async (reasoning) => {
const payload = await captureGooglePayload(
googleModel("gemini-3.7-flash", { xhigh: "high", max: "high" }),
reasoning,
);
expect(payload).toMatchObject({ config: { thinkingConfig: { includeThoughts: true, thinkingLevel: "HIGH" } } });
});
it("honors uppercase provider values for standard Google Generative AI levels", async () => {
const payload = await captureGooglePayload(googleModel("gemini-3.7-flash", { high: "LOW" }), "high");
expect(payload).toMatchObject({ config: { thinkingConfig: { thinkingLevel: "LOW" } } });
});
it("uses mapped Google Generative AI levels for token budgets", async () => {
const payload = await captureGooglePayload(googleModel("gemini-2.5-flash", { xhigh: "high" }), "xhigh", {
high: 1234,
});
expect(payload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: 1234 } } });
});
it("maps Google Vertex extended levels", async () => {
const payload = await captureVertexPayload(vertexModel("gemini-3.7-flash", { xhigh: "high" }), "xhigh");
expect(payload).toMatchObject({ config: { thinkingConfig: { includeThoughts: true, thinkingLevel: "HIGH" } } });
});
it("uses mapped Google Vertex levels for token budgets", async () => {
const payload = await captureVertexPayload(vertexModel("gemini-2.5-flash", { max: "high" }), "max", {
high: 4321,
});
expect(payload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: 4321 } } });
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { isThinkingPart, retainThoughtSignature } from "../src/api/google-shared.ts";
describe("Google thinking detection (thoughtSignature)", () => {
it("treats part.thought === true as thinking", () => {
expect(isThinkingPart({ thought: true, thoughtSignature: undefined })).toBe(true);
expect(isThinkingPart({ thought: true, thoughtSignature: "opaque-signature" })).toBe(true);
});
it("does not treat thoughtSignature alone as thinking", () => {
// Per Google docs, thoughtSignature is for context replay and can appear on any part type.
// Only thought === true indicates thinking content.
// See: https://ai.google.dev/gemini-api/docs/thought-signatures
expect(isThinkingPart({ thought: undefined, thoughtSignature: "opaque-signature" })).toBe(false);
expect(isThinkingPart({ thought: false, thoughtSignature: "opaque-signature" })).toBe(false);
});
it("does not treat empty/missing signatures as thinking if thought is not set", () => {
expect(isThinkingPart({ thought: undefined, thoughtSignature: undefined })).toBe(false);
expect(isThinkingPart({ thought: false, thoughtSignature: "" })).toBe(false);
});
it("preserves the existing signature when subsequent deltas omit thoughtSignature", () => {
const first = retainThoughtSignature(undefined, "sig-1");
expect(first).toBe("sig-1");
const second = retainThoughtSignature(first, undefined);
expect(second).toBe("sig-1");
const third = retainThoughtSignature(second, "");
expect(third).toBe("sig-1");
});
it("updates the signature when a new non-empty signature arrives", () => {
const updated = retainThoughtSignature("sig-1", "sig-2");
expect(updated).toBe("sig-2");
});
});

View File

@@ -0,0 +1,242 @@
import { arch, platform, release } from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const googleGenAiMock = vi.hoisted(() => ({
constructorCalls: [] as Array<Record<string, unknown>>,
}));
vi.mock("@google/genai", () => {
class GoogleGenAI {
models = {
generateContentStream: async function* () {
yield {
responseId: "vertex-response-id",
candidates: [
{
content: { parts: [{ text: "ok" }] },
finishReason: "STOP",
},
],
usageMetadata: {
promptTokenCount: 1,
candidatesTokenCount: 1,
totalTokenCount: 2,
},
};
},
};
constructor(config: Record<string, unknown>) {
googleGenAiMock.constructorCalls.push(config);
}
}
return {
GoogleGenAI,
ResourceScope: {
COLLECTION: "COLLECTION",
},
ThinkingLevel: {
THINKING_LEVEL_UNSPECIFIED: "THINKING_LEVEL_UNSPECIFIED",
MINIMAL: "MINIMAL",
LOW: "LOW",
MEDIUM: "MEDIUM",
HIGH: "HIGH",
},
};
});
import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts";
import { getModel } from "../src/compat.ts";
import type { Context, Model } from "../src/types.ts";
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const model = getModel("google-vertex", "gemini-3-flash-preview");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
const originalGoogleCloudApiKey = process.env.GOOGLE_CLOUD_API_KEY;
beforeEach(() => {
googleGenAiMock.constructorCalls.length = 0;
delete process.env.GOOGLE_CLOUD_API_KEY;
});
afterEach(() => {
if (originalGoogleCloudApiKey === undefined) {
delete process.env.GOOGLE_CLOUD_API_KEY;
} else {
process.env.GOOGLE_CLOUD_API_KEY = originalGoogleCloudApiKey;
}
});
describe("google-vertex api key resolution", () => {
it("falls back to ADC when options.apiKey is a placeholder marker", async () => {
const stream = streamGoogleVertex(model, context, {
apiKey: "<authenticated>",
project: "test-project",
location: "us-central1",
});
await stream.result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
expect(googleGenAiMock.constructorCalls[0]).toMatchObject({
vertexai: true,
project: "test-project",
location: "us-central1",
apiVersion: "v1",
});
expect(googleGenAiMock.constructorCalls[0]).not.toHaveProperty("apiKey");
});
it("falls back to ADC when options.apiKey is the gcp-vertex-credentials marker", async () => {
const stream = streamGoogleVertex(model, context, {
apiKey: "gcp-vertex-credentials",
project: "test-project",
location: "us-central1",
});
await stream.result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
expect(googleGenAiMock.constructorCalls[0]).toMatchObject({
vertexai: true,
project: "test-project",
location: "us-central1",
apiVersion: "v1",
});
expect(googleGenAiMock.constructorCalls[0]).not.toHaveProperty("apiKey");
});
it("falls back to ADC when GOOGLE_CLOUD_API_KEY is a placeholder marker", async () => {
process.env.GOOGLE_CLOUD_API_KEY = "<authenticated>";
const stream = streamGoogleVertex(model, context, {
project: "test-project",
location: "us-central1",
});
await stream.result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
expect(googleGenAiMock.constructorCalls[0]).toMatchObject({
vertexai: true,
project: "test-project",
location: "us-central1",
apiVersion: "v1",
});
expect(googleGenAiMock.constructorCalls[0]).not.toHaveProperty("apiKey");
});
it("still uses the API key client for real API keys", async () => {
const stream = streamGoogleVertex(model, context, {
apiKey: "AIzaSyExampleRealisticLookingApiKey123456",
});
await stream.result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
expect(googleGenAiMock.constructorCalls[0]).toMatchObject({
vertexai: true,
apiKey: "AIzaSyExampleRealisticLookingApiKey123456",
apiVersion: "v1",
});
expect(googleGenAiMock.constructorCalls[0]).not.toHaveProperty("project");
expect(googleGenAiMock.constructorCalls[0]).not.toHaveProperty("location");
});
it("does not forward generated Vertex base URL placeholders", async () => {
const stream = streamGoogleVertex(model, context, {
project: "test-project",
location: "us-central1",
});
await stream.result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toEqual({
headers: { "User-Agent": PI_USER_AGENT },
});
});
it("lets explicit headers override the default User-Agent", async () => {
const stream = streamGoogleVertex(model, context, {
project: "test-project",
location: "us-central1",
headers: { "User-Agent": "custom-agent" },
});
await stream.result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toEqual({
headers: { "User-Agent": "custom-agent" },
});
});
it("forwards custom baseUrl to the ADC client", async () => {
const customModel: Model<"google-vertex"> = { ...model, baseUrl: "https://proxy.example.com" };
const stream = streamGoogleVertex(customModel, context, {
project: "test-project",
location: "us-central1",
});
await stream.result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
expect(googleGenAiMock.constructorCalls[0]).toMatchObject({
vertexai: true,
project: "test-project",
location: "us-central1",
apiVersion: "v1",
httpOptions: {
baseUrl: "https://proxy.example.com",
baseUrlResourceScope: "COLLECTION",
},
});
});
it("forwards custom baseUrl to the API key client", async () => {
const customModel: Model<"google-vertex"> = { ...model, baseUrl: "https://proxy.example.com" };
const stream = streamGoogleVertex(customModel, context, {
apiKey: "AIzaSyExampleRealisticLookingApiKey123456",
});
await stream.result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
expect(googleGenAiMock.constructorCalls[0]).toMatchObject({
vertexai: true,
apiKey: "AIzaSyExampleRealisticLookingApiKey123456",
apiVersion: "v1",
httpOptions: {
baseUrl: "https://proxy.example.com",
baseUrlResourceScope: "COLLECTION",
},
});
});
it("does not append apiVersion when custom baseUrl already includes one", async () => {
const customModel: Model<"google-vertex"> = {
...model,
baseUrl: "https://proxy.example.com/v1/projects/test-project/locations/global",
};
const stream = streamGoogleVertex(customModel, context, {
project: "test-project",
location: "us-central1",
});
await stream.result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
expect(googleGenAiMock.constructorCalls[0]).toMatchObject({
httpOptions: {
baseUrl: "https://proxy.example.com/v1/projects/test-project/locations/global",
baseUrlResourceScope: "COLLECTION",
apiVersion: "",
},
});
});
});

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { parseOpenRouterImageModels } from "../scripts/generate-image-models.ts";
const validImageModel = {
id: "example/image-model",
name: "Example Image Model",
architecture: {
input_modalities: ["text", "image"],
output_modalities: ["image"],
},
pricing: {
prompt: "0.000001",
completion: "0.000002",
},
};
describe("OpenRouter image model parsing", () => {
it.each([{}, { data: [] }, { data: "invalid" }])("rejects a missing or empty strict catalog", (payload) => {
expect(() => parseOpenRouterImageModels(payload, true)).toThrow("missing or empty image model list");
});
it("rejects a strict catalog with no usable image models", () => {
expect(() =>
parseOpenRouterImageModels(
{
data: [
{
...validImageModel,
architecture: { input_modalities: ["text"], output_modalities: ["text"] },
},
],
},
true,
),
).toThrow("no usable image models");
});
it("parses a non-empty image model catalog", () => {
expect(parseOpenRouterImageModels({ data: [validImageModel] }, true)).toEqual([
expect.objectContaining({
id: "example/image-model",
input: ["text", "image"],
output: ["image"],
}),
]);
});
});

View File

@@ -0,0 +1,550 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import type { Api, Context, Model, Tool, ToolResultMessage } from "../src/compat.ts";
import { complete, getModel } from "../src/compat.ts";
import type { StreamOptions } from "../src/types.ts";
type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts";
import { hasBedrockCredentials } from "./bedrock-utils.ts";
import { resolveApiKey } from "./oauth.ts";
// Resolve OAuth tokens at module level (async, runs before tests)
const oauthTokens = await Promise.all([
resolveApiKey("anthropic"),
resolveApiKey("github-copilot"),
resolveApiKey("openai-codex"),
]);
const [anthropicOAuthToken, githubCopilotToken, openaiCodexToken] = oauthTokens;
/**
* Test that tool results containing only images work correctly across all providers.
* This verifies that:
* 1. Tool results can contain image content blocks
* 2. Providers correctly pass images from tool results to the LLM
* 3. The LLM can see and describe images returned by tools
*/
async function handleToolWithImageResult<TApi extends Api>(model: Model<TApi>, options?: StreamOptionsWithExtras) {
// Check if the model supports images
if (!model.input.includes("image")) {
console.log(`Skipping tool image result test - model ${model.id} doesn't support images`);
return;
}
// Read the test image
const imagePath = join(__dirname, "data", "red-circle.png");
const imageBuffer = readFileSync(imagePath);
const base64Image = imageBuffer.toString("base64");
// Define a tool that returns only an image (no text)
const getImageSchema = Type.Object({});
const getImageTool: Tool<typeof getImageSchema> = {
name: "get_circle",
description: "Returns a circle image for visualization",
parameters: getImageSchema,
};
const context: Context = {
systemPrompt: "You are a helpful assistant that uses tools when asked.",
messages: [
{
role: "user",
content: "Call the get_circle tool to get an image, and describe what you see, shapes, colors, etc.",
timestamp: Date.now(),
},
],
tools: [getImageTool],
};
// First request - LLM should call the tool
const firstResponse = await complete(model, context, options);
expect(firstResponse.stopReason).toBe("toolUse");
// Find the tool call
const toolCall = firstResponse.content.find((b) => b.type === "toolCall");
expect(toolCall).toBeTruthy();
if (!toolCall || toolCall.type !== "toolCall") {
throw new Error("Expected tool call");
}
expect(toolCall.name).toBe("get_circle");
// Add the tool call to context
context.messages.push(firstResponse);
// Create tool result with ONLY an image (no text)
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [
{
type: "image",
data: base64Image,
mimeType: "image/png",
},
],
isError: false,
timestamp: Date.now(),
};
context.messages.push(toolResult);
// Second request - LLM should describe the image from the tool result
const secondResponse = await complete(model, context, options);
expect(secondResponse.stopReason).toBe("stop");
expect(secondResponse.errorMessage).toBeFalsy();
// Verify the LLM can see and describe the image
const textContent = secondResponse.content.find((b) => b.type === "text");
expect(textContent).toBeTruthy();
if (textContent && textContent.type === "text") {
const lowerContent = textContent.text.toLowerCase();
// Should mention red and circle since that's what the image shows
expect(lowerContent).toContain("red");
expect(lowerContent).toContain("circle");
}
}
/**
* Test that tool results containing both text and images work correctly across all providers.
* This verifies that:
* 1. Tool results can contain mixed content blocks (text + images)
* 2. Providers correctly pass both text and images from tool results to the LLM
* 3. The LLM can see both the text and images in tool results
*/
async function handleToolWithTextAndImageResult<TApi extends Api>(
model: Model<TApi>,
options?: StreamOptionsWithExtras,
) {
// Check if the model supports images
if (!model.input.includes("image")) {
console.log(`Skipping tool text+image result test - model ${model.id} doesn't support images`);
return;
}
// Read the test image
const imagePath = join(__dirname, "data", "red-circle.png");
const imageBuffer = readFileSync(imagePath);
const base64Image = imageBuffer.toString("base64");
// Define a tool that returns both text and an image
const getImageSchema = Type.Object({});
const getImageTool: Tool<typeof getImageSchema> = {
name: "get_circle_with_description",
description: "Returns a circle image with a text description",
parameters: getImageSchema,
};
const context: Context = {
systemPrompt: "You are a helpful assistant that uses tools when asked.",
messages: [
{
role: "user",
content:
"Use the get_circle_with_description tool and tell me what you learned. Also say what color the shape is.",
timestamp: Date.now(),
},
],
tools: [getImageTool],
};
// First request - LLM should call the tool
const firstResponse = await complete(model, context, options);
expect(firstResponse.stopReason).toBe("toolUse");
// Find the tool call
const toolCall = firstResponse.content.find((b) => b.type === "toolCall");
expect(toolCall).toBeTruthy();
if (!toolCall || toolCall.type !== "toolCall") {
throw new Error("Expected tool call");
}
expect(toolCall.name).toBe("get_circle_with_description");
// Add the tool call to context
context.messages.push(firstResponse);
// Create tool result with BOTH text and image
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [
{
type: "text",
text: "This is a geometric shape with specific properties: it has a diameter of 100 pixels.",
},
{
type: "image",
data: base64Image,
mimeType: "image/png",
},
],
isError: false,
timestamp: Date.now(),
};
context.messages.push(toolResult);
// Second request - LLM should describe both the text and image from the tool result
const secondResponse = await complete(model, context, options);
expect(secondResponse.stopReason).toBe("stop");
expect(secondResponse.errorMessage).toBeFalsy();
// Verify the LLM can see both text and image
const textContent = secondResponse.content.find((b) => b.type === "text");
expect(textContent).toBeTruthy();
if (textContent && textContent.type === "text") {
const lowerContent = textContent.text.toLowerCase();
// Should mention details from the text (diameter/pixels)
expect(lowerContent.match(/diameter|100|pixel/)).toBeTruthy();
// Should also mention the visual properties (red and circle)
expect(lowerContent).toContain("red");
expect(lowerContent).toContain("circle");
}
}
describe("Tool Results with Images", () => {
describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider (gemini-2.5-flash)", () => {
const llm = getModel("google", "gemini-2.5-flash");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
});
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions Provider (gpt-4o-mini)", () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
void _compat;
const llm: Model<"openai-completions"> = {
...baseModel,
api: "openai-completions",
};
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
});
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses Provider (gpt-5-mini)", () => {
const llm = getModel("openai", "gpt-5-mini");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
});
describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider (gpt-4o-mini)", () => {
const llm = getModel("azure-openai-responses", "gpt-4o-mini");
const azureDeploymentName = resolveAzureDeploymentName(llm.id);
const azureOptions = azureDeploymentName ? { azureDeploymentName } : {};
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm, azureOptions);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm, azureOptions);
});
});
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider (claude-haiku-4-5)", () => {
const model = getModel("anthropic", "claude-haiku-4-5");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(model);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(model);
});
});
describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter Provider (glm-4.5v)", () => {
const llm = getModel("openrouter", "z-ai/glm-4.5v");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
});
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider (pixtral-12b)", () => {
const llm = getModel("mistral", "pixtral-12b");
it("should handle tool result with only image", { retry: 5, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
it("should handle tool result with text and image", { retry: 5, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider (Kimi-K2.6)", () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm, options);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm, options);
});
});
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider (Kimi-K2.6)", () => {
const llm = getModel("baseten", "moonshotai/Kimi-K2.6");
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm, options);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm, options);
});
});
describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider (mimo-v2.5-pro)", () => {
const llm = getModel("xiaomi", "mimo-v2.5-pro");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
// FIXME(xiaomi): when a tool_result contains both a descriptive text block
// and an image block, MiMo locks onto the text and ignores the image (it
// reports the text-derived diameter but never mentions the image's color).
// The image-only case above proves the image reaches the model, and the
// text-only path obviously works, so this is a multimodal-fusion quality
// issue in the model, not a transport bug. Re-enable when upstream model
// quality improves.
it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
});
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)(
"Xiaomi MiMo Token Plan (CN) Provider (mimo-v2.5-pro)",
() => {
const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
// FIXME(xiaomi): see the API-billing block above — same multimodal-fusion
// limitation applies to Token Plan endpoints (same model behind both).
it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
},
);
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)(
"Xiaomi MiMo Token Plan (AMS) Provider (mimo-v2.5-pro)",
() => {
const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
// FIXME(xiaomi): see the API-billing block above — same multimodal-fusion
// limitation applies to Token Plan endpoints (same model behind both).
it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
},
);
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)(
"Xiaomi MiMo Token Plan (SGP) Provider (mimo-v2.5-pro)",
() => {
const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
// FIXME(xiaomi): see the API-billing block above — same multimodal-fusion
// limitation applies to Token Plan endpoints (same model behind both).
it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
},
);
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider (qwen3.7-max)", () => {
const llm = getModel("qwen-token-plan", "qwen3.7-max");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
});
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider (qwen3.8-max)", () => {
const llm = getModel("qwen-token-plan-individual", "qwen3.8-max");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
});
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider (qwen3.7-max)", () => {
const llm = getModel("qwen-token-plan-cn", "qwen3.7-max");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
});
describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider (kimi-for-coding)", () => {
const llm = getModel("kimi-coding", "kimi-for-coding");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
});
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider (google/gemini-2.5-flash)", () => {
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
});
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider (claude-sonnet-4-5)", () => {
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm);
});
});
// =========================================================================
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
// =========================================================================
describe("Anthropic OAuth Provider (claude-sonnet-4-5)", () => {
const model = getModel("anthropic", "claude-sonnet-4-5");
it.skipIf(!anthropicOAuthToken)(
"should handle tool result with only image",
{ retry: 3, timeout: 30000 },
async () => {
await handleToolWithImageResult(model, { apiKey: anthropicOAuthToken });
},
);
it.skipIf(!anthropicOAuthToken)(
"should handle tool result with text and image",
{ retry: 3, timeout: 30000 },
async () => {
await handleToolWithTextAndImageResult(model, { apiKey: anthropicOAuthToken });
},
);
});
describe("GitHub Copilot Provider", () => {
it.skipIf(!githubCopilotToken)(
"claude-haiku-4.5 - should handle tool result with only image",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "claude-haiku-4.5");
await handleToolWithImageResult(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"claude-haiku-4.5 - should handle tool result with text and image",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "claude-haiku-4.5");
await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"claude-sonnet-4 - should handle tool result with only image",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await handleToolWithImageResult(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"claude-sonnet-4 - should handle tool result with text and image",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken });
},
);
});
describe("OpenAI Codex Provider", () => {
it.skipIf(!openaiCodexToken)(
"gpt-5.5 - should handle tool result with only image",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("openai-codex", "gpt-5.5");
await handleToolWithImageResult(llm, { apiKey: openaiCodexToken });
},
);
it.skipIf(!openaiCodexToken)(
"gpt-5.5 - should handle tool result with text and image",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("openai-codex", "gpt-5.5");
await handleToolWithTextAndImageResult(llm, { apiKey: openaiCodexToken });
},
);
});
});

View File

@@ -0,0 +1,209 @@
import { describe, expect, it } from "vitest";
import type { AuthContext } from "../src/auth/types.ts";
import { createImagesModels, createImagesProvider, type ImagesProvider } from "../src/images-models.ts";
import { builtinImagesModels } from "../src/providers/all.ts";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ImagesOptions } from "../src/types.ts";
function fakeAuthContext(env: Record<string, string>): AuthContext {
return {
env: async (name) => env[name],
fileExists: async () => false,
};
}
function testImageModel(provider: string, id: string): ImagesModel<ImagesApi> {
return {
id,
name: id,
api: "test-images",
provider,
baseUrl: "https://example.test/v1",
input: ["text"],
output: ["image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
}
function okResult(model: ImagesModel<ImagesApi>): AssistantImages {
return {
api: model.api,
provider: model.provider,
model: model.id,
output: [{ type: "image", data: "aGk=", mimeType: "image/png" }],
stopReason: "stop",
timestamp: Date.now(),
};
}
interface GenerateCall {
model: ImagesModel<ImagesApi>;
options: ImagesOptions | undefined;
}
function testProvider(input: {
id: string;
models?: ImagesModel<ImagesApi>[];
envVar?: string;
calls?: GenerateCall[];
}): ImagesProvider {
return createImagesProvider({
id: input.id,
auth: {
apiKey: {
name: "Test key",
resolve: async ({ ctx, credential }) => {
if (!input.envVar) return { auth: {} };
const key = credential?.key ?? (await ctx.env(input.envVar));
return key ? { auth: { apiKey: key }, source: credential ? "stored" : input.envVar } : undefined;
},
},
},
models: input.models ?? [testImageModel(input.id, "model-a")],
api: {
generateImages: async (model, _context, options) => {
input.calls?.push({ model, options });
return okResult(model);
},
},
});
}
const context: ImagesContext = { input: [{ type: "text", text: "a red circle" }] };
describe("ImagesModels", () => {
it("registers providers and reads models synchronously", () => {
const models = createImagesModels();
models.setProvider(testProvider({ id: "p1", models: [testImageModel("p1", "m1"), testImageModel("p1", "m2")] }));
models.setProvider(testProvider({ id: "p2", models: [testImageModel("p2", "m3")] }));
expect(models.getProviders().map((p) => p.id)).toEqual(["p1", "p2"]);
expect(models.getModels().map((m) => m.id)).toEqual(["m1", "m2", "m3"]);
expect(models.getModels("p1").map((m) => m.id)).toEqual(["m1", "m2"]);
expect(models.getModel("p2", "m3")?.id).toBe("m3");
expect(models.getModel("p2", "missing")).toBeUndefined();
models.deleteProvider("p1");
expect(models.getProvider("p1")).toBeUndefined();
});
it("resolves auth through the provider and merges it into requests; explicit options win", async () => {
const calls: GenerateCall[] = [];
const models = createImagesModels({ authContext: fakeAuthContext({ TEST_KEY: "env-key" }) });
models.setProvider(testProvider({ id: "p1", envVar: "TEST_KEY", calls }));
const model = models.getModel("p1", "model-a")!;
expect((await models.getAuth(model))?.auth.apiKey).toBe("env-key");
expect((await models.getAuth(model.provider))?.auth.apiKey).toBe("env-key");
expect((await models.getAuth(model, { apiKey: "explicit-key" }))?.auth.apiKey).toBe("explicit-key");
const result = await models.generateImages(model, context);
expect(result.stopReason).toBe("stop");
expect(calls[0].options?.apiKey).toBe("env-key");
await models.generateImages(model, context, { apiKey: "explicit" });
expect(calls[1].options?.apiKey).toBe("explicit");
});
it("merges provider-resolved env into image options", async () => {
const calls: GenerateCall[] = [];
const models = createImagesModels();
models.setProvider(
createImagesProvider({
id: "p1",
auth: {
apiKey: {
name: "Test key",
resolve: async () => ({
auth: { apiKey: "provider-key" },
env: { PROVIDER_ONLY: "provider", SHARED: "provider" },
}),
},
},
models: [testImageModel("p1", "model-a")],
api: {
generateImages: async (model, _context, options) => {
calls.push({ model, options });
return okResult(model);
},
},
}),
);
const model = models.getModel("p1", "model-a")!;
await models.generateImages(model, context, {
apiKey: "request-key",
env: { REQUEST_ONLY: "request", SHARED: "request" },
});
expect(calls[0].options?.apiKey).toBe("request-key");
expect(calls[0].options?.env).toEqual({
PROVIDER_ONLY: "provider",
REQUEST_ONLY: "request",
SHARED: "request",
});
});
it("returns an error result for unknown providers and unconfigured auth rejections", async () => {
const models = createImagesModels({ authContext: fakeAuthContext({}) });
const ghost = await models.generateImages(testImageModel("ghost", "m"), context);
expect(ghost.stopReason).toBe("error");
expect(ghost.errorMessage).toContain("Unknown provider: ghost");
// unconfigured (resolve -> undefined) still dispatches; provider decides what to do
const calls: GenerateCall[] = [];
models.setProvider(testProvider({ id: "p1", envVar: "MISSING", calls }));
const model = models.getModel("p1", "model-a")!;
expect(await models.getAuth(model)).toBeUndefined();
await models.generateImages(model, context);
expect(calls[0].options?.apiKey).toBeUndefined();
});
it("supports dynamic providers via refresh with in-flight dedupe", async () => {
let fetches = 0;
const provider = createImagesProvider({
id: "dyn",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
refreshModels: async () => {
fetches++;
await new Promise((resolve) => setTimeout(resolve, 5));
return [testImageModel("dyn", "listed")];
},
api: { generateImages: async (model) => okResult(model) },
});
const models = createImagesModels();
models.setProvider(provider);
expect(models.getModels("dyn")).toEqual([]);
await Promise.all([models.refresh("dyn"), models.refresh("dyn")]);
expect(fetches).toBe(1);
expect(models.getModel("dyn", "listed")).toBeDefined();
// failures reject with ModelsError for a single provider
models.setProvider(
createImagesProvider({
id: "flaky",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
refreshModels: async () => {
throw new Error("fetch failed");
},
api: { generateImages: async (model) => okResult(model) },
}),
);
await expect(models.refresh("flaky")).rejects.toMatchObject({ code: "model_source" });
await expect(models.refresh()).resolves.toBeUndefined();
});
it("builtinImagesModels registers the openrouter provider with its catalog", async () => {
const models = builtinImagesModels({ authContext: fakeAuthContext({ OPENROUTER_API_KEY: "or-key" }) });
const providers = models.getProviders();
expect(providers.map((p) => p.id)).toEqual(["openrouter"]);
const list = models.getModels("openrouter");
expect(list.length).toBeGreaterThan(0);
expect(list.every((m) => m.api === "openrouter-images")).toBe(true);
expect((await models.getAuth(list[0]))?.auth.apiKey).toBe("or-key");
});
});

View File

@@ -0,0 +1,90 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { getImageModel } from "../src/image-models.ts";
import { generateImages } from "../src/images.ts";
import type { ImageContent, ImagesContext, ImagesModel, ProviderImagesOptions } from "../src/types.ts";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
type ImagesOptionsWithExtras = ProviderImagesOptions & Record<string, unknown>;
async function basicImageGeneration<TApi extends string>(model: ImagesModel<TApi>, options?: ImagesOptionsWithExtras) {
const context: ImagesContext = {
input: [{ type: "text", text: "Generate a simple red circle on a plain white background. No text." }],
};
const response = await generateImages(model, context, options);
expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("stop");
expect(response.errorMessage).toBeFalsy();
expect(response.output.some((item) => item.type === "image")).toBe(true);
expect(response.timestamp).toBeGreaterThan(0);
}
async function handleTextAndImageOutput<TApi extends string>(
model: ImagesModel<TApi>,
options?: ImagesOptionsWithExtras,
) {
if (!model.output.includes("text")) {
console.log(`Skipping text+image output test - model ${model.id} doesn't support text output`);
return;
}
const context: ImagesContext = {
input: [{ type: "text", text: "Generate a red circle and include a brief description of the image." }],
};
const response = await generateImages(model, context, options);
expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("stop");
expect(response.output.some((item) => item.type === "image")).toBe(true);
expect(response.output.some((item) => item.type === "text" && item.text.trim().length > 0)).toBe(true);
}
async function handleImageInput<TApi extends string>(model: ImagesModel<TApi>, options?: ImagesOptionsWithExtras) {
if (!model.input.includes("image")) {
console.log(`Skipping image input test - model ${model.id} doesn't support image input`);
return;
}
const imagePath = join(__dirname, "data", "red-circle.png");
const imageBuffer = readFileSync(imagePath);
const imageContent: ImageContent = {
type: "image",
data: imageBuffer.toString("base64"),
mimeType: "image/png",
};
const context: ImagesContext = {
input: [{ type: "text", text: "Create a variation of this image with a blue background." }, imageContent],
};
const response = await generateImages(model, context, options);
expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("stop");
expect(response.output.some((item) => item.type === "image")).toBe(true);
}
describe("Images E2E Tests", () => {
describe.skipIf(!process.env.OPENROUTER_API_KEY)(
"OpenRouter Images Provider (google/gemini-2.5-flash-image)",
() => {
const model = getImageModel("openrouter", "google/gemini-2.5-flash-image");
it("should generate a basic image", { retry: 3 }, async () => {
await basicImageGeneration(model);
});
it("should handle text plus image output", { retry: 3 }, async () => {
await handleTextAndImageOutput(model);
});
it("should handle image input", { retry: 3 }, async () => {
await handleImageInput(model);
});
},
);
});

View File

@@ -0,0 +1,144 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { completeSimple, getModel } from "../src/compat.ts";
import { getEnvApiKey } from "../src/env-api-keys.ts";
import type { Api, Context, Model, StopReason, Tool, ToolCall, ToolResultMessage } from "../src/types.ts";
import { StringEnum } from "../src/utils/typebox-helpers.ts";
import { hasBedrockCredentials } from "./bedrock-utils.ts";
const calculatorSchema = Type.Object({
a: Type.Number({ description: "First number" }),
b: Type.Number({ description: "Second number" }),
operation: StringEnum(["add", "subtract", "multiply", "divide"], {
description: "The operation to perform.",
}),
});
const calculatorTool: Tool<typeof calculatorSchema> = {
name: "calculator",
description: "Perform basic arithmetic operations",
parameters: calculatorSchema,
};
type CalculatorOperation = "add" | "subtract" | "multiply" | "divide";
type CalculatorArguments = {
a: number;
b: number;
operation: CalculatorOperation;
};
function asCalculatorArguments(args: ToolCall["arguments"]): CalculatorArguments {
if (typeof args !== "object" || args === null) {
throw new Error("Tool arguments must be an object");
}
const value = args as Record<string, unknown>;
const operation = value.operation;
if (
typeof value.a !== "number" ||
typeof value.b !== "number" ||
(operation !== "add" && operation !== "subtract" && operation !== "multiply" && operation !== "divide")
) {
throw new Error("Invalid calculator arguments");
}
return { a: value.a, b: value.b, operation };
}
function evaluateCalculatorCall(toolCall: ToolCall): number {
const { a, b, operation } = asCalculatorArguments(toolCall.arguments);
switch (operation) {
case "add":
return a + b;
case "subtract":
return a - b;
case "multiply":
return a * b;
case "divide":
return a / b;
}
}
async function assertSecondToolCallWithInterleavedThinking<TApi extends Api>(
llm: Model<TApi>,
reasoning: "high" | "xhigh",
) {
const context: Context = {
systemPrompt: [
"You are a helpful assistant that must use tools for arithmetic.",
"Always think before every tool call, not just the first one.",
"Do not answer with plain text when a tool call is required.",
].join(" "),
messages: [
{
role: "user",
content: [
"Use calculator to calculate 328 * 29.",
"You must call the calculator tool exactly once.",
"Provide the final answer based on the best guess given the tool result, even if it seems unreliable.",
"Start by thinking about the steps you will take to solve the problem.",
].join(" "),
timestamp: Date.now(),
},
],
tools: [calculatorTool],
};
const firstResponse = await completeSimple(llm, context, { reasoning });
expect(firstResponse.stopReason, `Error: ${firstResponse.errorMessage}`).toBe("toolUse" satisfies StopReason);
expect(firstResponse.content.some((block) => block.type === "thinking")).toBe(true);
expect(firstResponse.content.some((block) => block.type === "toolCall")).toBe(true);
const firstToolCall = firstResponse.content.find((block) => block.type === "toolCall");
expect(firstToolCall?.type).toBe("toolCall");
if (!firstToolCall || firstToolCall.type !== "toolCall") {
throw new Error("Expected first response to include a tool call");
}
context.messages.push(firstResponse);
const correctAnswer = evaluateCalculatorCall(firstToolCall);
const firstToolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: firstToolCall.id,
toolName: firstToolCall.name,
content: [{ type: "text", text: `The answer is ${correctAnswer} or ${correctAnswer * 2}.` }],
isError: false,
timestamp: Date.now(),
};
context.messages.push(firstToolResult);
const secondResponse = await completeSimple(llm, context, { reasoning });
expect(secondResponse.stopReason, `Error: ${secondResponse.errorMessage}`).toBe("stop" satisfies StopReason);
expect(secondResponse.content.some((block) => block.type === "thinking")).toBe(true);
expect(secondResponse.content.some((block) => block.type === "text")).toBe(true);
}
const hasAnthropicCredentials = !!getEnvApiKey("anthropic");
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock interleaved thinking", () => {
it("should do interleaved thinking on Claude Opus 4.5", { retry: 3 }, async () => {
const llm = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-5-20251101-v1:0");
await assertSecondToolCallWithInterleavedThinking(llm, "high");
});
it("should do interleaved thinking on Claude Opus 4.6", { retry: 3 }, async () => {
const llm = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
await assertSecondToolCallWithInterleavedThinking(llm, "high");
});
});
describe.skipIf(!hasAnthropicCredentials)("Anthropic interleaved thinking", () => {
it("should do interleaved thinking on Claude Opus 4.5", { retry: 3 }, async () => {
const llm = getModel("anthropic", "claude-opus-4-5");
await assertSecondToolCallWithInterleavedThinking(llm, "high");
});
it("should do interleaved thinking on Claude Opus 4.6", { retry: 3 }, async () => {
const llm = getModel("anthropic", "claude-opus-4-6");
await assertSecondToolCallWithInterleavedThinking(llm, "high");
});
});

View File

@@ -0,0 +1,270 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { kimiCodingOAuth } from "../src/auth/oauth/kimi-coding.ts";
import type { ProviderAuthInteraction } from "../src/auth/types.ts";
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
const OAUTH_HOST = "https://auth.kimi.com";
function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function getUrl(input: unknown): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
if (input instanceof Request) return input.url;
throw new Error(`Unsupported fetch input: ${String(input)}`);
}
function deviceAuthorizationResponse(overrides?: Record<string, unknown>): Response {
return jsonResponse({
user_code: "ABCD-1234",
device_code: "device-code-123",
verification_uri: "https://www.kimi.com/code",
verification_uri_complete: "https://www.kimi.com/code?user_code=ABCD-1234",
interval: 5,
expires_in: 600,
...overrides,
});
}
function createInteraction(events: Array<Record<string, unknown>>): ProviderAuthInteraction {
return {
signal: new AbortController().signal,
prompt: async () => {
throw new Error("Kimi Code login should not prompt");
},
notify: (event) => events.push(event),
};
}
describe("Kimi Code OAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.useRealTimers();
});
it("logs in with the device authorization flow", async () => {
vi.useFakeTimers();
const startTime = new Date("2026-07-20T00:00:00Z");
vi.setSystemTime(startTime);
const events: Array<Record<string, unknown>> = [];
const pollResponses = [
jsonResponse({ error: "authorization_pending" }, 400),
jsonResponse({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600 }),
];
const pollTimes: number[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
const url = getUrl(input);
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
});
expect(new URLSearchParams(String(init?.body)).get("client_id")).toBe(CLIENT_ID);
return deviceAuthorizationResponse();
}
if (url === `${OAUTH_HOST}/api/oauth/token`) {
pollTimes.push(Date.now());
const params = new URLSearchParams(String(init?.body));
expect(params.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code");
expect(params.get("client_id")).toBe(CLIENT_ID);
expect(params.get("device_code")).toBe("device-code-123");
const response = pollResponses.shift();
if (!response) throw new Error("Unexpected extra token poll");
return response;
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
const credentialPromise = kimiCodingOAuth.login(createInteraction(events));
for (let i = 0; i < 5 && events.length === 0; i++) {
await vi.advanceTimersByTimeAsync(0);
}
expect(events).toEqual([
{
type: "device_code",
userCode: "ABCD-1234",
verificationUri: "https://www.kimi.com/code?user_code=ABCD-1234",
intervalSeconds: 5,
expiresInSeconds: 600,
},
]);
// waitBeforeFirstPoll: first poll happens after the 5s interval.
await vi.advanceTimersByTimeAsync(4999);
expect(pollTimes).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(pollTimes).toEqual([startTime.getTime() + 5000]);
await vi.advanceTimersByTimeAsync(5000);
await expect(credentialPromise).resolves.toEqual({
type: "oauth",
access: "access-token",
refresh: "refresh-token",
expires: startTime.getTime() + 10000 + 3600 * 1000,
});
expect(pollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 10000]);
});
it("fails when the device code expires", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
return deviceAuthorizationResponse();
}
if (url === `${OAUTH_HOST}/api/oauth/token`) {
return jsonResponse({ error: "expired_token" }, 400);
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
const assertion = expect(credentialPromise).rejects.toThrow("expired");
await vi.advanceTimersByTimeAsync(5000);
await assertion;
});
it("fails when the user denies the login", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
return deviceAuthorizationResponse();
}
if (url === `${OAUTH_HOST}/api/oauth/token`) {
return jsonResponse({ error: "access_denied" }, 400);
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
const assertion = expect(credentialPromise).rejects.toThrow("denied");
await vi.advanceTimersByTimeAsync(5000);
await assertion;
});
it("honors the KIMI_CODE_OAUTH_HOST override", async () => {
vi.useFakeTimers();
vi.stubEnv("KIMI_CODE_OAUTH_HOST", "https://auth.example.com/");
const urls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
urls.push(url);
if (url === "https://auth.example.com/api/oauth/device_authorization") {
return deviceAuthorizationResponse({ interval: 1 });
}
if (url === "https://auth.example.com/api/oauth/token") {
return jsonResponse({ access_token: "a", refresh_token: "r", expires_in: 60 });
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
await vi.advanceTimersByTimeAsync(1000);
await expect(credentialPromise).resolves.toMatchObject({ access: "a", refresh: "r" });
expect(urls).toEqual([
"https://auth.example.com/api/oauth/device_authorization",
"https://auth.example.com/api/oauth/token",
]);
});
it("refreshes tokens and returns a Bearer header for requests", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
const url = getUrl(input);
expect(url).toBe(`${OAUTH_HOST}/api/oauth/token`);
const params = new URLSearchParams(String(init?.body));
expect(params.get("grant_type")).toBe("refresh_token");
expect(params.get("refresh_token")).toBe("old-refresh");
expect(params.get("client_id")).toBe(CLIENT_ID);
return jsonResponse({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 });
}),
);
const before = Date.now();
const credential = await kimiCodingOAuth.refresh(
{
type: "oauth",
access: "old-access",
refresh: "old-refresh",
expires: before,
},
new AbortController().signal,
);
expect(credential).toEqual({
type: "oauth",
access: "new-access",
refresh: "new-refresh",
expires: expect.any(Number),
});
expect(credential.expires).toBeGreaterThanOrEqual(before + 3600 * 1000);
await expect(kimiCodingOAuth.toAuth(credential)).resolves.toEqual({
headers: { Authorization: "Bearer new-access" },
});
});
it("retries refresh on 429 and fails unauthorized on invalid_grant", async () => {
vi.useFakeTimers();
// 429 once, then success.
let calls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (): Promise<Response> => {
calls += 1;
if (calls === 1) return jsonResponse({ error: "temporarily_unavailable" }, 429);
return jsonResponse({ access_token: "a", refresh_token: "r", expires_in: 60 });
}),
);
const refreshPromise = kimiCodingOAuth.refresh(
{
type: "oauth",
access: "old",
refresh: "old",
expires: 0,
},
new AbortController().signal,
);
await vi.advanceTimersByTimeAsync(1000);
await expect(refreshPromise).resolves.toMatchObject({ access: "a" });
expect(calls).toBe(2);
// invalid_grant is not retried.
vi.stubGlobal(
"fetch",
vi.fn(async (): Promise<Response> => jsonResponse({ error: "invalid_grant" }, 400)),
);
await expect(
kimiCodingOAuth.refresh(
{ type: "oauth", access: "old", refresh: "old", expires: 0 },
new AbortController().signal,
),
).rejects.toThrow("unauthorized");
});
});

View File

@@ -0,0 +1,67 @@
/**
* The Message types require `content` to always be present, but untyped
* callers (custom tools, hand-built histories, old session files) can violate
* that contract. `transformMessages` is the choke point before every provider
* request and is intentionally lax: it normalizes null/missing content to an
* empty array (issues #6259, #6276).
*/
import { describe, expect, it } from "vitest";
import { transformMessages } from "../src/api/transform-messages.ts";
import type { Message, Model } from "../src/types.ts";
// Text-only model so the image downgrade path (replaceImagesWithPlaceholder) runs,
// which was the primary crash site for null tool result content.
function makeTextOnlyModel(): Model<"openai-completions"> {
return {
id: "test-model",
name: "Test Model",
api: "openai-completions",
provider: "openai",
baseUrl: "https://example.invalid/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16000,
};
}
describe("lax message content handling", () => {
it("normalizes null/missing content to an empty array instead of crashing", () => {
const messages = [
{ role: "user", content: null, timestamp: Date.now() },
{
role: "assistant",
content: null,
api: "openai-completions",
provider: "openai",
model: "test-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "web_search",
isError: false,
timestamp: Date.now(),
},
] as unknown as Message[];
const result = transformMessages(messages, makeTextOnlyModel());
expect(result).toHaveLength(3);
for (const msg of result) {
expect(msg.content).toEqual([]);
}
});
});

View File

@@ -0,0 +1,113 @@
import { spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const aiEntryUrl = new URL("../src/index.ts", import.meta.url).href;
const compatEntryUrl = new URL("../src/compat.ts", import.meta.url).href;
const providersAllUrl = new URL("../src/providers/all.ts", import.meta.url).href;
const SDK_SPECIFIERS = ["@anthropic-ai/sdk", "openai", "@google/genai", "@aws-sdk/client-bedrock-runtime"] as const;
type ProbeResult = {
loadedSpecifiers: string[];
};
function runProbe(action: string): ProbeResult {
const script = `
import { registerHooks } from "node:module";
const targets = new Set(${JSON.stringify(SDK_SPECIFIERS)});
const loaded = [];
registerHooks({
resolve(specifier, context, nextResolve) {
if (targets.has(specifier)) {
loaded.push(specifier);
}
return nextResolve(specifier, context);
},
});
const mod = await import(${JSON.stringify(aiEntryUrl)});
${action}
console.log(JSON.stringify({ loadedSpecifiers: [...new Set(loaded)] }));
`;
const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], {
cwd: packageRoot,
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(`Probe failed (exit ${result.status})\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`);
}
const stdoutLines = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
const lastLine = stdoutLines.at(-1);
if (!lastLine) {
throw new Error(`Probe produced no output\nSTDERR:\n${result.stderr}`);
}
return JSON.parse(lastLine) as ProbeResult;
}
describe("lazy provider module loading", () => {
it("does not load provider SDKs when importing the root barrel", () => {
const result = runProbe("");
expect(result.loadedSpecifiers).toEqual([]);
});
it("does not load provider SDKs when building all builtin providers", () => {
const result = runProbe(`
const all = await import(${JSON.stringify(providersAllUrl)});
const models = all.builtinModels();
models.getModels();
`);
expect(result.loadedSpecifiers).toEqual([]);
});
it("does not load provider SDKs when importing the compat entrypoint", () => {
const result = runProbe(`
await import(${JSON.stringify(compatEntryUrl)});
`);
expect(result.loadedSpecifiers).toEqual([]);
});
it("loads only the Anthropic SDK when streaming through the lazy API wrapper", () => {
const result = runProbe(`
const compat = await import(${JSON.stringify(compatEntryUrl)});
const model = {
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
};
const context = { messages: [{ role: "user", content: "hi" }] };
await compat.anthropicMessagesApi().streamSimple(model, context).result();
`);
expect(result.loadedSpecifiers).toEqual(["@anthropic-ai/sdk"]);
});
it("loads only the Anthropic SDK when dispatching through streamSimple", () => {
const result = runProbe(`
const compat = await import(${JSON.stringify(compatEntryUrl)});
const model = compat.getModel("anthropic", "claude-sonnet-4-6");
const context = { messages: [{ role: "user", content: "hi" }] };
await compat.streamSimple(model, context).result();
`);
expect(result.loadedSpecifiers).toEqual(["@anthropic-ai/sdk"]);
});
});

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { streamSimple as streamSimpleOpenAICodexResponses } from "../src/api/openai-codex-responses.ts";
import { clampThinkingLevel, getModel, getSupportedThinkingLevels } from "../src/compat.ts";
import type { Context, Model } from "../src/types.ts";
function mockToken(): string {
const payload = Buffer.from(
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
"utf8",
).toString("base64");
return `aaa.${payload}.bbb`;
}
describe("max thinking level", () => {
it("is opt-in for ordinary reasoning models", () => {
const model: Model<"openai-completions"> = {
id: "ordinary-reasoning",
name: "Ordinary Reasoning",
api: "openai-completions",
provider: "test",
baseUrl: "https://example.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
expect(getSupportedThinkingLevels(model)).toEqual(["off", "minimal", "low", "medium", "high"]);
expect(clampThinkingLevel(model, "max")).toBe("high");
});
it.each(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"] as const)(
"exposes xhigh and max for openai-codex/%s",
(modelId) => {
const model = getModel("openai-codex", modelId);
expect(model).toBeDefined();
expect(model?.thinkingLevelMap).toMatchObject({ xhigh: "xhigh", max: "max" });
expect(getSupportedThinkingLevels(model!)).toEqual([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]);
},
);
it("supports a hole between high and max", () => {
const model: Model<"openai-completions"> = {
id: "high-and-max",
name: "High and Max",
api: "openai-completions",
provider: "test",
baseUrl: "https://example.com/v1",
reasoning: true,
thinkingLevelMap: { xhigh: null, max: "max" },
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
expect(getSupportedThinkingLevels(model)).toEqual(["off", "minimal", "low", "medium", "high", "max"]);
expect(clampThinkingLevel(model, "xhigh")).toBe("max");
});
it.each(["gpt-5.6-sol", "gpt-6-astra"] as const)("sends max to the Codex Responses API for %s", async (modelId) => {
const model = getModel("openai-codex", modelId)!;
const context: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
let payload: unknown;
await streamSimpleOpenAICodexResponses(model, context, {
apiKey: mockToken(),
reasoning: "max",
onPayload: (request) => {
payload = request;
throw new Error("payload captured");
},
}).result();
expect(payload).toMatchObject({ reasoning: { effort: "max", summary: "auto" } });
});
});

View File

@@ -0,0 +1,431 @@
import { arch, platform, release } from "node:os";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { stream as streamMistral } from "../src/api/mistral-conversations.ts";
import { getModel } from "../src/compat.ts";
import type { Context, FetchFunction, ProviderResponse } from "../src/types.ts";
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
function createSseResponse(events: unknown[], headers?: Record<string, string>): Response {
const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\r\n\r\n")}\r\n\r\ndata: [DONE]\r\n\r\n`;
return new Response(body, {
headers: { "content-type": "text/event-stream", ...headers },
});
}
function createBytewiseSseResponse(event: unknown): Response {
const bytes = new TextEncoder().encode(`data: ${JSON.stringify(event)}\r\n\r\ndata: [DONE]\r\n\r\n`);
return new Response(
new ReadableStream({
start(controller) {
for (const byte of bytes) controller.enqueue(Uint8Array.of(byte));
controller.close();
},
}),
{ headers: { "content-type": "text/event-stream" } },
);
}
function createTerminalEvent(finishReason = "stop") {
return {
id: "mistral-response-id",
model: "mistral-large-latest",
choices: [{ index: 0, finish_reason: finishReason, delta: {} }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
};
}
describe("Mistral HTTP transport", () => {
it("serializes SDK-style payloads to the Mistral wire format", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
systemPrompt: "Be precise",
messages: [
{
role: "user",
content: [
{ type: "text", text: "describe" },
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
],
timestamp: 1,
},
],
tools: [
{
name: "lookup",
description: "Look something up",
parameters: Type.Object({ query: Type.String() }),
},
],
};
let requestUrl: string | undefined;
let requestInit: RequestInit | undefined;
let callbackPayload: Record<string, unknown> | undefined;
let callbackResponse: ProviderResponse | undefined;
const fetch: FetchFunction = async (input, init) => {
requestUrl = String(input);
requestInit = init;
return createSseResponse([createTerminalEvent()], { "x-request-id": "request-1" });
};
const message = await streamMistral(model, context, {
apiKey: "secret",
fetch,
headers: { "x-custom": "value" },
maxTokens: 123,
promptMode: "reasoning",
reasoningEffort: "high",
toolChoice: { type: "function", function: { name: "lookup" } },
sessionId: "session-1",
onPayload: (payload) => {
callbackPayload = payload as Record<string, unknown>;
return {
...callbackPayload,
topP: 0.9,
randomSeed: 42,
responseFormat: {
type: "json_schema",
jsonSchema: {
name: "result",
schemaDefinition: {
type: "object",
properties: { maxTokens: { type: "number" } },
},
},
},
presencePenalty: 0.1,
frequencyPenalty: 0.2,
parallelToolCalls: true,
safePrompt: true,
};
},
onResponse: (response) => {
callbackResponse = response;
},
}).result();
expect(message.stopReason).toBe("stop");
expect(requestUrl).toBe("https://api.mistral.ai/v1/chat/completions");
const headers = new Headers(requestInit?.headers);
expect(headers.get("authorization")).toBe("Bearer secret");
expect(headers.get("accept")).toBe("text/event-stream");
expect(headers.get("x-affinity")).toBe("session-1");
expect(headers.get("x-custom")).toBe("value");
expect(headers.get("user-agent")).toBe(PI_USER_AGENT);
expect(callbackPayload?.maxTokens).toBe(123);
expect(callbackPayload?.promptMode).toBe("reasoning");
expect(callbackPayload?.promptCacheKey).toBe("session-1");
expect(callbackResponse).toEqual({
status: 200,
headers: { "content-type": "text/event-stream", "x-request-id": "request-1" },
});
const wirePayload = JSON.parse(String(requestInit?.body)) as Record<string, unknown>;
expect(wirePayload.max_tokens).toBe(123);
expect(wirePayload.prompt_mode).toBe("reasoning");
expect(wirePayload.reasoning_effort).toBe("high");
expect(wirePayload.tool_choice).toEqual({ type: "function", function: { name: "lookup" } });
expect(wirePayload.prompt_cache_key).toBe("session-1");
expect(wirePayload.top_p).toBe(0.9);
expect(wirePayload.random_seed).toBe(42);
expect(wirePayload.presence_penalty).toBe(0.1);
expect(wirePayload.frequency_penalty).toBe(0.2);
expect(wirePayload.parallel_tool_calls).toBe(true);
expect(wirePayload.safe_prompt).toBe(true);
expect(wirePayload.response_format).toEqual({
type: "json_schema",
json_schema: {
name: "result",
schema: {
type: "object",
properties: { maxTokens: { type: "number" } },
},
},
});
expect(wirePayload).not.toHaveProperty("maxTokens");
expect(wirePayload).not.toHaveProperty("promptMode");
expect(wirePayload).not.toHaveProperty("promptCacheKey");
expect(wirePayload.messages).toEqual([
{ role: "system", content: "Be precise" },
{
role: "user",
content: [
{ type: "text", text: "describe" },
{ type: "image_url", image_url: "data:image/png;base64,aGVsbG8=" },
],
},
]);
});
it("serializes assistant thinking, tool calls, and tool results for replay", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
messages: [
{
role: "assistant",
api: "mistral-conversations",
provider: "mistral",
model: model.id,
content: [
{ type: "thinking", thinking: "reason" },
{ type: "text", text: "answer" },
{ type: "toolCall", id: "abc123456", name: "lookup", arguments: { query: "pi" } },
],
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 1,
},
{
role: "toolResult",
toolCallId: "abc123456",
toolName: "lookup",
content: [
{ type: "text", text: "found" },
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
],
isError: false,
timestamp: 2,
},
],
};
let requestInit: RequestInit | undefined;
const fetch: FetchFunction = async (_input, init) => {
requestInit = init;
return createSseResponse([createTerminalEvent()]);
};
const message = await streamMistral(model, context, { apiKey: "test", fetch }).result();
expect(message.stopReason).toBe("stop");
const wirePayload = JSON.parse(String(requestInit?.body)) as { messages: unknown[] };
expect(wirePayload.messages).toEqual([
{
role: "assistant",
prefix: false,
content: [
{ type: "thinking", thinking: [{ type: "text", text: "reason" }] },
{ type: "text", text: "answer" },
],
tool_calls: [
{
id: "abc123456",
type: "function",
function: { name: "lookup", arguments: '{"query":"pi"}' },
index: 0,
},
],
},
{
role: "tool",
tool_call_id: "abc123456",
name: "lookup",
content: [
{ type: "text", text: "found" },
{ type: "image_url", image_url: "data:image/png;base64,aGVsbG8=" },
],
},
]);
});
it("parses native thinking, text, fragmented tool calls, and cached-token usage", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
const events = [
{
id: "response-1",
model: model.id,
choices: [
{
index: 0,
finish_reason: null,
delta: { content: [{ type: "thinking", thinking: [{ type: "text", text: "reason" }] }] },
},
],
},
{
id: "response-1",
model: model.id,
choices: [
{
index: 0,
finish_reason: null,
delta: { content: [{ type: "text", text: "answer" }] },
},
],
},
{
id: "response-1",
model: model.id,
choices: [
{
index: 0,
finish_reason: null,
delta: {
tool_calls: [
{
id: "abc123456",
index: 0,
function: { name: "lookup", arguments: '{"query":' },
},
],
},
},
],
},
{
id: "response-1",
model: model.id,
choices: [
{
index: 0,
finish_reason: "tool_calls",
delta: {
tool_calls: [
{
index: 0,
function: { name: "", arguments: '"pi"}' },
},
],
},
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 4,
total_tokens: 14,
prompt_tokens_details: { cached_tokens: 3 },
},
},
];
const fetch: FetchFunction = async () => createSseResponse(events);
const message = await streamMistral(model, context, { apiKey: "test", fetch }).result();
expect(message.stopReason).toBe("toolUse");
expect(message.rawStopReason).toBe("tool_calls");
expect(message.responseId).toBe("response-1");
expect(message.content).toEqual([
{ type: "thinking", thinking: "reason" },
{ type: "text", text: "answer" },
{ type: "toolCall", id: "abc123456", name: "lookup", arguments: { query: "pi" } },
]);
expect(message.usage).toMatchObject({ input: 7, output: 4, cacheRead: 3, cacheWrite: 0, totalTokens: 14 });
});
it("parses SSE and UTF-8 sequences split across transport chunks", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
const fetch: FetchFunction = async () =>
createBytewiseSseResponse({
id: "response-bytewise",
model: model.id,
choices: [{ index: 0, finish_reason: "stop", delta: { content: "héllo 🌍" } }],
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
});
const message = await streamMistral(model, context, { apiKey: "test", fetch }).result();
expect(message.stopReason).toBe("stop");
expect(message.content).toEqual([{ type: "text", text: "héllo 🌍" }]);
});
it("honors case-insensitive header overrides and explicit affinity suppression", async () => {
const model = {
...getModel("mistral", "mistral-large-latest"),
headers: { Authorization: "Bearer model-key", "X-Affinity": "model-affinity" },
};
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
let requestHeaders: Headers | undefined;
const fetch: FetchFunction = async (_input, init) => {
requestHeaders = new Headers(init?.headers);
return createSseResponse([createTerminalEvent()]);
};
await streamMistral(model, context, {
apiKey: "request-key",
fetch,
sessionId: "automatic-affinity",
headers: { authorization: null, "x-affinity": null, "User-Agent": "custom-agent" },
}).result();
expect(requestHeaders?.has("authorization")).toBe(false);
expect(requestHeaders?.has("x-affinity")).toBe(false);
expect(requestHeaders?.get("user-agent")).toBe("custom-agent");
});
it("aborts while waiting for an SSE chunk", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
const controller = new AbortController();
const fetch: FetchFunction = async () =>
new Response(
new ReadableStream({
start() {},
}),
{ headers: { "content-type": "text/event-stream" } },
);
const result = streamMistral(model, context, {
apiKey: "test",
fetch,
signal: controller.signal,
}).result();
controller.abort();
const message = await result;
expect(message.stopReason).toBe("aborted");
});
it("applies the request timeout while waiting for an SSE chunk", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
const fetch: FetchFunction = async () =>
new Response(
new ReadableStream({
start() {},
}),
{ headers: { "content-type": "text/event-stream" } },
);
const message = await streamMistral(model, context, {
apiKey: "test",
fetch,
timeoutMs: 5,
}).result();
expect(message.stopReason).toBe("error");
expect(message.errorMessage).toMatch(/timeout/i);
});
it("preserves HTTP status and response bodies in errors", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
const fetch: FetchFunction = async () =>
new Response('{"message":"blocked by gateway"}', { status: 403, statusText: "Forbidden" });
const message = await streamMistral(model, context, { apiKey: "test", fetch }).result();
expect(message.stopReason).toBe("error");
expect(message.errorMessage).toBe('Mistral API error (403): {"message":"blocked by gateway"}');
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { stream as streamMistral } from "../src/api/mistral-conversations.ts";
import { getModel } from "../src/compat.ts";
import type { Context, FetchFunction } from "../src/types.ts";
const model = getModel("mistral", "devstral-medium-latest");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
function createFetch(finishReason: string): FetchFunction {
return async () =>
new Response(
`data: ${JSON.stringify({
id: "mistral-response-id",
model: model.id,
choices: [
{
index: 0,
finish_reason: finishReason,
delta: {},
},
],
usage: {
prompt_tokens: 1,
completion_tokens: 0,
total_tokens: 1,
},
})}\n\ndata: [DONE]\n\n`,
{ headers: { "content-type": "text/event-stream" } },
);
}
describe("Mistral raw stop reasons", () => {
it("preserves raw Mistral finish reasons for successful stops", async () => {
const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("stop") }).result();
expect(message.stopReason).toBe("stop");
expect(message.rawStopReason).toBe("stop");
expect(message.errorMessage).toBeUndefined();
});
it("preserves raw Mistral finish reasons for provider error stops", async () => {
const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("error") }).result();
expect(message.stopReason).toBe("error");
expect(message.rawStopReason).toBe("error");
expect(message.errorMessage).toBe("Provider stopped with: error");
});
it("treats unknown Mistral finish reasons as provider error stops", async () => {
const message = await streamMistral(model, context, {
apiKey: "test",
fetch: createFetch("unmapped_error"),
}).result();
expect(message.stopReason).toBe("error");
expect(message.rawStopReason).toBe("unmapped_error");
expect(message.errorMessage).toBe("Provider stopped with: unmapped_error");
});
});

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import type { Context, Model, SimpleStreamOptions } from "../src/types.ts";
interface MistralPayload {
promptMode?: "reasoning";
reasoningEffort?: "none" | "high";
promptCacheKey?: string;
}
function makeContext(): Context {
return {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
}
async function capturePayload(
model: Model<"mistral-conversations">,
options?: SimpleStreamOptions,
): Promise<MistralPayload> {
let capturedPayload: MistralPayload | undefined;
const payloadCaptureModel: Model<"mistral-conversations"> = {
...model,
baseUrl: "http://127.0.0.1:9",
};
const stream = streamSimple(payloadCaptureModel, makeContext(), {
...options,
apiKey: "fake-key",
onPayload: (payload) => {
capturedPayload = payload as MistralPayload;
return payload;
},
});
await stream.result();
if (!capturedPayload) {
throw new Error("Expected payload to be captured before request failure");
}
return capturedPayload;
}
describe("Mistral reasoning mode selection", () => {
it("uses reasoning_effort for Mistral Small 4", async () => {
const payload = await capturePayload(getModel("mistral", "mistral-small-2603"), { reasoning: "medium" });
expect(payload.reasoningEffort).toBe("high");
expect(payload.promptMode).toBeUndefined();
});
it("omits reasoning controls for Mistral Small 4 when thinking is off", async () => {
const payload = await capturePayload(getModel("mistral", "mistral-small-2603"));
expect(payload.reasoningEffort).toBeUndefined();
expect(payload.promptMode).toBeUndefined();
});
it("uses prompt_mode for Magistral reasoning models", async () => {
const payload = await capturePayload(getModel("mistral", "magistral-medium-latest"), { reasoning: "medium" });
expect(payload.promptMode).toBe("reasoning");
expect(payload.reasoningEffort).toBeUndefined();
});
it("uses reasoning_effort for Mistral Medium 3.5", async () => {
const payload = await capturePayload(getModel("mistral", "mistral-medium-3.5"), { reasoning: "medium" });
expect(payload.reasoningEffort).toBe("high");
expect(payload.promptMode).toBeUndefined();
});
it("omits reasoning controls for Mistral Medium 3.5 when thinking is off", async () => {
const payload = await capturePayload(getModel("mistral", "mistral-medium-3.5"));
expect(payload.reasoningEffort).toBeUndefined();
expect(payload.promptMode).toBeUndefined();
});
it("uses the session id as prompt cache key", async () => {
const payload = await capturePayload(getModel("mistral", "mistral-large-latest"), {
sessionId: "session-123",
});
expect(payload.promptCacheKey).toBe("session-123");
});
it("omits prompt cache key when cache retention is disabled", async () => {
const payload = await capturePayload(getModel("mistral", "mistral-large-latest"), {
sessionId: "session-123",
cacheRetention: "none",
});
expect(payload.promptCacheKey).toBeUndefined();
});
});

View File

@@ -0,0 +1,63 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { complete, getModel } from "../src/compat.ts";
import type { Context, Model } from "../src/types.ts";
interface MistralToolPayload {
tools?: Array<{
type: "function";
function: {
name: string;
parameters: Record<string, unknown>;
strict?: boolean;
};
}>;
}
describe("Mistral tool schema serialization", () => {
it("strips TypeBox symbol keys before the SDK validates tool schemas", async () => {
const model: Model<"mistral-conversations"> = {
...getModel("mistral", "devstral-medium-latest"),
baseUrl: "http://127.0.0.1:9",
};
const parameters = Type.Object({
nested: Type.Object({
value: Type.String(),
}),
});
const context: Context = {
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
tools: [
{
name: "inspect_schema",
description: "Inspect the schema",
parameters,
constrainedSampling: { type: "json_schema", strict: "require" },
},
],
};
let capturedPayload: MistralToolPayload | undefined;
const response = await complete(model, context, {
apiKey: "fake-key",
onPayload: (payload) => {
capturedPayload = payload as MistralToolPayload;
return payload;
},
});
expect(capturedPayload?.tools).toHaveLength(1);
expect(capturedPayload?.tools?.[0]?.function.strict).toBe(true);
const payloadParameters = capturedPayload?.tools?.[0]?.function.parameters;
expect(payloadParameters).toBeDefined();
expect(Object.getOwnPropertySymbols(payloadParameters ?? {})).toHaveLength(0);
const properties = payloadParameters?.properties;
expect(properties).toBeTruthy();
expect(Object.getOwnPropertySymbols((properties as Record<string, unknown>) ?? {})).toHaveLength(0);
const nested = (properties as Record<string, unknown> | undefined)?.nested;
expect(nested).toBeTruthy();
expect(Object.getOwnPropertySymbols((nested as Record<string, unknown>) ?? {})).toHaveLength(0);
expect(response.stopReason).toBe("error");
expect(response.errorMessage).not.toContain("Input validation failed");
});
});

View File

@@ -0,0 +1,17 @@
import { expect, expectTypeOf, it } from "vitest";
import { GITHUB_COPILOT_MODELS } from "../src/providers/github-copilot.models.ts";
import { XAI_MODELS } from "../src/providers/xai.models.ts";
it("derives model API, ID, and provider literals from grouped model data", () => {
expectTypeOf(XAI_MODELS["grok-4.5"].api).toEqualTypeOf<"openai-responses">();
expectTypeOf(XAI_MODELS["grok-4.5"].id).toEqualTypeOf<"grok-4.5">();
expectTypeOf(XAI_MODELS["grok-4.5"].provider).toEqualTypeOf<"xai">();
expectTypeOf(XAI_MODELS["grok-4.6"].api).toEqualTypeOf<"openai-responses">();
expectTypeOf(XAI_MODELS["grok-4.6"].id).toEqualTypeOf<"grok-4.6">();
expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-responses">();
});
it("routes GitHub Copilot Grok 4.5 through the Responses API", () => {
expectTypeOf(GITHUB_COPILOT_MODELS["grok-4.5"].api).toEqualTypeOf<"openai-responses">();
expect(GITHUB_COPILOT_MODELS["grok-4.5"].api).toBe("openai-responses");
});

View File

@@ -0,0 +1,177 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
assertExactModelIds,
createModelDataManifest,
MODEL_DATA_MANIFEST_FILE,
MODEL_DATA_SCHEMA_VERSION,
type ModelDataStructure,
readModelDataStructure,
validateModelDataDirectory,
} from "../scripts/model-data.ts";
const GENERATED_AT = "2026-07-23T10:00:00.000Z";
const temporaryRoots: string[] = [];
afterEach(() => {
for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true });
});
function createFixture(): {
dataDir: string;
packageRoot: string;
structure: ModelDataStructure;
values: Record<string, unknown>;
} {
const packageRoot = mkdtempSync(join(tmpdir(), "pi-model-data-"));
temporaryRoots.push(packageRoot);
const providersDir = join(packageRoot, "src", "providers");
const dataDir = join(providersDir, "data");
mkdirSync(dataDir, { recursive: true });
writeFileSync(
join(packageRoot, "src", "models.generated.ts"),
'import { TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\n',
);
writeFileSync(
join(providersDir, "test-provider.models.ts"),
'import values from "./data/test-provider.json" with { type: "json" };\nimport { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";\n\nexport const TEST_PROVIDER_MODELS: ModelCatalog<typeof values, "test-provider"> =\n\tflattenModelCatalog("test-provider", values);\n',
);
const structure: ModelDataStructure = {
"test-provider": {
"model-a": "openai-completions",
},
};
const values: Record<string, unknown> = {
"model-a": {
id: "model-a",
name: "Model A",
api: "openai-completions",
provider: "test-provider",
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
};
writeFixtureData(dataDir, structure, values);
return { dataDir, packageRoot, structure, values };
}
function writeFixtureData(
dataDir: string,
structure: ModelDataStructure,
values: Record<string, unknown>,
manifestSchemaVersion = MODEL_DATA_SCHEMA_VERSION,
apiGroup = "openai-completions",
): void {
const filename = "test-provider.json";
const content = `${JSON.stringify({ [apiGroup]: values })}\n`;
writeFileSync(join(dataDir, filename), content);
const manifest = createModelDataManifest(structure, { [filename]: content }, GENERATED_AT);
manifest.schemaVersion = manifestSchemaVersion;
writeFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), `${JSON.stringify(manifest)}\n`);
}
describe("generated model data validation", () => {
it("rejects a missing upstream model from an exact generated allowlist", () => {
expect(() => assertExactModelIds("qwen-token-plan-individual", ["model-a", "model-b"], ["model-a"])).toThrow(
"qwen-token-plan-individual model IDs do not match (missing: model-b)",
);
});
it("rejects an unexpected model from an exact generated allowlist", () => {
expect(() => assertExactModelIds("test-provider", ["model-a"], ["model-a", "model-b"])).toThrow(
"test-provider model IDs do not match (extra: model-b)",
);
});
it("reads and validates API-grouped model data", () => {
const { dataDir, packageRoot, structure } = createFixture();
expect(readModelDataStructure(packageRoot)).toEqual(structure);
expect(() => validateModelDataDirectory(structure, dataDir)).not.toThrow();
});
it("rejects a missing model data directory", () => {
const { dataDir, structure } = createFixture();
rmSync(dataDir, { recursive: true });
expect(() => validateModelDataDirectory(structure, dataDir)).toThrow("does not exist");
});
it.each([
["id", "wrong-id", "has id"],
["provider", "wrong-provider", "has provider"],
["api", "anthropic-messages", "has api"],
] as const)("rejects a wrong model %s", (field, value, expectedMessage) => {
const fixture = createFixture();
const model = fixture.values["model-a"] as Record<string, unknown>;
model[field] = value;
writeFixtureData(fixture.dataDir, fixture.structure, fixture.values);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow(expectedMessage);
});
it("rejects a model in the wrong API group", () => {
const fixture = createFixture();
writeFixtureData(
fixture.dataDir,
fixture.structure,
fixture.values,
MODEL_DATA_SCHEMA_VERSION,
"anthropic-messages",
);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("grouped under API");
});
it("rejects duplicate model IDs across API groups", () => {
const fixture = createFixture();
const filename = "test-provider.json";
const content = `${JSON.stringify({
"openai-completions": fixture.values,
"anthropic-messages": fixture.values,
})}\n`;
writeFileSync(join(fixture.dataDir, filename), content);
const manifest = createModelDataManifest(fixture.structure, { [filename]: content }, GENERATED_AT);
writeFileSync(join(fixture.dataDir, MODEL_DATA_MANIFEST_FILE), `${JSON.stringify(manifest)}\n`);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("more than one API group");
});
it("rejects missing model IDs and stale file hashes", () => {
const fixture = createFixture();
writeFileSync(join(fixture.dataDir, "test-provider.json"), "{}\n");
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow(/manifest hash|model IDs/);
});
it("rejects incompatible schema and generation stamps", () => {
const fixture = createFixture();
writeFixtureData(fixture.dataDir, fixture.structure, fixture.values, MODEL_DATA_SCHEMA_VERSION + 1);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("model data schema");
const manifestPath = join(fixture.dataDir, MODEL_DATA_MANIFEST_FILE);
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
manifest.structureHash = "stale";
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("generation stamp");
});
it("rejects an invalid generation timestamp", () => {
const fixture = createFixture();
const manifestPath = join(fixture.dataDir, MODEL_DATA_MANIFEST_FILE);
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
manifest.generatedAt = "invalid";
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("generation timestamp");
});
it("rejects missing provider shards imported by the aggregator", () => {
const { packageRoot } = createFixture();
writeFileSync(
join(packageRoot, "src", "models.generated.ts"),
'import { TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\nimport { MISSING_MODELS } from "./providers/missing.models.ts";\n',
);
expect(() => readModelDataStructure(packageRoot)).toThrow("aggregator and provider shards do not match");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it } from "vitest";
import { resolveHttpProxyUrlForTarget, UNSUPPORTED_PROXY_PROTOCOL_MESSAGE } from "../src/utils/node-http-proxy.ts";
const PROXY_ENV_KEYS = [
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"all_proxy",
"npm_config_http_proxy",
"npm_config_https_proxy",
"npm_config_proxy",
"npm_config_no_proxy",
] as const;
const originalEnv = new Map<string, string | undefined>();
for (const key of PROXY_ENV_KEYS) {
originalEnv.set(key, process.env[key]);
}
function resetProxyEnv(): void {
for (const key of PROXY_ENV_KEYS) {
delete process.env[key];
}
}
afterEach(() => {
resetProxyEnv();
for (const [key, value] of originalEnv) {
if (value !== undefined) {
process.env[key] = value;
}
}
});
describe("node HTTP proxy resolution", () => {
it("respects NO_PROXY exclusions", () => {
resetProxyEnv();
process.env.HTTPS_PROXY = "http://proxy.example:8080";
process.env.NO_PROXY = "bedrock-runtime.us-east-1.amazonaws.com";
expect(resolveHttpProxyUrlForTarget("https://bedrock-runtime.us-east-1.amazonaws.com")).toBeUndefined();
});
it("resolves HTTP and HTTPS proxy URLs", () => {
resetProxyEnv();
process.env.HTTPS_PROXY = "http://proxy.example:8080";
expect(resolveHttpProxyUrlForTarget("https://bedrock-runtime.us-east-1.amazonaws.com")?.toString()).toBe(
"http://proxy.example:8080/",
);
});
it("prefers scoped proxy env aliases before process env aliases", () => {
resetProxyEnv();
process.env.https_proxy = "http://process-proxy.example:8080";
expect(
resolveHttpProxyUrlForTarget("https://bedrock-runtime.us-east-1.amazonaws.com", {
HTTPS_PROXY: "http://scoped-proxy.example:8080",
})?.toString(),
).toBe("http://scoped-proxy.example:8080/");
});
it("rejects SOCKS and PAC proxy URLs explicitly", () => {
resetProxyEnv();
process.env.HTTPS_PROXY = "socks5://proxy.example:1080";
expect(() => resolveHttpProxyUrlForTarget("https://bedrock-runtime.us-east-1.amazonaws.com")).toThrow(
UNSUPPORTED_PROXY_PROTOCOL_MESSAGE,
);
});
it("handles subdomain wildcards, IPv6, and ports in NO_PROXY", () => {
resetProxyEnv();
process.env.HTTPS_PROXY = "http://proxy.example:8080";
process.env.NO_PROXY = "example.com, .wildcard.org, *.star.net, ::1, [2001:db8::1], 127.0.0.1:8080";
expect(resolveHttpProxyUrlForTarget("https://example.com")).toBeUndefined();
expect(resolveHttpProxyUrlForTarget("https://api.example.com")).toBeUndefined();
expect(resolveHttpProxyUrlForTarget("https://wildcard.org")).toBeUndefined();
expect(resolveHttpProxyUrlForTarget("https://api.wildcard.org")).toBeUndefined();
expect(resolveHttpProxyUrlForTarget("https://star.net")).toBeUndefined();
expect(resolveHttpProxyUrlForTarget("https://api.star.net")).toBeUndefined();
expect(resolveHttpProxyUrlForTarget("https://notexample.com")?.toString()).toBe("http://proxy.example:8080/");
expect(resolveHttpProxyUrlForTarget("https://[::1]:80")).toBeUndefined();
expect(resolveHttpProxyUrlForTarget("https://[2001:db8::1]")).toBeUndefined();
expect(resolveHttpProxyUrlForTarget("https://127.0.0.1:8080")).toBeUndefined();
expect(resolveHttpProxyUrlForTarget("https://127.0.0.1:3000")?.toString()).toBe("http://proxy.example:8080/");
});
});

View File

@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
import { kimiCodingOAuth } from "../src/auth/oauth/kimi-coding.ts";
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
import { openRouterOAuth } from "../src/auth/oauth/openrouter.ts";
import { xaiOAuth } from "../src/auth/oauth/xai.ts";
import { createModels } from "../src/models.ts";
import * as extensionOAuthCompatibility from "../src/oauth.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
import { githubCopilotProvider } from "../src/providers/github-copilot.ts";
const neverAbortedSignal = new AbortController().signal;
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
describe.sequential("OAuthAuth adapters", () => {
it("keeps the extension OAuth barrel free of built-in flow implementations", () => {
expect(extensionOAuthCompatibility).not.toHaveProperty("loginAnthropic");
expect(extensionOAuthCompatibility).not.toHaveProperty("anthropicOAuth");
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("identifies only subscription-backed OAuth flows as subscriptions", () => {
for (const oauth of [anthropicOAuth, openaiCodexOAuth, githubCopilotOAuth, kimiCodingOAuth, xaiOAuth]) {
expect(oauth.isSubscription).toBe(true);
}
expect(openRouterOAuth.isSubscription).not.toBe(true);
});
it("anthropic toAuth derives the api key from the access token", async () => {
const auth = await anthropicOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 });
expect(auth).toEqual({ apiKey: "token" });
});
it("openai-codex toAuth derives the api key from the access token", async () => {
const auth = await openaiCodexOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 });
expect(auth).toEqual({ apiKey: "token" });
});
it("openrouter derives the api key and keeps the permanent credential on refresh", async () => {
const credential = { type: "oauth" as const, access: "token", refresh: "", expires: Number.MAX_SAFE_INTEGER };
expect(await openRouterOAuth.toAuth(credential)).toEqual({ apiKey: "token" });
expect(await openRouterOAuth.refresh(credential, neverAbortedSignal)).toBe(credential);
});
it("xAI toAuth derives the api key from the access token", async () => {
const auth = await xaiOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 });
expect(auth).toEqual({ apiKey: "token" });
});
it("github-copilot toAuth derives baseUrl from the token proxy endpoint", async () => {
const access = "tid=abc;exp=123;proxy-ep=proxy.enterprise.example;rest";
const auth = await githubCopilotOAuth.toAuth({ type: "oauth", access, refresh: "r", expires: 0 });
expect(auth).toEqual({ apiKey: access, baseUrl: "https://api.enterprise.example" });
});
it("github-copilot toAuth falls back to the enterprise domain, then the individual endpoint", async () => {
const enterprise = await githubCopilotOAuth.toAuth({
type: "oauth",
access: "no-proxy-ep",
refresh: "r",
expires: 0,
enterpriseUrl: "https://company.ghe.com",
});
expect(enterprise.baseUrl).toBe("https://copilot-api.company.ghe.com");
const individual = await githubCopilotOAuth.toAuth({
type: "oauth",
access: "no-proxy-ep",
refresh: "r",
expires: 0,
});
expect(individual.baseUrl).toBe("https://api.individual.githubcopilot.com");
});
it("anthropic refresh exchanges the refresh token and returns a typed credential", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
jsonResponse({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 }),
),
);
const refreshed = await anthropicOAuth.refresh(
{ type: "oauth", access: "old", refresh: "old-r", expires: 0 },
neverAbortedSignal,
);
expect(refreshed.type).toBe("oauth");
expect(refreshed.access).toBe("new-access");
expect(refreshed.refresh).toBe("new-refresh");
expect(refreshed.expires).toBeGreaterThan(Date.now());
});
it("github-copilot refresh preserves the enterprise domain", async () => {
const fetchedUrls: string[] = [];
const fetchMock = vi.fn(async (input: unknown) => {
const url = typeof input === "string" ? input : String(input);
fetchedUrls.push(url);
if (url.endsWith("/models")) {
return jsonResponse({ data: [] });
}
return jsonResponse({ token: "new-token", expires_at: 9999999999 });
});
vi.stubGlobal("fetch", fetchMock);
const refreshed = await githubCopilotOAuth.refresh(
{
type: "oauth",
access: "old",
refresh: "gh-token",
expires: 0,
enterpriseUrl: "company.ghe.com",
},
neverAbortedSignal,
);
expect(refreshed.access).toBe("new-token");
expect(refreshed.enterpriseUrl).toBe("company.ghe.com");
expect(fetchedUrls[0]).toContain("api.company.ghe.com");
});
});
describe("OAuth through Models.getAuth (lazy load chain)", () => {
it("resolves stored anthropic oauth credentials via the lazy flow import", async () => {
const credentials = new InMemoryCredentialStore();
await credentials.modify("anthropic", async () => ({
type: "oauth",
access: "oauth-access-token",
refresh: "r",
// Keep this beyond getAuth()'s refresh window.
expires: Date.now() + 10 * 60_000,
}));
const models = createModels({ credentials });
models.setProvider(anthropicProvider());
const model = models.getModels("anthropic")[0];
const result = await models.getAuth(model.provider);
expect(result?.auth.apiKey).toBe("oauth-access-token");
expect(result?.source).toBe("OAuth");
});
it("resolves stored github-copilot oauth credentials including per-credential baseUrl", async () => {
const access = "tid=abc;exp=123;proxy-ep=proxy.business.githubcopilot.com;rest";
const credentials = new InMemoryCredentialStore();
await credentials.modify("github-copilot", async () => ({
type: "oauth",
access,
refresh: "r",
// Keep this beyond getAuth()'s refresh window.
expires: Date.now() + 10 * 60_000,
}));
const models = createModels({ credentials });
models.setProvider(githubCopilotProvider());
const model = models.getModels("github-copilot")[0];
const result = await models.getAuth(model.provider);
expect(result?.auth.apiKey).toBe(access);
expect(result?.auth.baseUrl).toBe("https://api.business.githubcopilot.com");
});
});

View File

@@ -0,0 +1,145 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { pollOAuthDeviceCodeFlow } from "../src/auth/oauth/device-code.ts";
const neverAbortedSignal = new AbortController().signal;
describe("OAuth device-code polling", () => {
afterEach(() => {
vi.useRealTimers();
});
it("polls immediately and returns the completed value", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const pollTimes: number[] = [];
const poll = vi.fn(async () => {
pollTimes.push(Date.now());
return pollTimes.length === 1
? { status: "pending" as const }
: { status: "complete" as const, value: "token" };
});
const resultPromise = pollOAuthDeviceCodeFlow({
intervalSeconds: 2,
expiresInSeconds: 30,
poll,
signal: neverAbortedSignal,
});
await vi.advanceTimersByTimeAsync(0);
expect(pollTimes).toEqual([new Date("2026-03-09T00:00:00Z").getTime()]);
await vi.advanceTimersByTimeAsync(1999);
expect(pollTimes).toEqual([new Date("2026-03-09T00:00:00Z").getTime()]);
await vi.advanceTimersByTimeAsync(1);
await expect(resultPromise).resolves.toBe("token");
expect(pollTimes).toEqual([
new Date("2026-03-09T00:00:00Z").getTime(),
new Date("2026-03-09T00:00:02Z").getTime(),
]);
});
it("can wait before the first poll", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const pollTimes: number[] = [];
const resultPromise = pollOAuthDeviceCodeFlow({
intervalSeconds: 2,
expiresInSeconds: 30,
waitBeforeFirstPoll: true,
poll: async () => {
pollTimes.push(Date.now());
return { status: "complete" as const, value: "token" };
},
signal: neverAbortedSignal,
});
await vi.advanceTimersByTimeAsync(1999);
expect(pollTimes).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
await expect(resultPromise).resolves.toBe("token");
expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]);
});
it("increases the interval by 5 seconds after slow_down without a server interval", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const startTime = Date.now();
const pollTimes: number[] = [];
const results = [{ status: "slow_down" as const }, { status: "complete" as const, value: "token" }];
const resultPromise = pollOAuthDeviceCodeFlow({
intervalSeconds: 2,
expiresInSeconds: 900,
poll: async () => {
pollTimes.push(Date.now());
const result = results.shift();
if (!result) throw new Error("Unexpected extra poll");
return result;
},
signal: neverAbortedSignal,
});
await vi.advanceTimersByTimeAsync(0);
expect(pollTimes).toEqual([startTime]);
await vi.advanceTimersByTimeAsync(6999);
expect(pollTimes).toEqual([startTime]);
await vi.advanceTimersByTimeAsync(1);
await expect(resultPromise).resolves.toBe("token");
expect(pollTimes).toEqual([startTime, startTime + 7000]);
});
it("honors a server-provided slow_down interval", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const startTime = Date.now();
const pollTimes: number[] = [];
const results = [
{ status: "slow_down" as const, intervalSeconds: 30 },
{ status: "complete" as const, value: "token" },
];
const resultPromise = pollOAuthDeviceCodeFlow({
intervalSeconds: 2,
expiresInSeconds: 900,
poll: async () => {
pollTimes.push(Date.now());
const result = results.shift();
if (!result) throw new Error("Unexpected extra poll");
return result;
},
signal: neverAbortedSignal,
});
await vi.advanceTimersByTimeAsync(0);
expect(pollTimes).toEqual([startTime]);
await vi.advanceTimersByTimeAsync(29999);
expect(pollTimes).toEqual([startTime]);
await vi.advanceTimersByTimeAsync(1);
await expect(resultPromise).resolves.toBe("token");
expect(pollTimes).toEqual([startTime, startTime + 30000]);
});
it("cancels an in-flight wait", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const resultPromise = pollOAuthDeviceCodeFlow({
intervalSeconds: 5,
expiresInSeconds: 30,
poll: async () => ({ status: "pending" }),
signal: controller.signal,
});
controller.abort();
await expect(resultPromise).rejects.toThrow("Login cancelled");
});
});

85
packages/ai/test/oauth.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Test helper for resolving API keys from ~/.pi/agent/auth.json
*
* Supports both API key and OAuth credentials.
* OAuth tokens are automatically refreshed if expired and saved back to auth.json.
*/
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join } from "path";
import type { OAuthCredentials } from "../src/auth/types.ts";
import { builtinProviders } from "../src/providers/all.ts";
const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
type ApiKeyCredential = {
type: "api_key";
key: string;
};
type OAuthCredentialEntry = {
type: "oauth";
} & OAuthCredentials;
type AuthCredential = ApiKeyCredential | OAuthCredentialEntry;
type AuthStorage = Record<string, AuthCredential>;
function loadAuthStorage(): AuthStorage {
if (!existsSync(AUTH_PATH)) {
return {};
}
try {
const content = readFileSync(AUTH_PATH, "utf-8");
return JSON.parse(content);
} catch {
return {};
}
}
function saveAuthStorage(storage: AuthStorage): void {
const configDir = dirname(AUTH_PATH);
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
writeFileSync(AUTH_PATH, JSON.stringify(storage, null, 2), "utf-8");
chmodSync(AUTH_PATH, 0o600);
}
/**
* Resolve API key for a provider from ~/.pi/agent/auth.json
*
* For API key credentials, returns the key directly.
* For OAuth credentials, returns the access token (refreshing if expired and saving back).
*
*/
export async function resolveApiKey(provider: string): Promise<string | undefined> {
const storage = loadAuthStorage();
const entry = storage[provider];
if (!entry) return undefined;
if (entry.type === "api_key") {
return entry.key;
}
if (entry.type === "oauth") {
const oauth = builtinProviders().find((candidate) => candidate.id === provider)?.auth.oauth;
if (!oauth) return undefined;
let credential = entry;
try {
if (Date.now() >= credential.expires) {
credential = await oauth.refresh(credential, new AbortController().signal);
}
} catch (error) {
console.log(JSON.stringify(error));
return undefined;
}
storage[provider] = credential;
saveAuthStorage(storage);
return (await oauth.toAuth(credential)).apiKey;
}
return undefined;
}

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { complete, getModel } from "../src/compat.ts";
import type { Context } from "../src/types.ts";
import { resolveApiKey } from "./oauth.ts";
const codexToken = await resolveApiKey("openai-codex");
describe("openai-codex cache affinity e2e", () => {
it.skipIf(!codexToken)("handles SSE requests with aligned cache-affinity identifiers", async () => {
const model = getModel("openai-codex", "gpt-5.5");
const sessionId = "0195d6e4-4cf9-7f44-a2d8-f8f7f49ee9d3";
const context: Context = {
systemPrompt: "You are a helpful assistant. Reply exactly as requested.",
messages: [
{
role: "user",
content: "Reply with exactly: cache affinity e2e success",
timestamp: Date.now(),
},
],
};
const response = await complete(model, context, {
apiKey: codexToken,
sessionId,
transport: "sse",
});
expect(response.stopReason, response.errorMessage).not.toBe("error");
expect(response.errorMessage).toBeUndefined();
expect(response.content.map((block) => (block.type === "text" ? block.text : "")).join("")).toContain(
"cache affinity e2e success",
);
});
});

View File

@@ -0,0 +1,486 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
const neverAbortedSignal = new AbortController().signal;
function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function getUrl(input: unknown): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
if (input instanceof Request) return input.url;
throw new Error(`Unsupported fetch input: ${String(input)}`);
}
function createAccessToken(accountId: string): string {
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64");
const payload = Buffer.from(
JSON.stringify({
"https://api.openai.com/auth": {
chatgpt_account_id: accountId,
},
}),
).toString("base64");
return `${header}.${payload}.signature`;
}
function deviceAuthPendingResponse(): Response {
return jsonResponse(
{
error: {
message: "Device authorization is pending. Please try again.",
type: "invalid_request_error",
param: null,
code: "deviceauth_authorization_pending",
},
},
403,
);
}
function loginOpenAICodexDeviceCodeForTest(options: {
onDeviceCode(info: {
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}): void;
signal?: AbortSignal;
}) {
return openaiCodexOAuth.login({
signal: options.signal ?? neverAbortedSignal,
prompt: async (prompt) => {
if (prompt.type !== "select") throw new Error(`Unexpected prompt: ${prompt.type}`);
return "device_code";
},
notify: (event) => {
if (event.type === "device_code") {
const { type: _, ...info } = event;
options.onDeviceCode(info);
}
},
});
}
describe("OpenAI Codex OAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("logs in with the OpenAI Codex device code flow", async () => {
vi.useFakeTimers();
const startTime = new Date("2026-05-20T00:00:00Z");
vi.setSystemTime(startTime);
const accessToken = createAccessToken("account-123");
const deviceInfos: Array<{
userCode: string;
verificationUri: string;
instructions?: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}> = [];
const pollTimes: number[] = [];
const pollResponses = [
deviceAuthPendingResponse(),
jsonResponse({
authorization_code: "oauth-code",
code_challenge: "device-code-challenge",
code_verifier: "device-code-verifier",
}),
];
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
const url = getUrl(input);
if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") {
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({ "Content-Type": "application/json" });
expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" });
return jsonResponse({
device_auth_id: "device-auth-id",
user_code: "ABCD-1234",
interval: "5",
});
}
if (url === "https://auth.openai.com/api/accounts/deviceauth/token") {
pollTimes.push(Date.now());
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({ "Content-Type": "application/json" });
expect(JSON.parse(String(init?.body))).toEqual({
device_auth_id: "device-auth-id",
user_code: "ABCD-1234",
});
const response = pollResponses.shift();
if (!response) {
throw new Error("Unexpected extra device auth poll");
}
return response;
}
if (url === "https://auth.openai.com/oauth/token") {
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({ "Content-Type": "application/x-www-form-urlencoded" });
const params = new URLSearchParams(String(init?.body));
expect(params.get("grant_type")).toBe("authorization_code");
expect(params.get("client_id")).toBe("app_EMoamEEZ73f0CkXaXp7hrann");
expect(params.get("code")).toBe("oauth-code");
expect(params.get("redirect_uri")).toBe("https://auth.openai.com/deviceauth/callback");
expect(params.get("code_verifier")).toBe("device-code-verifier");
return jsonResponse({
access_token: accessToken,
refresh_token: "refresh-token",
expires_in: 3600,
});
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
onDeviceCode: (info) => deviceInfos.push(info),
});
for (let i = 0; i < 5 && pollTimes.length === 0; i++) {
await vi.advanceTimersByTimeAsync(0);
}
expect(deviceInfos).toEqual([
{
userCode: "ABCD-1234",
verificationUri: "https://auth.openai.com/codex/device",
intervalSeconds: 5,
expiresInSeconds: 900,
},
]);
expect(pollTimes).toEqual([startTime.getTime()]);
await vi.advanceTimersByTimeAsync(4999);
expect(pollTimes).toEqual([startTime.getTime()]);
await vi.advanceTimersByTimeAsync(1);
await expect(credentialsPromise).resolves.toMatchObject({
access: accessToken,
refresh: "refresh-token",
expires: startTime.getTime() + 5000 + 3600 * 1000,
accountId: "account-123",
});
expect(pollTimes).toEqual([startTime.getTime(), startTime.getTime() + 5000]);
});
it("offers browser login first and uses the selected OpenAI Codex device code flow", async () => {
const accessToken = createAccessToken("account-456");
const selectPrompts: Array<{
message: string;
options: readonly { id: string; label: string }[];
}> = [];
const deviceInfos: Array<{
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
const url = getUrl(input);
if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") {
expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" });
return jsonResponse({
device_auth_id: "device-auth-id",
user_code: "WXYZ-7890",
interval: "5",
});
}
if (url === "https://auth.openai.com/api/accounts/deviceauth/token") {
return jsonResponse({
authorization_code: "oauth-code",
code_challenge: "device-code-challenge",
code_verifier: "device-code-verifier",
});
}
if (url === "https://auth.openai.com/oauth/token") {
return jsonResponse({
access_token: accessToken,
refresh_token: "refresh-token",
expires_in: 3600,
});
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
await expect(
openaiCodexOAuth.login({
signal: neverAbortedSignal,
prompt: async (prompt) => {
if (prompt.type !== "select") throw new Error("Text prompt should not be used");
selectPrompts.push(prompt);
return "device_code";
},
notify: (event) => {
if (event.type === "auth_url") throw new Error("Browser login should not start");
if (event.type === "device_code") {
const { type: _, ...info } = event;
deviceInfos.push(info);
}
},
}),
).resolves.toMatchObject({
type: "oauth",
access: accessToken,
refresh: "refresh-token",
accountId: "account-456",
});
expect(selectPrompts).toEqual([
{
type: "select",
message: "Select OpenAI Codex login method:",
options: [
{ id: "browser", label: "Browser login (default)" },
{ id: "device_code", label: "Device code login (headless)" },
],
},
]);
expect(deviceInfos).toEqual([
{
userCode: "WXYZ-7890",
verificationUri: "https://auth.openai.com/codex/device",
intervalSeconds: 5,
expiresInSeconds: 900,
},
]);
});
it("cancels when OpenAI Codex login method selection is cancelled", async () => {
await expect(
openaiCodexOAuth.login({
signal: neverAbortedSignal,
prompt: async () => {
throw new Error("Login cancelled");
},
notify: () => {},
}),
).rejects.toThrow("Login cancelled");
});
it("cancels the OpenAI Codex device code flow while waiting", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const pollTimes: number[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
const url = getUrl(input);
if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") {
expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" });
return jsonResponse({
device_auth_id: "device-auth-id",
user_code: "ABCD-1234",
interval: "5",
});
}
if (url === "https://auth.openai.com/api/accounts/deviceauth/token") {
pollTimes.push(Date.now());
return deviceAuthPendingResponse();
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
onDeviceCode: () => {},
signal: controller.signal,
});
const rejectionPromise = credentialsPromise.then(
() => new Error("Expected login to fail"),
(error: unknown) => error,
);
for (let i = 0; i < 5 && pollTimes.length === 0; i++) {
await vi.advanceTimersByTimeAsync(0);
}
expect(pollTimes).toHaveLength(1);
controller.abort();
const rejection = await rejectionPromise;
expect(rejection).toBeInstanceOf(Error);
expect((rejection as Error).message).toBe("Login cancelled");
});
it("times out the OpenAI Codex device code flow after 15 minutes", async () => {
vi.useFakeTimers();
const pollTimes: number[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
const url = getUrl(input);
if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") {
expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" });
return jsonResponse({
device_auth_id: "device-auth-id",
user_code: "ABCD-1234",
interval: "60",
});
}
if (url === "https://auth.openai.com/api/accounts/deviceauth/token") {
pollTimes.push(Date.now());
return deviceAuthPendingResponse();
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
onDeviceCode: () => {},
});
const rejectionPromise = credentialsPromise.then(
() => new Error("Expected login to fail"),
(error: unknown) => error,
);
for (let i = 0; i < 5 && pollTimes.length === 0; i++) {
await vi.advanceTimersByTimeAsync(0);
}
expect(pollTimes).toHaveLength(1);
await vi.advanceTimersByTimeAsync(15 * 60 * 1000);
const rejection = await rejectionPromise;
expect(rejection).toBeInstanceOf(Error);
expect((rejection as Error).message).toBe("Device flow timed out");
});
it("treats OpenAI Codex device auth 403 and 404 responses as pending", async () => {
vi.useFakeTimers();
const accessToken = createAccessToken("account-403-404");
const pollTimes: number[] = [];
const pollResponses = [
jsonResponse({ error: "access_denied", error_description: "denied" }, 403),
new Response("not ready", { status: 404, headers: { "Content-Type": "text/plain" } }),
jsonResponse({
authorization_code: "oauth-code",
code_challenge: "device-code-challenge",
code_verifier: "device-code-verifier",
}),
];
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") {
return jsonResponse({
device_auth_id: "device-auth-id",
user_code: "ABCD-1234",
interval: "1",
});
}
if (url === "https://auth.openai.com/api/accounts/deviceauth/token") {
pollTimes.push(Date.now());
const response = pollResponses.shift();
if (!response) {
throw new Error("Unexpected extra device auth poll");
}
return response;
}
if (url === "https://auth.openai.com/oauth/token") {
return jsonResponse({
access_token: accessToken,
refresh_token: "refresh-token",
expires_in: 3600,
});
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
onDeviceCode: () => {},
});
for (let i = 0; i < 5 && pollTimes.length === 0; i++) {
await vi.advanceTimersByTimeAsync(0);
}
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(1000);
await expect(credentialsPromise).resolves.toMatchObject({
access: accessToken,
refresh: "refresh-token",
accountId: "account-403-404",
});
expect(pollTimes).toHaveLength(3);
});
it("includes the response body in OpenAI Codex device auth poll failures", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") {
return jsonResponse({
device_auth_id: "device-auth-id",
user_code: "ABCD-1234",
interval: "5",
});
}
if (url === "https://auth.openai.com/api/accounts/deviceauth/token") {
return jsonResponse({ error: "server_error", error_description: "try again later" }, 500);
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
await expect(
loginOpenAICodexDeviceCodeForTest({
onDeviceCode: () => {},
}),
).rejects.toThrow(
'OpenAI Codex device auth failed with status 500: {"error":"server_error","error_description":"try again later"}',
);
});
it("does not write token refresh failures to stderr", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
vi.stubGlobal(
"fetch",
vi.fn(async (): Promise<Response> => {
return new Response(
JSON.stringify({
error: {
message: "Could not validate your token. Please try signing in again.",
type: "invalid_request_error",
},
}),
{ status: 401, statusText: "Unauthorized", headers: { "Content-Type": "application/json" } },
);
}),
);
await expect(
openaiCodexOAuth.refresh(
{
type: "oauth",
access: "invalid-access-token",
refresh: "invalid-refresh-token",
expires: 0,
},
neverAbortedSignal,
),
).rejects.toThrow(/OpenAI Codex token refresh failed \(401\).*Could not validate your token/);
expect(consoleError).not.toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,231 @@
import { Type } from "typebox";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import { getModel } from "../src/compat.ts";
import type { Message, Model } from "../src/types.ts";
interface CacheControl {
type: "ephemeral";
ttl?: string;
}
interface TextPart {
type: "text";
text: string;
cache_control?: CacheControl;
}
interface ToolWithCacheControl {
type: string;
cache_control?: CacheControl;
}
interface CapturedParams {
messages: Array<{
role: string;
content: string | TextPart[] | null;
}>;
tools?: ToolWithCacheControl[];
}
const mockState = vi.hoisted(() => ({
lastParams: undefined as CapturedParams | undefined,
}));
vi.mock("openai", () => {
class FakeOpenAI {
chat = {
completions: {
create: (params: CapturedParams) => {
mockState.lastParams = params;
const stream = {
async *[Symbol.asyncIterator]() {
yield {
id: "chatcmpl-test",
choices: [{ delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
};
},
};
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
withResponse: () => Promise<{
data: typeof stream;
response: { status: number; headers: Headers };
}>;
};
promise.withResponse = async () => ({
data: stream,
response: { status: 200, headers: new Headers() },
});
return promise;
},
},
};
}
return { default: FakeOpenAI };
});
async function capturePayload(
model: Model<"openai-completions">,
options?: { cacheRetention?: "none" | "short" | "long" },
messages?: Message[],
): Promise<CapturedParams> {
const timestamp = Date.now();
await streamOpenAICompletions(
model,
{
systemPrompt: "System prompt",
messages: messages ?? [{ role: "user", content: "Hello", timestamp }],
tools: [
{
name: "read",
description: "Read a file",
parameters: Type.Object({
path: Type.String(),
}),
},
],
},
{ apiKey: "test-key", ...options },
).result();
if (!mockState.lastParams) {
throw new Error("Expected payload to be captured");
}
return mockState.lastParams;
}
function getInstructionMessage(params: CapturedParams) {
return params.messages.find((message) => message.role === "system" || message.role === "developer");
}
function expectAnthropicCacheMarkers(params: CapturedParams): void {
const instructionMessage = getInstructionMessage(params);
expect(instructionMessage).toBeDefined();
expect(Array.isArray(instructionMessage?.content)).toBe(true);
expect((instructionMessage?.content as TextPart[])[0]?.cache_control).toEqual({ type: "ephemeral" });
expect(params.tools).toHaveLength(1);
expect(params.tools?.[0]?.cache_control).toEqual({ type: "ephemeral" });
const lastMessage = params.messages[params.messages.length - 1];
expect(lastMessage.role).toBe("user");
expect(Array.isArray(lastMessage.content)).toBe(true);
expect((lastMessage.content as TextPart[])[0]?.cache_control).toEqual({ type: "ephemeral" });
}
describe("openai-completions cacheControlFormat", () => {
beforeEach(() => {
mockState.lastParams = undefined;
});
it("applies Anthropic-style cache markers when model compat enables them", async () => {
const model: Model<"openai-completions"> = {
id: "custom-qwen",
name: "Custom Qwen",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://example.com/v1",
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 32000,
compat: {
cacheControlFormat: "anthropic",
},
};
const params = await capturePayload(model);
expectAnthropicCacheMarkers(params);
});
it("preserves Anthropic-style cache markers for OpenRouter Anthropic batch aliases", async () => {
const model = getModel("openrouter", "anthropic/claude-fable-5.1:batch");
const params = await capturePayload(model);
expectAnthropicCacheMarkers(params);
});
it("moves the conversation cache marker to a tool result", async () => {
const model = getModel("openrouter", "anthropic/claude-fable-5.1:batch");
const timestamp = Date.now();
const params = await capturePayload(model, undefined, [
{ role: "user", content: "Read the file", timestamp },
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" } }],
api: "openai-completions",
provider: "openrouter",
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp,
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: "file contents" }],
isError: false,
timestamp,
},
]);
const userMessage = params.messages.find((message) => message.role === "user");
expect(userMessage?.content).toBe("Read the file");
const toolMessage = params.messages[params.messages.length - 1];
expect(toolMessage.role).toBe("tool");
expect(Array.isArray(toolMessage.content)).toBe(true);
expect((toolMessage.content as TextPart[])[0]?.cache_control).toEqual({ type: "ephemeral" });
});
it("omits Anthropic-style cache markers when cacheRetention is none", async () => {
const model: Model<"openai-completions"> = {
id: "custom-qwen",
name: "Custom Qwen",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://example.com/v1",
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 32000,
compat: {
cacheControlFormat: "anthropic",
},
};
const params = await capturePayload(model, { cacheRetention: "none" });
const instructionMessage = getInstructionMessage(params);
expect(Array.isArray(instructionMessage?.content)).toBe(false);
expect(params.tools?.[0]?.cache_control).toBeUndefined();
expect(typeof params.messages[params.messages.length - 1]?.content).toBe("string");
});
});

View File

@@ -0,0 +1,304 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
// Empty tools arrays must NOT be serialized as `tools: []` — some OpenAI-compatible
// backends (e.g. DashScope / Aliyun Qwen via compatible-mode) reject the request with
// `"[] is too short - 'tools'"` (HTTP 400) when `--no-tools` produces an empty array.
// Regression for https://github.com/earendil-works/pi-mono/issues/<issue-number>
const mockState = vi.hoisted(() => ({
lastParams: undefined as unknown,
lastClientOptions: undefined as unknown,
}));
vi.mock("openai", () => {
class FakeOpenAI {
constructor(options: unknown) {
mockState.lastClientOptions = options;
}
chat = {
completions: {
create: (params: unknown) => {
mockState.lastParams = params;
const stream = {
async *[Symbol.asyncIterator]() {
yield {
choices: [{ delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
};
},
};
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
withResponse: () => Promise<{
data: typeof stream;
response: { status: number; headers: Headers };
}>;
};
promise.withResponse = async () => ({
data: stream,
response: { status: 200, headers: new Headers() },
});
return promise;
},
},
};
}
return { default: FakeOpenAI };
});
describe("openai-completions empty tools handling", () => {
beforeEach(() => {
mockState.lastParams = undefined;
mockState.lastClientOptions = undefined;
});
it("omits tools field when context.tools is an empty array", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions" } as const;
await streamSimple(
model,
{
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
tools: [],
},
{ apiKey: "test" },
).result();
const params = mockState.lastParams as { tools?: unknown };
expect("tools" in (params as object)).toBe(false);
});
it("omits tools field when context.tools is undefined", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions" } as const;
await streamSimple(
model,
{
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ apiKey: "test" },
).result();
const params = mockState.lastParams as { tools?: unknown };
expect("tools" in (params as object)).toBe(false);
});
it("sends default maxTokens", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions" } as const;
await streamSimple(
model,
{
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ apiKey: "test" },
).result();
const params = mockState.lastParams as { max_tokens?: number; max_completion_tokens?: number };
expect(params.max_tokens).toBeUndefined();
expect(params.max_completion_tokens).toBe(model.maxTokens);
});
it("sends explicit maxTokens", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions" } as const;
await streamSimple(
model,
{
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ apiKey: "test", maxTokens: 1234 },
).result();
const params = mockState.lastParams as { max_tokens?: number; max_completion_tokens?: number };
expect(params.max_tokens).toBeUndefined();
expect(params.max_completion_tokens).toBe(1234);
});
it("clamps default maxTokens to remaining context", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions", contextWindow: 10000, maxTokens: 8000 } as const;
await streamSimple(
model,
{
messages: [{ role: "user", content: "x".repeat(8000), timestamp: Date.now() }],
},
{ apiKey: "test" },
).result();
const params = mockState.lastParams as { max_tokens?: number; max_completion_tokens?: number };
expect(params.max_tokens).toBeUndefined();
expect(params.max_completion_tokens).toBe(3904);
});
it("clamps explicit maxTokens to remaining context", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions", contextWindow: 10000, maxTokens: 8000 } as const;
await streamSimple(
model,
{
messages: [{ role: "user", content: "x".repeat(8000), timestamp: Date.now() }],
},
{ apiKey: "test", maxTokens: 7000 },
).result();
const params = mockState.lastParams as { max_tokens?: number; max_completion_tokens?: number };
expect(params.max_tokens).toBeUndefined();
expect(params.max_completion_tokens).toBe(3904);
});
it("uses conservative OpenAI-compatible fields for Cloudflare AI Gateway /compat models", async () => {
process.env.CLOUDFLARE_API_KEY = "cf-token";
process.env.CLOUDFLARE_ACCOUNT_ID = "account-id";
process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id";
const model = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6")!;
await streamSimple(
model,
{
systemPrompt: "You are helpful.",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ maxTokens: 1234, reasoning: "high" },
).result();
const params = mockState.lastParams as {
messages: Array<{ role: string }>;
max_tokens?: number;
max_completion_tokens?: number;
reasoning_effort?: string;
store?: boolean;
};
expect(params.messages[0].role).toBe("system");
expect(params.max_tokens).toBe(1234);
expect(params.max_completion_tokens).toBeUndefined();
expect(params.reasoning_effort).toBeUndefined();
expect(params.store).toBeUndefined();
const clientOptions = mockState.lastClientOptions as {
baseURL?: string;
defaultHeaders?: Record<string, unknown>;
};
expect(clientOptions.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/account-id/gateway-id/compat");
expect(clientOptions.defaultHeaders?.Authorization).toBeNull();
expect(clientOptions.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer cf-token");
});
it("resolves Cloudflare AI Gateway base URL through provider auth", async () => {
process.env.CLOUDFLARE_API_KEY = "cf-token";
process.env.CLOUDFLARE_ACCOUNT_ID = "account-id";
process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id";
const model = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6")!;
await streamSimple(model, {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
}).result();
const clientOptions = mockState.lastClientOptions as { baseURL?: string };
expect(clientOptions.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/account-id/gateway-id/compat");
});
it("preserves inline upstream Authorization for Cloudflare AI Gateway BYOK requests", async () => {
process.env.CLOUDFLARE_API_KEY = "cf-token";
process.env.CLOUDFLARE_ACCOUNT_ID = "account-id";
process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id";
const model = getModel("cloudflare-ai-gateway", "gpt-5.1")!;
await streamSimple(
model,
{
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ headers: { Authorization: "Bearer upstream-token" } },
).result();
const clientOptions = mockState.lastClientOptions as { defaultHeaders?: Record<string, unknown> };
expect(clientOptions.defaultHeaders?.Authorization).toBe("Bearer upstream-token");
expect(clientOptions.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer cf-token");
});
it("sends session affinity headers for Workers AI through Cloudflare AI Gateway", async () => {
process.env.CLOUDFLARE_API_KEY = "cf-token";
process.env.CLOUDFLARE_ACCOUNT_ID = "account-id";
process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id";
const workersModel = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6")!;
await streamSimple(
workersModel,
{
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ sessionId: "session-1" },
).result();
const clientOptions = mockState.lastClientOptions as { defaultHeaders?: Record<string, string> };
expect(clientOptions.defaultHeaders?.session_id).toBe("session-1");
expect(clientOptions.defaultHeaders?.["x-client-request-id"]).toBe("session-1");
expect(clientOptions.defaultHeaders?.["x-session-affinity"]).toBe("session-1");
});
it("still emits tools: [] for Anthropic/LiteLLM proxy when conversation has tool history", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions" } as const;
await streamSimple(
model,
{
messages: [
{ role: "user", content: "use the tool", timestamp: Date.now() },
{
role: "assistant",
content: [
{
type: "toolCall",
id: "t1",
name: "noop",
arguments: {},
},
],
stopReason: "toolUse",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
api: "openai-completions",
provider: "openai",
model: "gpt-4o-mini",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "t1",
toolName: "noop",
content: [{ type: "text", text: "done" }],
isError: false,
timestamp: Date.now(),
},
],
tools: [],
},
{ apiKey: "test" },
).result();
const params = mockState.lastParams as { tools?: unknown[] };
expect(Array.isArray(params.tools)).toBe(true);
expect(params.tools).toEqual([]);
});
});

View File

@@ -0,0 +1,274 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import { getModel } from "../src/compat.ts";
import type { Model } from "../src/types.ts";
interface FakeOpenAIClientOptions {
apiKey: string;
baseURL: string;
dangerouslyAllowBrowser: boolean;
defaultHeaders?: Record<string, string>;
}
interface CapturedCompletionsPayload {
prompt_cache_key?: string;
prompt_cache_retention?: "24h" | "in-memory" | null;
session_id?: string;
}
const mockState = vi.hoisted(() => ({
lastParams: undefined as CapturedCompletionsPayload | undefined,
lastClientOptions: undefined as FakeOpenAIClientOptions | undefined,
}));
vi.mock("openai", () => {
class FakeOpenAI {
chat = {
completions: {
create: (params: CapturedCompletionsPayload) => {
mockState.lastParams = params;
const stream = {
async *[Symbol.asyncIterator]() {
yield {
choices: [{ delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
};
},
};
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
withResponse: () => Promise<{
data: typeof stream;
response: { status: number; headers: Headers };
}>;
};
promise.withResponse = async () => ({
data: stream,
response: { status: 200, headers: new Headers() },
});
return promise;
},
},
};
constructor(options: FakeOpenAIClientOptions) {
mockState.lastClientOptions = options;
}
}
return { default: FakeOpenAI };
});
describe("openai-completions prompt caching", () => {
const originalEnv = process.env.PI_CACHE_RETENTION;
beforeEach(() => {
mockState.lastParams = undefined;
mockState.lastClientOptions = undefined;
delete process.env.PI_CACHE_RETENTION;
});
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.PI_CACHE_RETENTION;
} else {
process.env.PI_CACHE_RETENTION = originalEnv;
}
});
function createModel(overrides: Partial<Model<"openai-completions">> = {}): Model<"openai-completions"> {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
return {
...(baseModel as Omit<Model<"openai-completions">, "api">),
api: "openai-completions",
...overrides,
};
}
async function captureRequest(
options?: {
cacheRetention?: "none" | "short" | "long";
sessionId?: string;
headers?: Record<string, string>;
},
model: Model<"openai-completions"> = createModel(),
) {
await streamOpenAICompletions(
model,
{
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ apiKey: "test-key", ...options },
).result();
return {
payload: mockState.lastParams,
headers: mockState.lastClientOptions?.defaultHeaders ?? {},
};
}
it("sets prompt_cache_key for direct OpenAI requests when caching is enabled", async () => {
const { payload } = await captureRequest({ sessionId: "session-123" });
expect(payload?.prompt_cache_key).toBe("session-123");
expect(payload?.prompt_cache_retention).toBeUndefined();
});
it("sets prompt_cache_retention to 24h for direct OpenAI requests when cacheRetention is long", async () => {
const { payload } = await captureRequest({ cacheRetention: "long", sessionId: "session-456" });
expect(payload?.prompt_cache_key).toBe("session-456");
expect(payload?.prompt_cache_retention).toBe("24h");
});
it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => {
const sessionId = "x".repeat(67);
const { payload } = await captureRequest({ sessionId });
expect(payload?.prompt_cache_key).toBe("x".repeat(64));
});
it("omits prompt cache fields when cacheRetention is none", async () => {
const { payload } = await captureRequest({ cacheRetention: "none", sessionId: "session-789" });
expect(payload?.prompt_cache_key).toBeUndefined();
expect(payload?.prompt_cache_retention).toBeUndefined();
});
it("omits prompt cache fields for non-OpenAI base URLs without compatible long retention", async () => {
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
compat: { supportsLongCacheRetention: false },
});
const { payload } = await captureRequest({ cacheRetention: "long", sessionId: "session-proxy" }, model);
expect(payload?.prompt_cache_key).toBeUndefined();
expect(payload?.prompt_cache_retention).toBeUndefined();
});
it("uses PI_CACHE_RETENTION for direct OpenAI requests", async () => {
process.env.PI_CACHE_RETENTION = "long";
const { payload } = await captureRequest({ sessionId: "session-env" });
expect(payload?.prompt_cache_key).toBe("session-env");
expect(payload?.prompt_cache_retention).toBe("24h");
});
it("sends known session-affinity headers when compat.sendSessionAffinityHeaders is enabled", async () => {
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
compat: { sendSessionAffinityHeaders: true },
});
const { headers } = await captureRequest({ sessionId: "session-affinity" }, model);
expect(headers.session_id).toBe("session-affinity");
expect(headers["x-client-request-id"]).toBe("session-affinity");
expect(headers["x-session-affinity"]).toBe("session-affinity");
});
it.each(["accounts/fireworks/models/glm-5p2", "accounts/fireworks/routers/glm-5p2-fast"] as const)(
"sends Fireworks session affinity for %s",
async (modelId) => {
const model = getModel("fireworks", modelId);
const { headers } = await captureRequest({ sessionId: "fireworks-session" }, model);
expect(headers["x-session-affinity"]).toBe("fireworks-session");
},
);
it("uses OpenAI no-session format when configured", async () => {
const model = createModel({
compat: { sendSessionAffinityHeaders: true, sessionAffinityFormat: "openai-nosession" },
});
const { payload, headers } = await captureRequest({ sessionId: "session-nosession" }, model);
expect(payload?.session_id).toBeUndefined();
expect(payload?.prompt_cache_key).toBe("session-nosession");
expect(headers.session_id).toBeUndefined();
expect(headers["x-client-request-id"]).toBe("session-nosession");
expect(headers["x-session-affinity"]).toBe("session-nosession");
expect(headers["x-session-id"]).toBeUndefined();
});
it("uses OpenRouter session-affinity header when configured", async () => {
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
compat: { sendSessionAffinityHeaders: true, sessionAffinityFormat: "openrouter" },
});
const { payload, headers } = await captureRequest({ sessionId: "session-proxy" }, model);
expect(payload?.session_id).toBeUndefined();
expect(payload?.prompt_cache_key).toBeUndefined();
expect(headers["x-session-id"]).toBe("session-proxy");
expect(headers.session_id).toBeUndefined();
expect(headers["x-client-request-id"]).toBeUndefined();
expect(headers["x-session-affinity"]).toBeUndefined();
});
it("auto-detects OpenRouter session-affinity header for OpenRouter endpoints", async () => {
const model = createModel({
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
compat: { sendSessionAffinityHeaders: true },
});
const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model);
expect(payload?.session_id).toBeUndefined();
expect(payload?.prompt_cache_key).toBeUndefined();
expect(headers["x-session-id"]).toBe("session-openrouter");
expect(headers.session_id).toBeUndefined();
expect(headers["x-client-request-id"]).toBeUndefined();
expect(headers["x-session-affinity"]).toBeUndefined();
});
it("omits OpenRouter session-affinity data when disabled", async () => {
const model = createModel({
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
});
const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model);
expect(payload?.session_id).toBeUndefined();
expect(payload?.prompt_cache_key).toBeUndefined();
expect(headers["x-session-id"]).toBeUndefined();
});
it("omits session-affinity headers when cacheRetention is none", async () => {
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
compat: { sendSessionAffinityHeaders: true },
});
const { headers } = await captureRequest({ cacheRetention: "none", sessionId: "session-affinity" }, model);
expect(headers.session_id).toBeUndefined();
expect(headers["x-client-request-id"]).toBeUndefined();
expect(headers["x-session-affinity"]).toBeUndefined();
});
it("lets explicit headers override generated session-affinity headers", async () => {
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
compat: { sendSessionAffinityHeaders: true },
});
const { headers } = await captureRequest(
{
sessionId: "session-affinity",
headers: {
session_id: "override-session",
"x-client-request-id": "override-request",
"x-session-affinity": "override-affinity",
},
},
model,
);
expect(headers.session_id).toBe("override-session");
expect(headers["x-client-request-id"]).toBe("override-request");
expect(headers["x-session-affinity"]).toBe("override-affinity");
});
});

View File

@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import type { Context, Model } from "../src/types.ts";
const mockState = vi.hoisted(() => ({
chunks: [] as unknown[],
}));
vi.mock("openai", () => {
class FakeOpenAI {
chat = {
completions: {
create: () => {
const chunks = mockState.chunks;
const stream = {
async *[Symbol.asyncIterator]() {
for (const chunk of chunks) yield chunk;
},
};
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
withResponse: () => Promise<{
data: typeof stream;
response: { status: number; headers: Headers };
}>;
};
promise.withResponse = async () => ({
data: stream,
response: { status: 200, headers: new Headers() },
});
return promise;
},
},
};
}
return { default: FakeOpenAI };
});
const model: Model<"openai-completions"> = {
id: "test-model",
name: "Test Model",
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,
};
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
describe("OpenAI completions raw stop reasons", () => {
beforeEach(() => {
mockState.chunks = [];
});
it("preserves raw finish reasons for successful stops", async () => {
mockState.chunks = [{ id: "chatcmpl-1", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }];
const message = await streamOpenAICompletions(model, context, { apiKey: "test" }).result();
expect(message.stopReason).toBe("stop");
expect(message.rawStopReason).toBe("stop");
expect(message.errorMessage).toBeUndefined();
});
it("preserves raw finish reasons for provider error stops", async () => {
mockState.chunks = [{ id: "chatcmpl-2", choices: [{ index: 0, delta: {}, finish_reason: "content_filter" }] }];
const message = await streamOpenAICompletions(model, context, { apiKey: "test" }).result();
expect(message.stopReason).toBe("error");
expect(message.rawStopReason).toBe("content_filter");
expect(message.errorMessage).toBe("Provider finish_reason: content_filter");
});
});

View File

@@ -0,0 +1,251 @@
import { Type } from "typebox";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import type { AssistantMessage, Model, Tool } from "../src/types.ts";
const mockState = vi.hoisted(() => ({
chunkSets: [] as unknown[][],
payloads: [] as unknown[],
}));
vi.mock("openai", () => {
class FakeOpenAI {
chat = {
completions: {
create: (payload: unknown) => {
mockState.payloads.push(payload);
const chunks = mockState.chunkSets.shift() ?? [];
const stream = {
async *[Symbol.asyncIterator]() {
for (const chunk of chunks) {
yield chunk;
}
},
};
const result = Promise.resolve(stream) as Promise<typeof stream> & {
withResponse: () => Promise<{ data: typeof stream; response: { status: number; headers: Headers } }>;
};
result.withResponse = async () => ({
data: stream,
response: { status: 200, headers: new Headers() },
});
return result;
},
},
};
}
return { default: FakeOpenAI };
});
const reasoningDetail = { type: "reasoning.encrypted", id: "call_1", data: "encrypted-signature" };
const signedReasoningTextDetail = {
type: "reasoning.text",
text: "I should call the read tool.",
signature: "sha256:signed-text",
id: "reasoning-text-1",
format: "anthropic-claude-v1",
index: 0,
};
const reasoningSummaryDetail = {
type: "reasoning.summary",
summary: "Decided to inspect the requested file.",
id: "reasoning-summary-1",
format: "anthropic-claude-v1",
index: 1,
};
const readTool: Tool = {
name: "read",
description: "Read a file",
parameters: Type.Object({ path: Type.String() }),
};
function model(): Model<"openai-completions"> {
return {
id: "google/gemini-test",
name: "Gemini Test",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 100_000,
maxTokens: 4096,
};
}
function chunk(delta: Record<string, unknown>, finishReason: string | null = null): unknown {
return {
id: "chatcmpl-test",
model: "google/gemini-test",
choices: [{ index: 0, delta, finish_reason: finishReason }],
};
}
function toolCallChunk(): unknown {
return chunk({
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: { name: "read", arguments: '{"path":"README.md"}' },
},
],
});
}
async function runOpenAICompletionsStream(messages: AssistantMessage[] = []): Promise<AssistantMessage> {
return await streamOpenAICompletions(model(), { messages, tools: [readTool] }, { apiKey: "test" }).result();
}
function getAssistantPayload(payload: unknown): { reasoning?: unknown; reasoning_details?: unknown } | undefined {
const messages = (
payload as { messages?: Array<{ role?: string; reasoning?: unknown; reasoning_details?: unknown }> }
).messages;
return messages?.find((message) => message.role === "assistant");
}
describe("openai-completions reasoning_details streaming", () => {
beforeEach(() => {
mockState.chunkSets = [];
mockState.payloads = [];
});
it("preserves reasoning_details in the thinking signature", async () => {
mockState.chunkSets = [
[chunk({ reasoning_details: [reasoningDetail] }), toolCallChunk(), chunk({}, "tool_calls")],
[chunk({ content: "ok" }), chunk({}, "stop")],
];
const assistantMessage = await runOpenAICompletionsStream();
const thinking = assistantMessage.content.find((block) => block.type === "thinking");
expect(thinking).toEqual({
type: "thinking",
thinking: "",
thinkingSignature: JSON.stringify([reasoningDetail]),
});
const toolCall = assistantMessage.content.find((block) => block.type === "toolCall");
expect(toolCall).toEqual({
type: "toolCall",
id: "call_1",
name: "read",
arguments: { path: "README.md" },
});
await runOpenAICompletionsStream([assistantMessage]);
expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual([reasoningDetail]);
});
it("falls back to encrypted tool-call signatures for older stored assistant messages", async () => {
mockState.chunkSets = [
[chunk({ reasoning_details: [reasoningDetail] }), toolCallChunk(), chunk({}, "tool_calls")],
[chunk({ content: "ok" }), chunk({}, "stop")],
];
const assistantMessage = await runOpenAICompletionsStream();
assistantMessage.content = assistantMessage.content.filter((block) => block.type !== "thinking");
const toolCall = assistantMessage.content.find((block) => block.type === "toolCall");
if (!toolCall || toolCall.type !== "toolCall") throw new Error("Expected tool call");
toolCall.thoughtSignature = JSON.stringify(reasoningDetail);
await runOpenAICompletionsStream([assistantMessage]);
expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual([reasoningDetail]);
});
it("preserves signed text and summary reasoning_details in their original sequence", async () => {
mockState.chunkSets = [
[
chunk({ reasoning: signedReasoningTextDetail.text, reasoning_details: [signedReasoningTextDetail] }),
chunk({ reasoning_details: [reasoningDetail, reasoningSummaryDetail] }),
toolCallChunk(),
chunk({}, "tool_calls"),
],
[chunk({ content: "ok" }), chunk({}, "stop")],
];
const assistantMessage = await runOpenAICompletionsStream();
const expectedReasoningDetails = [signedReasoningTextDetail, reasoningDetail, reasoningSummaryDetail];
const thinking = assistantMessage.content.find((block) => block.type === "thinking");
expect(thinking).toEqual({
type: "thinking",
thinking: signedReasoningTextDetail.text,
thinkingSignature: JSON.stringify(expectedReasoningDetails),
});
await runOpenAICompletionsStream([assistantMessage]);
const payload = getAssistantPayload(mockState.payloads[1]);
expect(payload?.reasoning_details).toEqual(expectedReasoningDetails);
expect(payload?.reasoning).toBeUndefined();
});
it("merges consecutive text and summary reasoning_details deltas before replay", async () => {
const textDelta = { type: "reasoning.text", text: "The", index: 0 };
const textDeltaWithSignature = {
type: "reasoning.text",
text: " user wants the time.",
signature: "sha256:text-signature",
format: "openai-responses-v1",
index: 0,
};
const summaryDelta = { type: "reasoning.summary", summary: "Looked", index: 0 };
const summaryDeltaWithFormat = {
type: "reasoning.summary",
summary: " up time.",
format: "openai-responses-v1",
index: 0,
};
const laterSummaryDelta = {
type: "reasoning.summary",
summary: "After encrypted block.",
format: "openai-responses-v1",
index: 0,
};
const expectedReasoningDetails = [
{
type: "reasoning.text",
text: "The user wants the time.",
index: 0,
signature: "sha256:text-signature",
format: "openai-responses-v1",
},
{
type: "reasoning.summary",
summary: "Looked up time.",
index: 0,
format: "openai-responses-v1",
},
reasoningDetail,
laterSummaryDelta,
];
mockState.chunkSets = [
[
chunk({ reasoning_details: [textDelta] }),
chunk({ reasoning_details: [textDeltaWithSignature] }),
chunk({ reasoning_details: [summaryDelta] }),
chunk({ reasoning_details: [summaryDeltaWithFormat] }),
chunk({ reasoning_details: [reasoningDetail] }),
chunk({ reasoning_details: [laterSummaryDelta] }),
toolCallChunk(),
chunk({}, "tool_calls"),
],
[chunk({ content: "ok" }), chunk({}, "stop")],
];
const assistantMessage = await runOpenAICompletionsStream();
const thinking = assistantMessage.content.find((block) => block.type === "thinking");
expect(thinking).toEqual({
type: "thinking",
thinking: "",
thinkingSignature: JSON.stringify(expectedReasoningDetails),
});
await runOpenAICompletionsStream([assistantMessage]);
expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual(expectedReasoningDetails);
});
});

View File

@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { complete } from "../src/compat.ts";
import type { Model } from "../src/types.ts";
// Router/virtual ids (e.g. OpenRouter `auto`) keep `model` pinned to the
// requested id and surface the routed concrete id on `responseModel`.
const mockState = vi.hoisted(() => ({
chunks: [] as unknown[],
}));
vi.mock("openai", () => {
class FakeOpenAI {
chat = {
completions: {
create: () => {
const chunks = mockState.chunks;
const stream = {
async *[Symbol.asyncIterator]() {
for (const chunk of chunks) yield chunk;
},
};
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
withResponse: () => Promise<{
data: typeof stream;
response: { status: number; headers: Headers };
}>;
};
promise.withResponse = async () => ({
data: stream,
response: { status: 200, headers: new Headers() },
});
return promise;
},
},
};
}
return { default: FakeOpenAI };
});
function openRouterAuto(): Model<"openai-completions"> {
return {
id: "openrouter/auto",
name: "OpenRouter Auto",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
};
}
describe("openai-completions responseModel", () => {
beforeEach(() => {
mockState.chunks = [];
});
it("surfaces routed chunk.model on responseModel without changing model", async () => {
mockState.chunks = [
{ id: "chatcmpl-1", model: "anthropic/claude-opus-4.8", choices: [{ index: 0, delta: { content: "hi" } }] },
{
id: "chatcmpl-1",
model: "anthropic/claude-opus-4.8",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
];
const message = await complete(
openRouterAuto(),
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] },
{ apiKey: "test" },
);
expect(message.model).toBe("openrouter/auto");
expect(message.responseModel).toBe("anthropic/claude-opus-4.8");
expect(message.provider).toBe("openrouter");
expect(message.stopReason).toBe("stop");
});
it("leaves responseModel undefined when chunks echo the requested id", async () => {
mockState.chunks = [
{ id: "chatcmpl-2", model: "openrouter/auto", choices: [{ index: 0, delta: { content: "hi" } }] },
{
id: "chatcmpl-2",
model: "openrouter/auto",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
];
const message = await complete(
openRouterAuto(),
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] },
{ apiKey: "test" },
);
expect(message.model).toBe("openrouter/auto");
expect(message.responseModel).toBeUndefined();
});
it("ignores empty or missing chunk.model", async () => {
mockState.chunks = [
{ id: "chatcmpl-3", choices: [{ index: 0, delta: { content: "hi" } }] },
{ id: "chatcmpl-3", model: "", choices: [{ index: 0, delta: { content: "!" } }] },
{
id: "chatcmpl-3",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 1,
completion_tokens: 2,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
];
const message = await complete(
openRouterAuto(),
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] },
{ apiKey: "test" },
);
expect(message.model).toBe("openrouter/auto");
expect(message.responseModel).toBeUndefined();
});
});

View File

@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import type { Context, Model } from "../src/types.ts";
const mockState = vi.hoisted(() => ({
requestOptions: [] as unknown[],
requestErrors: [] as Error[],
}));
vi.mock("openai", () => {
class FakeOpenAI {
chat = {
completions: {
create: (_params: unknown, options: unknown) => {
mockState.requestOptions.push(options);
const stream = {
async *[Symbol.asyncIterator]() {
yield {
id: "chatcmpl-test",
choices: [{ index: 0, delta: { content: "ok" } }],
};
yield {
id: "chatcmpl-test",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
};
},
};
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
withResponse: () => Promise<{
data: typeof stream;
response: { status: number; headers: Headers };
}>;
};
promise.withResponse = async () => {
const error = mockState.requestErrors.shift();
if (error) throw error;
return {
data: stream,
response: { status: 200, headers: new Headers() },
};
};
return promise;
},
},
};
}
return { default: FakeOpenAI };
});
const model: Model<"openai-completions"> = {
id: "test-model",
name: "Test Model",
api: "openai-completions",
provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
const context: Context = {
systemPrompt: "",
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }],
tools: [],
};
async function consume(options?: { maxRetries?: number; maxRetryDelayMs?: number }) {
const stream = streamOpenAICompletions(model, context, { apiKey: "test", ...options });
for await (const _event of stream) {
void _event;
}
return stream.result();
}
describe("openai-completions provider retries", () => {
beforeEach(() => {
mockState.requestOptions = [];
mockState.requestErrors = [];
});
afterEach(() => {
vi.useRealTimers();
});
it("disables SDK retries by default", async () => {
await consume();
expect(mockState.requestOptions).toEqual([expect.objectContaining({ maxRetries: 0 })]);
});
it("honors provider retries while keeping SDK retries disabled", async () => {
vi.useFakeTimers();
mockState.requestErrors = [
Object.assign(new Error("rate limited"), {
status: 429,
headers: new Headers({ "retry-after-ms": "100" }),
}),
Object.assign(new Error("server error"), {
status: 500,
headers: new Headers({ "retry-after-ms": "100" }),
}),
];
const result = consume({ maxRetries: 2, maxRetryDelayMs: 100 });
await vi.advanceTimersByTimeAsync(0);
expect(mockState.requestOptions).toHaveLength(1);
await vi.advanceTimersByTimeAsync(99);
expect(mockState.requestOptions).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
expect(mockState.requestOptions).toHaveLength(2);
await vi.advanceTimersByTimeAsync(99);
expect(mockState.requestOptions).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1);
await result;
expect(mockState.requestOptions).toEqual([
expect.objectContaining({ maxRetries: 0 }),
expect.objectContaining({ maxRetries: 0 }),
expect.objectContaining({ maxRetries: 0 }),
]);
});
it("fails immediately when a provider-requested retry delay exceeds the limit", async () => {
mockState.requestErrors = [
Object.assign(new Error("rate limited"), {
status: 429,
headers: new Headers({ "retry-after": "277403" }),
}),
];
const result = await consume({ maxRetries: 2, maxRetryDelayMs: 1000 });
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Server requested 277403s retry delay (max: 1s)");
expect(result.errorMessage).toContain("rate limited");
expect(mockState.requestOptions).toEqual([expect.objectContaining({ maxRetries: 0 })]);
});
});

View File

@@ -0,0 +1,227 @@
import { once } from "node:events";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { convertMessages, stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import type {
AssistantMessage,
AssistantMessageEvent,
Context,
Model,
OpenAICompletionsCompat,
Usage,
} from "../src/types.ts";
const emptyUsage: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const compat = {
supportsStore: true,
supportsDeveloperRole: true,
supportsReasoningEffort: true,
supportsUsageInStreaming: true,
supportsFinishReason: true,
maxTokensField: "max_completion_tokens",
requiresToolResultName: false,
requiresAssistantAfterToolResult: false,
requiresThinkingAsText: true,
requiresReasoningContentOnAssistantMessages: false,
thinkingFormat: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsThinkingTokenBudget: false,
thinkingTokenBudgetField: undefined,
supportsStrictMode: true,
supportsOpenAIGrammarTools: false,
cacheControlFormat: undefined,
sendSessionAffinityHeaders: false,
sessionAffinityFormat: "openai",
supportsLongCacheRetention: true,
} satisfies Omit<
Required<OpenAICompletionsCompat>,
"cacheControlFormat" | "deferredToolsMode" | "thinkingTokenBudgetField" | "vllmPriority"
> & {
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
};
function buildModel(baseUrl = "http://127.0.0.1:1"): Model<"openai-completions"> {
return {
id: "repro-model",
name: "Repro Model",
api: "openai-completions",
provider: "repro-provider",
baseUrl,
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
compat,
};
}
function buildAssistant(content: AssistantMessage["content"]): AssistantMessage {
return {
role: "assistant",
content,
api: "openai-completions",
provider: "repro-provider",
model: "repro-model",
usage: emptyUsage,
stopReason: "stop",
timestamp: 2,
};
}
function buildContext(assistant: AssistantMessage): Context {
return {
messages: [
{ role: "user", content: "hello", timestamp: 1 },
assistant,
{ role: "user", content: "continue", timestamp: 3 },
],
};
}
async function collectEvents(stream: AsyncIterable<AssistantMessageEvent>): Promise<AssistantMessageEvent[]> {
const events: AssistantMessageEvent[] = [];
for await (const event of stream) {
events.push(event);
}
return events;
}
interface ChatCompletionsRequestBody {
model: string;
messages: Array<{ role: string; content?: unknown }>;
stream: boolean;
stream_options?: { include_usage?: boolean };
}
describe("openai-completions thinking-as-text replay", () => {
afterEach(() => {
delete process.env.OPENAI_API_KEY;
});
it("serializes same-model thinking-plus-text replay as assistant text parts", () => {
const messages = convertMessages(
buildModel(),
buildContext(
buildAssistant([
{ type: "thinking", thinking: "internal reasoning" },
{ type: "text", text: "visible answer" },
]),
),
compat,
);
expect(messages[1]).toEqual({
role: "assistant",
content: [
{ type: "text", text: "internal reasoning" },
{ type: "text", text: "visible answer" },
],
});
});
it("serializes same-model thinking-only replay as assistant text parts", () => {
const messages = convertMessages(
buildModel(),
buildContext(buildAssistant([{ type: "thinking", thinking: "internal reasoning" }])),
compat,
);
expect(messages[1]).toEqual({
role: "assistant",
content: [{ type: "text", text: "internal reasoning" }],
});
});
it("reaches the endpoint when replay contains both thinking and text", async () => {
const requestBodies: ChatCompletionsRequestBody[] = [];
const server = http.createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/chat/completions") {
res.writeHead(404).end();
return;
}
let body = "";
for await (const chunk of req) {
body += chunk.toString();
}
requestBodies.push(JSON.parse(body) as ChatCompletionsRequestBody);
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
});
res.write(
`data: ${JSON.stringify({
id: "chatcmpl-repro",
object: "chat.completion.chunk",
created: 0,
model: "repro-model",
choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }],
})}\n\n`,
);
res.write(
`data: ${JSON.stringify({
id: "chatcmpl-repro",
object: "chat.completion.chunk",
created: 0,
model: "repro-model",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
})}\n\n`,
);
res.write("data: [DONE]\n\n");
res.end();
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
try {
const { port } = server.address() as AddressInfo;
const events = await collectEvents(
streamOpenAICompletions(
buildModel(`http://127.0.0.1:${port}`),
buildContext(
buildAssistant([
{ type: "thinking", thinking: "internal reasoning" },
{ type: "text", text: "visible answer" },
]),
),
{ apiKey: "test-key" },
),
);
expect(requestBodies).toHaveLength(1);
expect(requestBodies[0]?.messages[1]).toEqual({
role: "assistant",
content: [
{ type: "text", text: "internal reasoning" },
{ type: "text", text: "visible answer" },
],
});
const terminalEvent = events.at(-1);
expect(terminalEvent?.type).toBe("done");
} finally {
server.close();
await once(server, "close");
}
});
});

View File

@@ -0,0 +1,202 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { streamSimple } from "../src/compat.ts";
import type { Model, SimpleStreamOptions, ThinkingBudgets } from "../src/types.ts";
const mockState = vi.hoisted(() => ({
lastParams: undefined as unknown,
}));
vi.mock("openai", () => {
class FakeOpenAI {
chat = {
completions: {
create: (params: unknown) => {
mockState.lastParams = params;
const stream = {
async *[Symbol.asyncIterator]() {
yield {
choices: [{ delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
};
},
};
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
withResponse: () => Promise<{ data: typeof stream; response: { status: number; headers: Headers } }>;
};
promise.withResponse = async () => ({
data: stream,
response: { status: 200, headers: new Headers() },
});
return promise;
},
},
};
}
return { default: FakeOpenAI };
});
type CapturedParams = {
thinking_token_budget?: number;
thinking_budget?: number;
thinking_budget_tokens?: number;
thinking?: unknown;
chat_template_kwargs?: Record<string, unknown>;
};
function vllmModel(
compat: Model<"openai-completions">["compat"] = {
thinkingFormat: "zai",
supportsThinkingTokenBudget: true,
},
): Model<"openai-completions"> {
return {
id: "zai-org/glm-5.2",
name: "GLM 5.2 (local vLLM)",
api: "openai-completions",
provider: "local-vllm",
baseUrl: "http://localhost:8000/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 16384,
compat,
};
}
async function capture(
model: Model<"openai-completions">,
options?: {
reasoning?: SimpleStreamOptions["reasoning"];
thinkingBudgets?: ThinkingBudgets;
maxTokens?: number;
},
): Promise<CapturedParams> {
let payload: unknown;
await streamSimple(
model,
{ messages: [{ role: "user", content: "Hi", timestamp: Date.now() }] },
{
apiKey: "test",
reasoning: options?.reasoning,
thinkingBudgets: options?.thinkingBudgets,
maxTokens: options?.maxTokens,
onPayload: (params: unknown) => {
payload = params;
},
},
).result();
return (payload ?? mockState.lastParams) as CapturedParams;
}
describe("openai-completions thinking token budget", () => {
beforeEach(() => {
mockState.lastParams = undefined;
});
it("sends the configured budget for the requested level", async () => {
const params = await capture(vllmModel(), { reasoning: "medium", thinkingBudgets: { medium: 4096 } });
expect(params.thinking_token_budget).toBe(4096);
});
it("omits the budget when neither the field nor the alias is set", async () => {
const params = await capture(vllmModel({ thinkingFormat: "zai" }), {
reasoning: "medium",
thinkingBudgets: { medium: 4096 },
});
expect(params.thinking_token_budget).toBeUndefined();
expect(params.thinking_budget).toBeUndefined();
expect(params.thinking_budget_tokens).toBeUndefined();
});
it("omits the budget when thinking is off", async () => {
const params = await capture(vllmModel(), { reasoning: undefined, thinkingBudgets: { high: 8192 } });
expect(params.thinking_token_budget).toBeUndefined();
});
it("clamps xhigh and max to the high budget", async () => {
const xhigh = await capture(vllmModel(), { reasoning: "xhigh", thinkingBudgets: { high: 8192 } });
const max = await capture(vllmModel(), { reasoning: "max", thinkingBudgets: { high: 8192 } });
expect(xhigh.thinking_token_budget).toBe(8192);
expect(max.thinking_token_budget).toBe(8192);
});
it("leaves room for the answer when the budget meets the response ceiling", async () => {
const params = await capture(vllmModel(), { reasoning: "high" });
expect(params.thinking_token_budget).toBe(16384 - 1024);
});
it("uses the caller max_tokens as the ceiling when it is lower than the model cap", async () => {
const params = await capture(vllmModel(), {
reasoning: "high",
thinkingBudgets: { high: 8192 },
maxTokens: 4096,
});
expect(params.thinking_token_budget).toBe(4096 - 1024);
});
it.each(["thinking_budget", "thinking_budget_tokens"] as const)(
"sends %s when thinkingTokenBudgetField is set",
async (field) => {
const params = await capture(vllmModel({ thinkingFormat: "qwen", thinkingTokenBudgetField: field }), {
reasoning: "medium",
thinkingBudgets: { medium: 4096 },
});
expect(params[field]).toBe(4096);
expect(params.thinking_token_budget).toBeUndefined();
},
);
it("lets thinkingTokenBudgetField win over the boolean alias", async () => {
const params = await capture(
vllmModel({
thinkingFormat: "zai",
supportsThinkingTokenBudget: true,
thinkingTokenBudgetField: "thinking_budget",
}),
{ reasoning: "medium", thinkingBudgets: { medium: 4096 } },
);
expect(params.thinking_budget).toBe(4096);
expect(params.thinking_token_budget).toBeUndefined();
});
it("puts the clamped budget in chat_template_kwargs when $var is thinking.budget", async () => {
const params = await capture(
vllmModel({
thinkingFormat: "chat-template",
chatTemplateKwargs: {
enable_thinking: { $var: "thinking.enabled" },
thinking_budget: { $var: "thinking.budget" },
},
}),
{ reasoning: "high" },
);
expect(params.chat_template_kwargs).toEqual({
enable_thinking: true,
thinking_budget: 16384 - 1024,
});
expect(params.thinking_token_budget).toBeUndefined();
});
it("omits thinking.budget from chat_template_kwargs when thinking is off", async () => {
const params = await capture(
vllmModel({
thinkingFormat: "chat-template",
chatTemplateKwargs: {
enable_thinking: { $var: "thinking.enabled" },
thinking_budget: { $var: "thinking.budget" },
},
}),
{ reasoning: undefined },
);
expect(params.chat_template_kwargs).toEqual({ enable_thinking: false });
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import { convertMessages } from "../src/api/openai-completions.ts";
import { getModel } from "../src/compat.ts";
import type {
AssistantMessage,
Context,
Model,
OpenAICompletionsCompat,
ToolResultMessage,
Usage,
} from "../src/types.ts";
const emptyUsage: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const compat: Omit<
Required<OpenAICompletionsCompat>,
"deferredToolsMode" | "thinkingTokenBudgetField" | "vllmPriority"
> & {
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
} = {
supportsStore: true,
supportsDeveloperRole: true,
supportsReasoningEffort: true,
supportsUsageInStreaming: true,
supportsFinishReason: true,
maxTokensField: "max_completion_tokens",
requiresToolResultName: false,
requiresAssistantAfterToolResult: false,
requiresThinkingAsText: false,
requiresReasoningContentOnAssistantMessages: false,
thinkingFormat: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsThinkingTokenBudget: false,
thinkingTokenBudgetField: undefined,
supportsStrictMode: true,
supportsOpenAIGrammarTools: false,
cacheControlFormat: "anthropic",
sendSessionAffinityHeaders: false,
sessionAffinityFormat: "openai",
supportsLongCacheRetention: true,
};
function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessage {
return {
role: "toolResult",
toolCallId,
toolName: "read",
content: [
{ type: "text", text: "Read image file [image/png]" },
{ type: "image", data: "ZmFrZQ==", mimeType: "image/png" },
],
isError: false,
timestamp,
};
}
function buildEmptyToolResult(toolCallId: string, timestamp: number): ToolResultMessage {
return {
role: "toolResult",
toolCallId,
toolName: "bash",
content: [{ type: "text", text: "" }],
isError: false,
timestamp,
};
}
describe("openai-completions convertMessages", () => {
it("batches tool-result images after consecutive tool results", () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
const model: Model<"openai-completions"> = {
...baseModel,
api: "openai-completions",
input: ["text", "image"],
};
const now = Date.now();
const assistantMessage: AssistantMessage = {
role: "assistant",
content: [
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "img-1.png" } },
{ type: "toolCall", id: "tool-2", name: "read", arguments: { path: "img-2.png" } },
],
api: model.api,
provider: model.provider,
model: model.id,
usage: emptyUsage,
stopReason: "toolUse",
timestamp: now,
};
const context: Context = {
messages: [
{ role: "user", content: "Read the images", timestamp: now - 2 },
assistantMessage,
buildToolResult("tool-1", now + 1),
buildToolResult("tool-2", now + 2),
],
};
const messages = convertMessages(model, context, compat);
const roles = messages.map((message) => message.role);
expect(roles).toEqual(["user", "assistant", "tool", "tool", "user"]);
const imageMessage = messages[messages.length - 1];
expect(imageMessage.role).toBe("user");
expect(Array.isArray(imageMessage.content)).toBe(true);
const imageParts = (imageMessage.content as Array<{ type?: string }>).filter(
(part) => part?.type === "image_url",
);
expect(imageParts.length).toBe(2);
});
it("uses '(no tool output)' placeholder for empty tool results without images", () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
const model: Model<"openai-completions"> = {
...baseModel,
api: "openai-completions",
input: ["text", "image"],
};
const now = Date.now();
const assistantMessage: AssistantMessage = {
role: "assistant",
content: [{ type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "true" } }],
api: model.api,
provider: model.provider,
model: model.id,
usage: emptyUsage,
stopReason: "toolUse",
timestamp: now,
};
const context: Context = {
messages: [
{ role: "user", content: "Run the command", timestamp: now - 1 },
assistantMessage,
buildEmptyToolResult("tool-1", now + 1),
],
};
const messages = convertMessages(model, context, compat);
const toolMessage = messages.find((m) => m.role === "tool") as { role: "tool"; content: string } | undefined;
expect(toolMessage).toBeTruthy();
expect(toolMessage?.content).toBe("(no tool output)");
expect(toolMessage?.content).not.toContain("see attached image");
});
});

View File

@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import { getModel } from "../src/compat.ts";
import type { Model } from "../src/types.ts";
interface CapturedCompletionsPayload {
priority?: number;
[key: string]: unknown;
}
const mockState = vi.hoisted(() => ({
lastParams: undefined as CapturedCompletionsPayload | undefined,
}));
vi.mock("openai", () => {
class FakeOpenAI {
chat = {
completions: {
create: (params: CapturedCompletionsPayload) => {
mockState.lastParams = params;
const stream = {
async *[Symbol.asyncIterator]() {
yield {
choices: [{ delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
};
},
};
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
withResponse: () => Promise<{
data: typeof stream;
response: { status: number; headers: Headers };
}>;
};
promise.withResponse = async () => ({
data: stream,
response: { status: 200, headers: new Headers() },
});
return promise;
},
},
};
}
return { default: FakeOpenAI };
});
function createModel(overrides: Partial<Model<"openai-completions">> = {}): Model<"openai-completions"> {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
return {
...(baseModel as Omit<Model<"openai-completions">, "api">),
api: "openai-completions",
...overrides,
};
}
async function captureRequest(model: Model<"openai-completions">) {
await streamOpenAICompletions(
model,
{
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ apiKey: "test-key" },
).result();
return mockState.lastParams;
}
describe("openai-completions vllm priority", () => {
beforeEach(() => {
mockState.lastParams = undefined;
});
it("sends compat.vllmPriority as the top-level priority request field", async () => {
const payload = await captureRequest(
createModel({ compat: { vllmPriority: 10 } as Model<"openai-completions">["compat"] }),
);
expect(payload?.priority).toBe(10);
});
it("omits priority when vllmPriority is not set", async () => {
const payload = await captureRequest(createModel());
expect(payload?.priority).toBeUndefined();
});
});

Some files were not shown because too many files have changed in this diff Show More