chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
alexion committed 2026-10-15 17:58:48 -04:00
1 parent 719370c8f7
commit 6081e3e325
1743 files changed
+205459 -131704

No files matched your search

+2 -2
View File
@@ -140,7 +140,7 @@ describe("AI Providers Abort Tests", () => {
});
describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider Abort", () => {
const llm = getModel("azure-openai-responses", "gpt-4o-mini");
const llm = getModel("azure", "gpt-4o-mini");
const azureDeploymentName = resolveAzureDeploymentName(llm.id);
const azureOptions = azureDeploymentName ? { azureDeploymentName } : {};
@@ -178,7 +178,7 @@ describe("AI Providers Abort Tests", () => {
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider Abort", () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
const llm = getModel("together", "moonshotai/Kimi-K3");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm, { reasoningEffort: "high" });
@@ -4,13 +4,14 @@ import type { Api, Model } from "../src/types.ts";
const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [
"anthropic/claude-fable-5",
"anthropic/claude-haiku-5-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",
"fireworks/accounts/fireworks/models/deepseek-v4p1-flash",
"fireworks/accounts/fireworks/models/gpt-oss-120b",
"fireworks/accounts/fireworks/models/qwen3p8-max",
"opencode/claude-opus-4-8",
"opencode/claude-opus-5",
"vercel-ai-gateway/anthropic/claude-opus-4.8",
@@ -32,8 +33,12 @@ describe("Anthropic adaptive thinking model metadata", () => {
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),
flaggedModels.filter(
(modelId) =>
// Regression for #9323: Fireworks uses catalog effort metadata and
// verified fallbacks, not a fixed set of adaptive model names.
modelId.startsWith("fireworks/") ||
/(opus[-.](4[-.][678]|5)|sonnet[-.]4[-.]6|sonnet[-.]5|haiku[-.]5[-.]5|fable[-.]5)/.test(modelId),
),
);
});
@@ -4,7 +4,8 @@ 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";
import type { Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const mockState = vi.hoisted(() => ({
constructorOpts: undefined as Record<string, unknown> | undefined,
@@ -57,10 +58,10 @@ vi.mock("@anthropic-ai/sdk", () => {
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const neverAbortedSignal = new AbortController().signal;
const context: Context = {
const context = normalizeContext({
systemPrompt: "System prompt.",
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
});
const anthropicModel: Model<"anthropic-messages"> = {
id: "claude-test",
@@ -1,8 +1,7 @@
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";
import { getModel, normalizeContext } from "../src/compat.ts";
function createSseResponse(events: Array<{ event: string; data: string }>): Response {
const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n");
@@ -57,7 +56,7 @@ function eventsWithCacheCreation(
}
// 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() }] };
const context = normalizeContext({ 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 () => {
@@ -65,7 +64,9 @@ describe("Anthropic 1h cache write cost", () => {
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();
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);
@@ -73,10 +74,47 @@ describe("Anthropic 1h cache write cost", () => {
expect(result.usage.cost.cacheWrite).toBeCloseTo(7.75, 10);
});
// Regression for #9210: Vercel AI Gateway sends cache usage in message_delta, not message_start.
it("prices 1h cache writes reported only in message_delta", async () => {
const model = getModel("vercel-ai-gateway", "anthropic/claude-haiku-4.5");
const response = createSseResponse([
{
event: "message_start",
data: JSON.stringify({
type: "message_start",
message: { id: "msg_test", usage: { input_tokens: 0, output_tokens: 0 } },
}),
},
{
event: "message_delta",
data: JSON.stringify({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: {
input_tokens: 3,
output_tokens: 4,
cache_creation_input_tokens: 6535,
cache_creation: { ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: 6535 },
},
}),
},
{ event: "message_stop", data: JSON.stringify({ type: "message_stop" }) },
]);
const result = await streamAnthropic(model, context, {
client: createFakeAnthropicClient(response),
}).result();
expect(result.usage.cacheWrite).toBe(6535);
expect(result.usage.cacheWrite1h).toBe(6535);
expect(result.usage.cost.cacheWrite).toBeCloseTo((6535 * model.cost.input * 2) / 1_000_000, 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();
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);
@@ -4,6 +4,7 @@ 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";
import { normalizeContext } from "../src/utils/transcript.ts";
interface CapturedRequest {
headers: IncomingMessage["headers"];
@@ -32,20 +33,6 @@ const tool: Tool = {
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() }],
@@ -84,10 +71,14 @@ async function captureAnthropicRequest(
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",
});
const stream = streamAnthropic(
createModel(`http://127.0.0.1:${address.port}`, compat),
normalizeContext(context),
{
apiKey: "test-key",
cacheRetention: "none",
},
);
for await (const event of stream) {
if (event.type === "done" || event.type === "error") break;
@@ -112,14 +103,6 @@ function getFirstTool(body: Record<string, unknown>): Record<string, unknown> {
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());
@@ -141,26 +124,4 @@ describe("Anthropic eager tool input streaming compatibility", () => {
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",
});
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import { getModel, getModels, streamSimple } from "../src/compat.ts";
import type { AssistantMessage, Context, Model } from "../src/types.ts";
interface AnthropicPayload {
@@ -97,12 +97,29 @@ describe("Anthropic empty thinking signature compat", () => {
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);
// Regression for #9676: Vercel AI Gateway emits unsigned thinking for translated models.
it("allows empty thinking signatures for every Vercel AI Gateway model", () => {
const models = getModels("vercel-ai-gateway");
expect(models.length).toBeGreaterThan(0);
expect(models.every((model) => model.compat?.allowEmptySignature === true)).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: "" }]);
// Regression for #9323: Fireworks emits unsigned thinking that must survive replay.
it.each([
"accounts/fireworks/models/deepseek-v4p1-flash",
"accounts/fireworks/models/qwen3p8-max",
"accounts/fireworks/models/qwen3p8-2p4t-a95b",
"accounts/fireworks/models/nemotron-3-ultra-nvfp4",
] as const)("preserves unsigned thinking for Fireworks %s", async (modelId) => {
const model = getModel("fireworks", modelId);
expect(model.compat?.allowEmptySignature).toBe(true);
const context = makeContext("", "internal reasoning", "fireworks", modelId);
const assistant = context.messages[1] as AssistantMessage;
assistant.content.push({ type: "text", text: "answer" });
const payload = await capturePayload(model, context);
expect(payload.messages?.find((message) => message.role === "assistant")?.content).toEqual([
{ type: "thinking", thinking: "internal reasoning", signature: "" },
{ type: "text", text: "answer" },
]);
});
});
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import { streamSimple } from "../src/compat.ts";
import type { AssistantMessage, Context, Model } from "../src/types.ts";
interface AnthropicPayload {
messages: Array<{ role: string; content: string | Array<Record<string, unknown>> }>;
}
class PayloadCaptured extends Error {
constructor() {
super("payload captured");
this.name = "PayloadCaptured";
}
}
const model: Model<"anthropic-messages"> = {
id: "claude-test",
name: "Claude Test",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "http://127.0.0.1:9",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 1024,
};
const assistant: AssistantMessage = {
role: "assistant",
content: [
{ type: "toolCall", id: "call-1", name: "codemode", arguments: {} },
{ type: "toolCall", id: "call-2", name: "read", arguments: {} },
],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-test",
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(),
};
async function capturePayload(context: Context): Promise<AnthropicPayload> {
let capturedPayload: AnthropicPayload | undefined;
const stream = streamSimple(model, context, {
apiKey: "fake-key",
cacheRetention: "none",
onPayload: (payload) => {
capturedPayload = payload as AnthropicPayload;
throw new PayloadCaptured();
},
});
await stream.result();
if (!capturedPayload) throw new Error("Expected payload capture before request");
return capturedPayload;
}
const image = (data: string) => ({ type: "image", source: { type: "base64", media_type: "image/png", data } });
describe("Anthropic error tool results with images", () => {
// Regression for #10794: Anthropic rejects error tool results that contain images.
it("moves the images after the tool results of the turn", async () => {
const payload = await capturePayload({
messages: [
{ role: "user", content: "Show the screenshots", timestamp: Date.now() },
assistant,
{
role: "toolResult",
toolCallId: "call-1",
toolName: "codemode",
content: [
{ type: "text", text: "Script failed" },
{ type: "image", data: "AAAA", mimeType: "image/png" },
{ type: "image", data: "CCCC", mimeType: "image/png" },
{ type: "text", text: "Script error" },
],
isError: true,
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "call-2",
toolName: "read",
content: [{ type: "image", data: "BBBB", mimeType: "image/png" }],
isError: false,
timestamp: Date.now(),
},
],
});
expect(payload.messages[2]).toEqual({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call-1",
content: "Script failed\n(image attached after the tool results)\nScript error",
is_error: true,
},
{
type: "tool_result",
tool_use_id: "call-2",
content: [{ type: "text", text: "(see attached image)" }, image("BBBB")],
is_error: false,
},
{ type: "text", text: "Images from the error result of tool call call-1 (codemode):" },
image("AAAA"),
image("CCCC"),
],
});
});
it("keeps error tool results without images unchanged", async () => {
const payload = await capturePayload({
messages: [
{ role: "user", content: "Run it", timestamp: Date.now() },
{ ...assistant, content: [assistant.content[0]] },
{
role: "toolResult",
toolCallId: "call-1",
toolName: "codemode",
content: [{ type: "text", text: "Script failed" }],
isError: true,
timestamp: Date.now(),
},
],
});
expect(payload.messages[2]).toEqual({
role: "user",
content: [{ type: "tool_result", tool_use_id: "call-1", content: "Script failed", is_error: true }],
});
});
});
@@ -0,0 +1,119 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import type { Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
// Runs the real Anthropic SDK against a fake fetch to check how often the
// workload identity federation token exchange happens.
// https://github.com/earendil-works/pi/issues/10177
function sseResponse(): 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" } });
}
interface RecordedRequest {
path: string;
authorization: string | null;
}
function createFetch(requests: RecordedRequest[]): typeof globalThis.fetch {
return async (input, init) => {
const request = new Request(input, init);
const path = new URL(request.url).pathname;
requests.push({ path, authorization: request.headers.get("authorization") });
if (path === "/v1/oauth/token") {
return new Response(JSON.stringify({ access_token: "federated-token", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return sseResponse();
};
}
const context = normalizeContext({
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,
};
let tempDir: string;
let federationEnv: Record<string, string>;
beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), "pi-anthropic-federation-"));
const identityTokenFile = join(tempDir, "identity.jwt");
writeFileSync(identityTokenFile, "header.payload.signature");
federationEnv = {
ANTHROPIC_FEDERATION_RULE_ID: "fdrl_test",
ANTHROPIC_ORGANIZATION_ID: "org-test",
ANTHROPIC_IDENTITY_TOKEN_FILE: identityTokenFile,
};
});
afterAll(() => {
rmSync(tempDir, { recursive: true, force: true });
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("Anthropic workload identity federation with the SDK", () => {
it("exchanges the identity token once across requests", async () => {
const requests: RecordedRequest[] = [];
const fetch = createFetch(requests);
for (let i = 0; i < 3; i++) {
const message = await streamAnthropic(anthropicModel, context, { env: federationEnv, fetch }).result();
expect(message.stopReason).toBe("stop");
}
expect(requests.filter((request) => request.path === "/v1/oauth/token")).toHaveLength(1);
const messageRequests = requests.filter((request) => request.path === "/v1/messages");
expect(messageRequests).toHaveLength(3);
for (const request of messageRequests) {
expect(request.authorization).toBe("Bearer federated-token");
}
});
it("does not run the SDK credential chain for header-owned auth", async () => {
for (const [name, value] of Object.entries(federationEnv)) vi.stubEnv(name, value);
const requests: RecordedRequest[] = [];
const message = await streamAnthropic(anthropicModel, context, {
headers: { Authorization: "Bearer auth-token" },
fetch: createFetch(requests),
}).result();
expect(message.stopReason).toBe("stop");
expect(requests).toEqual([{ path: "/v1/messages", authorization: "Bearer auth-token" }]);
});
});
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { createModels } from "../src/models.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
import type { Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.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 {
opts: Record<string, unknown>;
constructor(opts: Record<string, unknown>) {
this.opts = opts;
mockState.constructorOpts = opts;
}
withOptions(options: Record<string, unknown>) {
return new FakeAnthropic({ ...this.opts, ...options });
}
beta = {
messages: {
create: (params: Record<string, unknown>) => {
mockState.createParams = params;
return { asResponse: async () => createSseResponse() };
},
},
};
}
return { default: FakeAnthropic };
});
const neverAbortedSignal = new AbortController().signal;
const federationEnv = {
ANTHROPIC_FEDERATION_RULE_ID: "fdrl_test",
ANTHROPIC_ORGANIZATION_ID: "org-test",
ANTHROPIC_SERVICE_ACCOUNT_ID: "svac_test",
ANTHROPIC_IDENTITY_TOKEN_FILE: "/tmp/identity.jwt",
};
const expectedConfig = {
organization_id: "org-test",
workspace_id: undefined,
authentication: {
type: "oidc_federation",
federation_rule_id: "fdrl_test",
service_account_id: "svac_test",
identity_token: { source: "file", path: "/tmp/identity.jwt" },
},
};
const context = normalizeContext({
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,
};
function resolveWithEnv(env: Record<string, string>) {
return anthropicProvider().auth.apiKey?.resolve({
ctx: { env: async (name) => env[name], fileExists: async () => false },
signal: neverAbortedSignal,
});
}
afterEach(() => {
mockState.constructorOpts = undefined;
mockState.createParams = undefined;
});
// https://github.com/earendil-works/pi/issues/10177
describe("Anthropic workload identity federation", () => {
it("resolves the federation variables as provider env with no request auth", async () => {
expect(await resolveWithEnv(federationEnv)).toEqual({
auth: {},
env: federationEnv,
source: "workload identity federation",
});
});
it("passes ANTHROPIC_WORKSPACE_ID through when set", async () => {
const result = await resolveWithEnv({ ...federationEnv, ANTHROPIC_WORKSPACE_ID: "wrkspc_test" });
expect(result?.env?.ANTHROPIC_WORKSPACE_ID).toBe("wrkspc_test");
});
it("is not configured when a federation variable is missing", async () => {
const { ANTHROPIC_IDENTITY_TOKEN_FILE: _omitted, ...partial } = federationEnv;
expect(await resolveWithEnv(partial)).toBeUndefined();
});
it("treats ANTHROPIC_SERVICE_ACCOUNT_ID as optional, like the SDK", async () => {
const { ANTHROPIC_SERVICE_ACCOUNT_ID: _omitted, ...partial } = federationEnv;
expect(await resolveWithEnv(partial)).toEqual({
auth: {},
env: partial,
source: "workload identity federation",
});
await streamAnthropic(anthropicModel, context, { env: partial }).result();
expect(mockState.constructorOpts?.config).toEqual({
...expectedConfig,
authentication: { ...expectedConfig.authentication, service_account_id: undefined },
});
});
it("keeps API key and auth token precedence over federation", async () => {
expect(await resolveWithEnv({ ...federationEnv, ANTHROPIC_API_KEY: "api-key" })).toEqual({
auth: { apiKey: "api-key" },
source: "ANTHROPIC_API_KEY",
});
expect(await resolveWithEnv({ ...federationEnv, ANTHROPIC_AUTH_TOKEN: "auth-token" })).toEqual({
auth: { headers: { Authorization: "Bearer auth-token" } },
source: "ANTHROPIC_AUTH_TOKEN",
});
});
it("hands the SDK a federation config instead of a key", async () => {
await streamAnthropic(anthropicModel, context, { env: federationEnv }).result();
expect(mockState.constructorOpts?.apiKey).toBeNull();
expect(mockState.constructorOpts?.authToken).toBeNull();
expect(mockState.constructorOpts?.config).toEqual(expectedConfig);
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string | null>;
expect(headers.Authorization).toBeUndefined();
expect(mockState.createParams?.betas ?? []).not.toContain("oauth-2025-04-20");
});
it("threads authContext federation variables through Models", async () => {
const models = createModels({
authContext: {
env: async (name) => federationEnv[name as keyof typeof federationEnv],
fileExists: async () => false,
},
});
models.setProvider(anthropicProvider());
await models.streamSimple(anthropicModel, context).result();
expect(mockState.constructorOpts?.apiKey).toBeNull();
expect(mockState.constructorOpts?.config).toEqual(expectedConfig);
});
it("lets an explicit API key win over federation env", async () => {
await streamAnthropic(anthropicModel, context, { apiKey: "explicit-key", env: federationEnv }).result();
expect(mockState.constructorOpts?.apiKey).toBe("explicit-key");
expect(mockState.constructorOpts?.config).toBeUndefined();
});
it("does not federate other anthropic-messages providers", async () => {
const kimi: Model<"anthropic-messages"> = {
...anthropicModel,
provider: "kimi-coding",
baseUrl: "https://api.kimi.com/coding",
};
const message = await streamAnthropic(kimi, context, { env: federationEnv }).result();
expect(message.stopReason).toBe("error");
expect(mockState.constructorOpts).toBeUndefined();
});
});
@@ -89,20 +89,6 @@ describe("Anthropic forceAdaptiveThinking compat override", () => {
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"),
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { stream } from "../src/api/anthropic-messages.ts";
import { getModel } from "../src/compat.ts";
import { type AnthropicOptions, stream } from "../src/api/anthropic-messages.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { AssistantMessage, Context, Model } from "../src/types.ts";
interface WireMessage {
@@ -64,14 +64,14 @@ function assistant(model: Model<"anthropic-messages">, level?: string): Assistan
async function capture(
model: Model<"anthropic-messages">,
context: Context,
effort?: "low" | "medium" | "high" | "xhigh" | "max",
options: Pick<AnthropicOptions, "effort" | "thinkingEnabled"> = {},
): Promise<{ payload: CapturedPayload; message: AssistantMessage }> {
let payload: CapturedPayload | undefined;
const result = stream(model, context, {
const result = stream(model, normalizeContext(context), {
apiKey: "test-key",
cacheRetention: "none",
thinkingEnabled: true,
effort,
...options,
onPayload: (value) => {
payload = value as CapturedPayload;
throw new Error("payload captured");
@@ -91,11 +91,11 @@ function effortMessages(payload: CapturedPayload): WireMessage[] {
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 first = await capture(model, { messages: [user("one", 1)] }, { effort: "low" });
const second = await capture(
model,
{ messages: [user("one", 1), assistant(model, "low"), user("two", 2)] },
"high",
{ effort: "high" },
);
expect(first.payload.messages).toEqual([
@@ -120,7 +120,7 @@ describe("Anthropic mid-conversation effort", () => {
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);
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);
});
@@ -143,15 +143,50 @@ describe("Anthropic mid-conversation effort", () => {
const { payload } = await capture(
model,
{ messages: [user("one", 1), legacy, user("two", 2), otherProvider, user("three", 3)] },
"medium",
{ effort: "medium" },
);
expect(effortMessages(payload)).toEqual([{ role: "system", content: [], output_config: { effort: "medium" } }]);
});
it("sends plain disabled thinking without effort when off is supported", async () => {
const model = managedModel();
model.thinkingLevelMap = { minimal: null, low: "low", medium: "medium", high: "high", max: "max" };
// Anthropic rejects block_binding with disabled thinking, and rejects effort markers
// that change level (here low, then medium) while thinking is disabled.
const { payload, message } = await capture(
model,
{
messages: [
user("one", 1),
assistant(model, "low"),
user("two", 2),
assistant(model, "medium"),
user("three", 3),
],
},
{ thinkingEnabled: false },
);
expect(payload.thinking).toEqual({ type: "disabled" });
expect(payload.output_config).toBeUndefined();
expect(effortMessages(payload)).toEqual([]);
expect(message.providerThinkingLevel).toBeUndefined();
});
it("keeps adaptive thinking when off is unsupported", async () => {
const { payload, message } = await capture(
managedModel(),
{ messages: [user("one", 1)] },
{ thinkingEnabled: false },
);
expect(payload.thinking?.type).toBe("adaptive");
expect(effortMessages(payload)).toEqual([{ role: "system", content: [], output_config: { effort: "high" } }]);
expect(message.providerThinkingLevel).toBe("high");
});
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");
const { payload, message } = await capture(model, { messages: [user("one", 1)] }, { effort: "low" });
expect(payload.messages).toEqual([{ role: "user", content: "one" }]);
expect(payload.output_config).toEqual({ effort: "low" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
@@ -182,15 +217,11 @@ describe("Anthropic mid-conversation effort", () => {
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();
const result = await stream(managedModel(), normalizeContext({ 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");
@@ -207,6 +238,19 @@ describe("Anthropic mid-conversation effort", () => {
expect(openRouter.baseUrl).toBe("https://openrouter.ai/api");
expect(openRouter.compat?.supportsMidConvoEffort).toBe(true);
expect(unsupported.compat?.supportsMidConvoEffort).toBeUndefined();
// OpenRouter rejects configuration_update on Opus 5 but accepts it on Fable 5.1.
expect(getModel("openrouter", "anthropic/claude-opus-5").compat?.supportsMidConvoEffort).toBeUndefined();
expect(getModel("anthropic", "claude-opus-5").compat?.allowedFallbackModels).toBeUndefined();
});
it("supports off only on managed models that accept disabled thinking", () => {
for (const id of ["claude-opus-5", "claude-haiku-5-5"] as const) {
const model = getModel("anthropic", id);
expect(model.compat?.supportsMidConvoEffort).toBe(true);
expect(model.thinkingLevelMap?.off).not.toBeNull();
}
for (const id of ["claude-opus-5-5", "claude-sonnet-5-5", "claude-fable-5-1"] as const) {
expect(getModel("anthropic", id).thinkingLevelMap?.off).toBeNull();
}
});
});
+155
View File
@@ -1,8 +1,10 @@
import { createServer, type Server } from "node:http";
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;
const nativeFetch = globalThis.fetch;
function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), {
@@ -61,6 +63,7 @@ describe.sequential("Anthropic OAuth", () => {
if (event.type === "auth_url") authUrl = event.url;
},
prompt: async (prompt) => {
if (prompt.type === "select") return "browser";
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
const url = new URL(authUrl);
const state = url.searchParams.get("state");
@@ -75,6 +78,70 @@ describe.sequential("Anthropic OAuth", () => {
expect(fetchMock).toHaveBeenCalledOnce();
});
it("offers browser login first and uses the selected Anthropic copy code flow", async () => {
const selectPrompts: Array<{
message: string;
options: readonly { id: string; label: string }[];
}> = [];
let authUrl = "";
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
const body = getJsonBody(init);
expect(body.grant_type).toBe("authorization_code");
expect(body.code).toBe("copied-code");
expect(body.state).toBe(new URL(authUrl).searchParams.get("state"));
expect(body.redirect_uri).toBe("https://platform.claude.com/oauth/code/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 === "select") {
selectPrompts.push(prompt);
return "copy_code";
}
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
return `copied-code#${new URL(authUrl).searchParams.get("state")}`;
},
});
expect(credentials.access).toBe("access-token");
expect(credentials.refresh).toBe("refresh-token");
expect(new URL(authUrl).searchParams.get("redirect_uri")).toBe("https://platform.claude.com/oauth/code/callback");
expect(fetchMock).toHaveBeenCalledOnce();
expect(selectPrompts).toEqual([
{
type: "select",
message: "Select Anthropic login method:",
options: [
{ id: "browser", label: "Browser login (default)" },
{ id: "copy_code", label: "Copy code login (headless)" },
],
},
]);
});
it("cancels when Anthropic login method selection is cancelled", async () => {
await expect(
anthropicOAuth.login({
signal: neverAbortedSignal,
prompt: async () => {
throw new Error("Login cancelled");
},
notify: () => {},
}),
).rejects.toThrow("Login cancelled");
});
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");
@@ -126,6 +193,7 @@ describe.sequential("Anthropic OAuth", () => {
notify: (event) => events.push(event),
prompt: async (prompt) => {
prompts.push(prompt);
if (prompt.type === "select") return "browser";
if (prompt.type === "manual_code") {
manualSignal = prompt.signal;
return "the-code";
@@ -141,4 +209,91 @@ describe.sequential("Anthropic OAuth", () => {
// the prompt's signal is aborted once login settles, so UIs can dismiss it
expect(manualSignal?.aborted).toBe(true);
});
it("completes login through the browser callback and shows the sign-in page", async () => {
const login = await loginThroughBrowserCallback();
expect(login.credential.access).toBe("access");
expect(login.exchangedCode).toBe("browser-code");
expect(login.exchangedRedirectUri).toBe(login.redirectUri);
expect(login.pageStatus).toBe(200);
expect(login.pageText).toContain("Signed in to Anthropic.");
});
// #10571
it("falls back to a free callback port when the preferred port cannot be bound", async () => {
const blocker = createServer();
await listen(blocker, 53692);
try {
const login = await loginThroughBrowserCallback();
const redirectUri = new URL(login.redirectUri);
expect(redirectUri.hostname).toBe("localhost");
expect(redirectUri.pathname).toBe("/callback");
expect(redirectUri.port).not.toBe("53692");
expect(login.credential.access).toBe("access");
expect(login.exchangedRedirectUri).toBe(login.redirectUri);
expect(login.pageStatus).toBe(200);
} finally {
await new Promise<void>((resolve) => blocker.close(() => resolve()));
}
});
});
function listen(server: Server, port: number): Promise<void> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
}
async function loginThroughBrowserCallback() {
let exchangedCode: string | undefined;
let exchangedRedirectUri: string | undefined;
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
if (getUrl(input) !== "https://platform.claude.com/v1/oauth/token") return nativeFetch(input as string, init);
const body = getJsonBody(init);
exchangedCode = body.code;
exchangedRedirectUri = body.redirect_uri;
return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 });
}),
);
let redirectUri = "";
let callbackPage: Promise<Response> | undefined;
const credential = await anthropicOAuth.login({
signal: neverAbortedSignal,
notify: (event) => {
if (event.type !== "auth_url") return;
const params = new URL(event.url).searchParams;
redirectUri = params.get("redirect_uri") ?? "";
const callbackUrl = new URL(redirectUri);
callbackUrl.hostname = "127.0.0.1";
callbackUrl.search = new URLSearchParams({
code: "browser-code",
state: params.get("state") ?? "",
}).toString();
callbackPage = nativeFetch(callbackUrl);
},
prompt: (prompt) =>
prompt.type === "select"
? Promise.resolve("browser")
: new Promise((_, reject) => {
prompt.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
}),
});
const page = await callbackPage;
return {
credential,
redirectUri,
exchangedCode,
exchangedRedirectUri,
pageStatus: page?.status,
pageText: await page?.text(),
};
}
+135 -20
View File
@@ -2,8 +2,9 @@ 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";
import { transformMessages } from "../src/api/transform-messages.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { Api, Model, 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");
@@ -80,7 +81,121 @@ function createFakeAnthropicClient(response: Response): Anthropic {
} as unknown as Anthropic;
}
type ResponseContentBlock = { type: "thinking"; thinking: string; signature: string } | { type: "text"; text: string };
function createResponseModelSseResponse(model: string, contentBlock: ResponseContentBlock): Response {
return createSseResponse([
{
event: "message_start",
data: JSON.stringify({
type: "message_start",
message: { id: "msg_response_model", model, usage: { input_tokens: 100, output_tokens: 0 } },
}),
},
{
event: "content_block_start",
data: JSON.stringify({ type: "content_block_start", index: 0, content_block: contentBlock }),
},
{ 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: 20 },
}),
},
{ event: "message_stop", data: JSON.stringify({ type: "message_stop" }) },
]);
}
describe("Anthropic raw SSE parsing", () => {
it("forwards parsed provider stream events in order", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const providerEvents: unknown[] = [];
const eventModels: Model<Api>[] = [];
const result = await streamAnthropic(
model,
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
{
client: createFakeAnthropicClient(createSseResponse(minimalAnthropicEvents)),
onProviderStreamEvent: async (event, eventModel) => {
await Promise.resolve();
providerEvents.push(event);
eventModels.push(eventModel);
},
},
).result();
expect(result.stopReason).toBe("stop");
expect(providerEvents.map((event) => (event as { type: string }).type)).toEqual([
"message_start",
"content_block_start",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
]);
expect(eventModels).toEqual([model, model, model, model, model, model]);
});
it("keeps signed thinking replayable when a proxy relabels the model", async () => {
// Regression test for earendil-works/pi#9188.
const model = getModel("anthropic", "claude-opus-5");
const responseModel = "kimi-for-coding";
const initialContext = normalizeContext({
messages: [{ role: "user", content: "Hello", timestamp: 1 }],
});
const first = await streamAnthropic(model, initialContext, {
client: createFakeAnthropicClient(
createResponseModelSseResponse(responseModel, {
type: "thinking",
thinking: "reasoning",
signature: "signature",
}),
),
}).result();
expect(first.model).toBe(model.id);
expect(first.responseModel).toBe(responseModel);
const transformed = transformMessages([...initialContext.messages, first], model);
const replayedAssistant = transformed.find((message) => message.role === "assistant");
expect(replayedAssistant?.content).toEqual([
{ type: "thinking", thinking: "reasoning", thinkingSignature: "signature" },
]);
});
it("uses a returned fallback model for cost attribution", async () => {
const fallbackModel = "fallback-model";
const model: Model<"anthropic-messages"> = {
...getModel("anthropic", "claude-opus-5"),
compat: {
allowedFallbackModels: [
{
provider: "anthropic",
model: fallbackModel,
cost: { input: 3, output: 5, cacheRead: 0, cacheWrite: 0 },
},
],
},
};
const result = await streamAnthropic(
model,
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
{
client: createFakeAnthropicClient(
createResponseModelSseResponse(fallbackModel, { type: "text", text: "done" }),
),
},
).result();
expect(result.model).toBe(model.id);
expect(result.responseModel).toBe(fallbackModel);
expect(result.usage.cost.input).toBeCloseTo(0.0003, 10);
expect(result.usage.cost.output).toBeCloseTo(0.0001, 10);
});
it("fails safely when Anthropic falls back after output begins", async () => {
const model = getModel("anthropic", "claude-opus-5");
const response = createSseResponse([
@@ -120,7 +235,7 @@ describe("Anthropic raw SSE parsing", () => {
const result = await streamAnthropic(
model,
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
{ client: createFakeAnthropicClient(response) },
).result();
@@ -143,7 +258,7 @@ describe("Anthropic raw SSE parsing", () => {
await streamAnthropic(
getModel("anthropic", "claude-fable-5-1"),
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
{
client,
onPayload: (payload) => ({ ...(payload as Record<string, unknown>), stream: false }),
@@ -167,8 +282,8 @@ describe("Anthropic raw SSE parsing", () => {
} as unknown as Anthropic;
await streamAnthropic(
getModel("openrouter", "anthropic/claude-3-haiku"),
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
getModel("openrouter", "anthropic/claude-haiku-4.5"),
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
{ client, thinkingEnabled: false },
).result();
@@ -190,7 +305,7 @@ describe("Anthropic raw SSE parsing", () => {
const result = await streamAnthropic(
getModel("anthropic", "claude-fable-5-1"),
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
{ client },
).result();
@@ -220,7 +335,7 @@ describe("Anthropic raw SSE parsing", () => {
const result = await streamAnthropic(
getModel("anthropic", "claude-fable-5-1"),
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
{ client: createFakeAnthropicClient(createSseResponse(events)) },
).result();
@@ -242,7 +357,7 @@ describe("Anthropic raw SSE parsing", () => {
});
it("repairs malformed SSE JSON and malformed streamed tool JSON", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "Use the edit tool.", timestamp: Date.now() }],
tools: [
{
@@ -254,7 +369,7 @@ describe("Anthropic raw SSE parsing", () => {
}),
},
],
};
});
const malformedToolJsonDelta = String.raw`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"A\H\",\"text\":\"col1 col2\"}"}}`;
@@ -329,9 +444,9 @@ describe("Anthropic raw SSE parsing", () => {
it("preserves content from content_block_start events", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
};
});
const response = createSseResponse([
{
event: "message_start",
@@ -427,9 +542,9 @@ describe("Anthropic raw SSE parsing", () => {
it("preserves refusal stop details from message_delta", async () => {
const model = getModel("anthropic", "claude-fable-5");
const context: Context = {
const context = normalizeContext({
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([
@@ -486,9 +601,9 @@ describe("Anthropic raw SSE parsing", () => {
it("preserves sensitive stop reasons with a descriptive error message", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "blocked request", timestamp: Date.now() }],
};
});
const response = createSseResponse([
{
event: "message_start",
@@ -536,9 +651,9 @@ describe("Anthropic raw SSE parsing", () => {
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 = {
const context = normalizeContext({
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
};
});
const response = createSseResponse(
minimalAnthropicEvents.map((event) =>
event.event === "message_delta"
@@ -564,9 +679,9 @@ describe("Anthropic raw SSE parsing", () => {
it("ignores unknown SSE events after message_stop", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
};
});
const response = createSseResponse([
...minimalAnthropicEvents,
{ event: "done", data: "[DONE]" },
@@ -0,0 +1,288 @@
import Anthropic from "@anthropic-ai/sdk";
import type { BetaTool, MessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/beta/messages/messages.js";
import { Type } from "typebox";
import { describe, expect, it, vi } from "vitest";
import { type AnthropicOptions, stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { stream as streamPiMessages } from "../src/api/pi-messages.ts";
import type { Model, Tool, TranscriptContext } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const grammarError =
"The compiled grammar is too large, which would cause performance issues. Simplify your tool schemas or reduce the number of strict tools.";
const model: Model<"anthropic-messages"> = {
id: "claude-haiku-4-5",
name: "Test Claude",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://anthropic.example",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 100,
compat: { supportsStrictTools: true },
};
function tool(name: string, strict?: "prefer" | "require"): Tool {
return {
name,
description: "Look up a value",
parameters: Type.Object({ value: Type.String(), optional: Type.Optional(Type.Number()) }),
...(strict ? { constrainedSampling: { type: "json_schema", strict } } : {}),
};
}
function context(tools: Tool[]): TranscriptContext {
return normalizeContext({ tools, messages: [{ role: "user", content: "Hello", timestamp: 1 }] });
}
function errorResponse(message = grammarError, status = 400, type = "invalid_request_error"): Response {
return Response.json({ type: "error", error: { type, message } }, { status });
}
function successResponse(): Response {
const events = [
{
type: "message_start",
message: { id: "msg_test", model: model.id, usage: { input_tokens: 10, output_tokens: 0 } },
},
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
];
return new Response(events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), {
headers: { "content-type": "text/event-stream" },
});
}
function harness(respond: (payload: MessageCreateParamsStreaming, attempt: number) => Response) {
const payloads: MessageCreateParamsStreaming[] = [];
const fetch: NonNullable<AnthropicOptions["fetch"]> = async (_input, init) => {
const payload = JSON.parse(init?.body as string) as MessageCreateParamsStreaming;
payloads.push(payload);
return respond(payload, payloads.length);
};
return { payloads, options: { apiKey: "test-key", fetch, maxRetries: 0 } satisfies AnthropicOptions };
}
// Regression tests for https://github.com/earendil-works/pi/issues/10409.
describe("Anthropic preferred strict tool fallback", () => {
it.each([
grammarError,
// Observed from the live API: 32 strict tools, then 20 tools with 24 optional fields each.
"Too many strict tools (32). The maximum number of strict tools supported is 20. Try reducing the number of tools marked as strict.",
"Schemas contains too many parameters with union types (480 parameters with type arrays or anyOf). This causes exponential compilation cost. Reduce the number of nullable or union-typed parameters (limit: 16 parameters with unions).",
])("retries %s with original non-strict schemas and preserves required tools", async (message) => {
const preferred = tool("preferred", "prefer");
const required = tool("required", "require");
const ordinary = tool("ordinary");
const transcript = context([preferred, required, ordinary]);
const original = JSON.stringify(transcript);
// Use the real SDK's HTTP error parsing, not a synthetic thrown Error.
const { payloads, options } = harness((_payload, attempt) =>
attempt === 1 ? errorResponse(message) : successResponse(),
);
const onPayload = vi.fn((payload: unknown) => ({
...(payload as MessageCreateParamsStreaming),
inference_geo: "us",
stream: false,
}));
const onResponse = vi.fn();
const stream = streamAnthropic(model, transcript, { ...options, onPayload, onResponse, cacheRetention: "long" });
const events: string[] = [];
for await (const event of stream) events.push(event.type);
const result = await stream.result();
expect(result.stopReason).toBe("stop");
expect(events).toEqual(["start", "done"]);
expect(payloads).toHaveLength(2);
expect(onPayload).toHaveBeenCalledTimes(2);
expect(onResponse).toHaveBeenCalledTimes(1);
const first = payloads[0].tools as BetaTool[];
const second = payloads[1].tools as BetaTool[];
expect(first[0]).toMatchObject({
strict: true,
input_schema: {
required: ["value", "optional"],
properties: { optional: { anyOf: [{ type: "number" }, { type: "null" }] } },
},
});
expect(second[0].strict).toBeUndefined();
expect(second[0].input_schema).toEqual({
type: "object",
properties: { value: { type: "string" }, optional: { type: "number" } },
required: ["value"],
});
expect(second[1]).toEqual(first[1]);
expect(second[1].strict).toBe(true);
expect(second[2]).toEqual(first[2]);
expect(second[2].cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
expect(payloads.map((payload) => payload.stream)).toEqual([true, true]);
expect(payloads[1]).toMatchObject({ inference_geo: "us" });
expect(JSON.stringify(transcript)).toBe(original);
expect(result.diagnostics?.[0]).toMatchObject({
type: "anthropic_strict_tools_fallback",
details: { reason: "strict_tool_limits_exceeded" },
});
});
it("downgrades inline additions and same-name redefinitions without downgrading require", async () => {
const transcript = context([tool("lookup", "require")]);
transcript.messages.push(
{ role: "system", content: "", toolsAdded: [tool("lookup", "prefer")], timestamp: 2 },
{ role: "system", content: "", toolsAdded: [tool("lookup", "require")], timestamp: 3 },
);
const { payloads, options } = harness((_payload, attempt) =>
attempt === 1 ? errorResponse() : successResponse(),
);
const result = await streamAnthropic(
{
...model,
compat: { ...model.compat, supportsMidConvoSystemMessages: true, supportsMidConvoToolChanges: true },
},
transcript,
{ ...options, apiKey: "sk-ant-oat-test" },
).result();
expect(result.stopReason).toBe("stop");
expect(payloads).toHaveLength(2);
expect(payloads[1].tools).toEqual(payloads[0].tools);
const additions = payloads.map((payload) =>
payload.messages.flatMap((message) =>
Array.isArray(message.content)
? message.content.flatMap((block) =>
block.type === "tool_addition" && block.tool.type === "tool_definition"
? [block.tool.definition as BetaTool]
: [],
)
: [],
),
);
expect(additions[0].map((definition) => definition.strict)).toEqual([true, true]);
expect(additions[1].map((definition) => definition.strict)).toEqual([undefined, true]);
expect(additions[1][0].input_schema.required).toEqual(["value"]);
expect(additions[1][1]).toEqual(additions[0][1]);
});
it.each([
["required", [tool("lookup", "require")]],
["ordinary", [tool("lookup")]],
[
"locally unsupported prefer",
[{ ...tool("lookup", "prefer"), parameters: Type.Object({ value: Type.Number({ minimum: 1 }) }) }],
],
])("does not retry a %s tool set", async (_name, tools) => {
const { payloads, options } = harness(() => errorResponse());
const result = await streamAnthropic(model, context(tools), options).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain(grammarError);
expect(payloads).toHaveLength(1);
});
it.each([
["unrelated 400", "Invalid tool schema", 400, "invalid_request_error"],
["wrong error type", grammarError, 400, "authentication_error"],
["wrong HTTP status", grammarError, 500, "invalid_request_error"],
])("does not downgrade on %s", async (_name, message, status, type) => {
const { payloads, options } = harness(() => errorResponse(message, status, type));
const result = await streamAnthropic(model, context([tool("lookup", "prefer")]), options).result();
expect(result.stopReason).toBe("error");
expect(payloads).toHaveLength(1);
});
it("surfaces a second rejection rather than retrying again", async () => {
const { payloads, options } = harness((_payload, attempt) =>
errorResponse(attempt === 1 ? grammarError : "Required grammar is still too large"),
);
const result = await streamAnthropic(
model,
context([tool("lookup", "prefer"), tool("required", "require")]),
options,
).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Required grammar is still too large");
expect(payloads).toHaveLength(2);
expect((payloads[1].tools as BetaTool[])[1].strict).toBe(true);
});
it("does not downgrade a hook exception, even if it looks like the provider rejection", async () => {
const { payloads, options } = harness(() => successResponse());
const result = await streamAnthropic(model, context([tool("lookup", "prefer")]), {
...options,
onPayload: () => {
throw Anthropic.APIError.generate(
400,
{ error: { type: "invalid_request_error", message: grammarError } },
grammarError,
new Headers(),
);
},
}).result();
expect(result.stopReason).toBe("error");
expect(payloads).toHaveLength(0);
expect(result.diagnostics).toBeUndefined();
});
it("honors cancellation before the fallback attempt", async () => {
const controller = new AbortController();
const { payloads, options } = harness(() => {
controller.abort();
return errorResponse();
});
const result = await streamAnthropic(model, context([tool("lookup", "prefer")]), {
...options,
signal: controller.signal,
}).result();
expect(result.stopReason).toBe("aborted");
expect(payloads).toHaveLength(1);
});
it("does not retry an SSE error after response headers", async () => {
const { payloads, options } = harness(
() =>
new Response(
`event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: grammarError } })}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
);
const result = await streamAnthropic(model, context([tool("lookup", "prefer")]), options).result();
expect(result.stopReason).toBe("error");
expect(payloads).toHaveLength(1);
});
it("does not disable strict sampling on later requests", async () => {
const { payloads, options } = harness((_payload, attempt) =>
attempt === 1 ? errorResponse() : successResponse(),
);
const transcript = context([tool("lookup", "prefer")]);
await streamAnthropic(model, transcript, options).result();
await streamAnthropic(model, transcript, options).result();
expect(payloads.map((payload) => (payload.tools as BetaTool[])[0].strict)).toEqual([true, undefined, true]);
});
it("preserves prefer through pi-messages so the gateway's Anthropic adapter can fall back", async () => {
const { payloads, options } = harness((_payload, attempt) =>
attempt === 1 ? errorResponse() : successResponse(),
);
const transcript = context([tool("lookup", "prefer")]);
const gatewayModel: Model<"pi-messages"> = {
...model,
api: "pi-messages",
provider: "radius",
baseUrl: "https://radius.example/v1",
compat: undefined,
};
const result = await streamPiMessages(gatewayModel, transcript, {
apiKey: "radius-test",
fetch: async (_input, init) => {
const request = JSON.parse(init?.body as string) as { context: TranscriptContext };
expect(request.context).toEqual(JSON.parse(JSON.stringify(transcript)));
const upstream = await streamAnthropic(model, request.context, options).result();
return new Response(
`data: ${JSON.stringify({ type: "done", reason: upstream.stopReason, usage: upstream.usage })}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
);
},
}).result();
expect(result.stopReason).toBe("stop");
expect(payloads.map((payload) => (payload.tools as BetaTool[])[0].strict)).toEqual([true, undefined]);
});
});
@@ -0,0 +1,124 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import type { Model, Tool } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
interface AnthropicToolPayload {
tools?: Array<{ strict?: boolean; input_schema: Record<string, unknown> }>;
}
class PayloadCaptured extends Error {
constructor() {
super("payload captured");
this.name = "PayloadCaptured";
}
}
function createModel(): Model<"anthropic-messages"> {
return {
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
api: "anthropic-messages",
provider: "test-anthropic",
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: { forceAdaptiveThinking: true, supportsStrictTools: true },
};
}
function createTool(parameters: Tool["parameters"], constrainedSampling?: Tool["constrainedSampling"]): Tool {
return {
name: "lookup",
description: "Look up a value",
parameters,
...(constrainedSampling ? { constrainedSampling } : {}),
};
}
function createStrictTool(parameters: Tool["parameters"]): Tool {
return createTool(parameters, { type: "json_schema", strict: "prefer" });
}
async function captureFirstTool(tool: Tool): Promise<NonNullable<AnthropicToolPayload["tools"]>[number]> {
let payload: AnthropicToolPayload | undefined;
const stream = streamAnthropic(
createModel(),
normalizeContext({ messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }], tools: [tool] }),
{
apiKey: "test-key",
cacheRetention: "none",
onPayload: (value) => {
payload = value as AnthropicToolPayload;
throw new PayloadCaptured();
},
},
);
await stream.result();
const firstTool = payload?.tools?.[0];
if (!firstTool) {
throw new Error("Expected a tool in the captured Anthropic payload");
}
return firstTool;
}
describe("Anthropic strict tool schemas", () => {
it("only sends the full input schema for strict JSON-schema tools", async () => {
const legacyParameters = Type.Object(
{ value: Type.String() },
{ additionalProperties: false, title: "LookupInput" },
);
const legacyTool = await captureFirstTool(createTool(legacyParameters));
expect(legacyTool.strict).toBeUndefined();
expect(legacyTool.input_schema).toEqual({
type: "object",
properties: legacyParameters.properties,
required: legacyParameters.required,
});
const strictTool = await captureFirstTool(
createStrictTool(
Type.Object(
{ value: Type.String(), optional: Type.Optional(Type.Number()) },
{ title: "StrictLookupInput" },
),
),
);
expect(strictTool.strict).toBe(true);
expect(strictTool.input_schema).toMatchObject({
additionalProperties: false,
required: ["value", "optional"],
properties: { optional: { anyOf: [{ type: "number" }, { type: "null" }] } },
title: "StrictLookupInput",
});
});
// https://github.com/earendil-works/pi/issues/9953
it("sends prefer tools non-strict when they use keywords Anthropic strict mode rejects", async () => {
const unsupportedParameters: Tool["parameters"][] = [
Type.Object({ timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: 300000 })) }),
Type.Object({ options: Type.Object({ tags: Type.Array(Type.String(), { minItems: 2 }) }) }),
Type.Object({ expression: Type.String({ format: "regex" }) }),
];
for (const parameters of unsupportedParameters) {
const tool = await captureFirstTool(createStrictTool(parameters));
expect(tool.strict).toBeUndefined();
}
const supportedTool = await captureFirstTool(
createStrictTool(
Type.Object({
code: Type.String({ minLength: 1, maxLength: 1000, pattern: "^[a-z]+$" }),
url: Type.String({ format: "uri" }),
tags: Type.Array(Type.String(), { minItems: 1 }),
}),
),
);
expect(supportedTool.strict).toBe(true);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { stream } from "../src/api/anthropic-messages.ts";
import { getModel } from "../src/compat.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { AssistantMessage, Context } from "../src/types.ts";
const enabled = Boolean(process.env.ANTHROPIC_API_KEY);
@@ -20,7 +20,7 @@ function strictBinding(payload: unknown): unknown {
}
async function request(context: Context, effort: "low" | "high"): Promise<AssistantMessage> {
return stream(model, context, {
return stream(model, normalizeContext(context), {
apiKey: process.env.ANTHROPIC_API_KEY,
cacheRetention: "none",
maxTokens: 1536,
@@ -1,606 +0,0 @@
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",
);
});
});
+66 -20
View File
@@ -1,9 +1,10 @@
import { arch, platform, release } from "node:os";
import type { ResponseStreamEvent } from "openai/resources/responses/responses.js";
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";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { Api, Context, Model } from "../src/types.ts";
interface CapturedAzureClientOptions {
apiKey: string;
@@ -22,6 +23,7 @@ interface CapturedAzureResponsesPayload {
const azureMock = vi.hoisted(() => ({
constructorCalls: [] as CapturedAzureClientOptions[],
lastParams: undefined as CapturedAzureResponsesPayload | undefined,
streamEvents: undefined as ResponseStreamEvent[] | undefined,
}));
vi.mock("openai", () => {
@@ -29,7 +31,16 @@ vi.mock("openai", () => {
responses = {
create: (params: CapturedAzureResponsesPayload) => {
azureMock.lastParams = params;
throw new Error("mock create");
const events = azureMock.streamEvents;
if (!events) throw new Error("mock create");
return {
withResponse: async () => ({
data: (async function* () {
for (const event of events) yield event;
})(),
response: { status: 200, headers: new Headers() },
}),
};
},
};
@@ -55,6 +66,7 @@ const originalAzureOpenAIApiKey = process.env.AZURE_OPENAI_API_KEY;
beforeEach(() => {
azureMock.constructorCalls.length = 0;
azureMock.lastParams = undefined;
azureMock.streamEvents = undefined;
delete process.env.AZURE_OPENAI_BASE_URL;
delete process.env.AZURE_OPENAI_RESOURCE_NAME;
delete process.env.AZURE_OPENAI_API_VERSION;
@@ -89,15 +101,15 @@ afterEach(() => {
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();
const model = getModel("azure", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, normalizeContext(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, {
const model = getModel("azure", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, normalizeContext(context), {
apiKey: "test-api-key",
azureBaseUrl: "https://my-resource.openai.azure.com",
headers,
@@ -106,7 +118,7 @@ async function captureClientHeaders(headers?: Record<string, string>): Promise<R
return azureMock.constructorCalls[0].defaultHeaders ?? {};
}
describe("azure-openai-responses base URL normalization", () => {
describe("azure 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");
@@ -154,15 +166,17 @@ describe("azure-openai-responses base URL normalization", () => {
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();
const model = getModel("azure", "gpt-4o-mini");
const result = await streamAzureOpenAIResponses(model, normalizeContext(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, {
const model = getModel("azure", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, normalizeContext(context), {
apiKey: "test-api-key",
azureBaseUrl: "https://my-resource.openai.azure.com",
sessionId: "x".repeat(67),
@@ -172,8 +186,8 @@ describe("azure-openai-responses base URL normalization", () => {
});
it("disables server-side response storage", async () => {
const model = getModel("azure-openai-responses", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, context, {
const model = getModel("azure", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, normalizeContext(context), {
apiKey: "test-api-key",
azureBaseUrl: "https://my-resource.openai.azure.com",
}).result();
@@ -182,7 +196,7 @@ describe("azure-openai-responses base URL normalization", () => {
});
it("honors supportsStrictMode: false", async () => {
const baseModel = getModel("azure-openai-responses", "gpt-4o-mini");
const baseModel = getModel("azure", "gpt-4o-mini");
const model: Model<"azure-openai-responses"> = {
...baseModel,
compat: { ...baseModel.compat, supportsStrictMode: false },
@@ -190,7 +204,7 @@ describe("azure-openai-responses base URL normalization", () => {
await streamAzureOpenAIResponses(
model,
{
normalizeContext({
...context,
tools: [
{
@@ -200,7 +214,7 @@ describe("azure-openai-responses base URL normalization", () => {
constrainedSampling: { type: "json_schema", strict: "prefer" },
},
],
},
}),
{ apiKey: "test-api-key", azureBaseUrl: "https://my-resource.openai.azure.com" },
).result();
@@ -209,14 +223,46 @@ describe("azure-openai-responses base URL normalization", () => {
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();
const model = getModel("azure", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, normalizeContext(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", () => {
describe("azure provider stream events", () => {
it("forwards parsed events in order before normalizing the response", async () => {
azureMock.streamEvents = [
{ type: "response.created", sequence_number: 0, response: { id: "resp_azure" } } as ResponseStreamEvent,
{
type: "response.completed",
sequence_number: 1,
response: { id: "resp_azure", status: "completed" },
} as ResponseStreamEvent,
];
const model = getModel("azure", "gpt-4o-mini");
const received: unknown[] = [];
const eventModels: Model<Api>[] = [];
const result = await streamAzureOpenAIResponses(model, normalizeContext(context), {
apiKey: "test-api-key",
azureBaseUrl: "https://my-resource.openai.azure.com",
onProviderStreamEvent: async (event, eventModel) => {
await Promise.resolve();
received.push(event);
eventModels.push(eventModel);
},
}).result();
expect(received).toEqual(azureMock.streamEvents);
expect(received[0]).toBe(azureMock.streamEvents[0]);
expect(received[1]).toBe(azureMock.streamEvents[1]);
expect(eventModels).toEqual([model, model]);
expect(result.stopReason).toBe("stop");
expect(result.responseId).toBe("resp_azure");
});
});
describe("azure user agent", () => {
it("uses pi's User-Agent by default", async () => {
expect((await captureClientHeaders())["User-Agent"]).toBe(PI_USER_AGENT);
});
@@ -0,0 +1,280 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getModel, normalizeContext } from "../src/compat.ts";
import { azureProvider } from "../src/providers/azure.ts";
import type { AssistantMessage, Context } from "../src/types.ts";
interface FakeOpenAIClientOptions {
apiKey: string;
baseURL: string;
}
interface CapturedCompletionsPayload {
model?: string;
messages?: Array<{ role: string; content?: unknown; reasoning_content?: string }>;
reasoning_effort?: string;
thinking?: unknown;
prompt_cache_key?: string;
prompt_cache_retention?: string;
}
const mockState = vi.hoisted(() => ({
lastParams: undefined as CapturedCompletionsPayload | undefined,
lastClientOptions: undefined as FakeOpenAIClientOptions | undefined,
dispatchedTo: undefined as "chat.completions" | "responses" | undefined,
}));
vi.mock("openai", () => {
class FakeOpenAI {
chat = {
completions: {
create: (params: CapturedCompletionsPayload) => {
mockState.lastParams = params;
mockState.dispatchedTo = "chat.completions";
const stream = {
async *[Symbol.asyncIterator]() {
yield {
choices: [{ delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
prompt_tokens_details: { cached_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;
}
}
class FakeAzureOpenAI {
responses = {
create: () => {
mockState.dispatchedTo = "responses";
throw new Error("responses reached");
},
};
}
return { default: FakeOpenAI, AzureOpenAI: FakeAzureOpenAI };
});
const azure = azureProvider();
const originalBaseUrl = process.env.AZURE_OPENAI_BASE_URL;
const originalCacheRetention = process.env.PI_CACHE_RETENTION;
const originalDeploymentMap = process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP;
beforeEach(() => {
mockState.lastParams = undefined;
mockState.lastClientOptions = undefined;
mockState.dispatchedTo = undefined;
delete process.env.PI_CACHE_RETENTION;
delete process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP;
process.env.AZURE_OPENAI_BASE_URL = "https://my-resource.services.ai.azure.com";
});
afterEach(() => {
if (originalBaseUrl === undefined) delete process.env.AZURE_OPENAI_BASE_URL;
else process.env.AZURE_OPENAI_BASE_URL = originalBaseUrl;
if (originalCacheRetention === undefined) delete process.env.PI_CACHE_RETENTION;
else process.env.PI_CACHE_RETENTION = originalCacheRetention;
if (originalDeploymentMap === undefined) delete process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP;
else process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP = originalDeploymentMap;
});
const context = normalizeContext({
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
});
function deepSeekModel() {
return getModel("azure", "deepseek-v4-pro");
}
// Regression for #9645: Azure Foundry rejects DeepSeek's thinking field and every prompt cache parameter here.
describe("azure deepseek-v4-pro over Chat Completions", () => {
it("turns thinking on with reasoning_effort instead of DeepSeek's thinking field", async () => {
await azure.streamSimple(deepSeekModel(), context, { apiKey: "test-key", reasoning: "high" }).result();
expect(mockState.lastParams?.reasoning_effort).toBe("high");
expect(mockState.lastParams?.thinking).toBeUndefined();
});
it("clamps thinking levels the deployment does not accept", async () => {
await azure.streamSimple(deepSeekModel(), context, { apiKey: "test-key", reasoning: "max" }).result();
expect(mockState.lastParams?.reasoning_effort).toBe("high");
});
it("sends no reasoning_effort when no thinking level is requested", async () => {
await azure.streamSimple(deepSeekModel(), context, { apiKey: "test-key" }).result();
expect(mockState.lastParams?.reasoning_effort).toBeUndefined();
expect(mockState.lastParams?.thinking).toBeUndefined();
});
it("omits prompt cache parameters when long retention comes from PI_CACHE_RETENTION", async () => {
process.env.PI_CACHE_RETENTION = "long";
await azure.stream(deepSeekModel(), context, { apiKey: "test-key", sessionId: "session-env" }).result();
expect(mockState.lastParams?.prompt_cache_key).toBeUndefined();
expect(mockState.lastParams?.prompt_cache_retention).toBeUndefined();
});
// The deployment discards a `developer` system message once reasoning_effort is set, without
// billing it, so the system prompt has to go out under the system role.
it("sends the system prompt under the system role", async () => {
await azure.stream(deepSeekModel(), context, { apiKey: "test-key", reasoningEffort: "low" }).result();
expect(mockState.lastParams?.messages?.[0]).toMatchObject({ role: "system", content: "sys" });
});
// The deployment honours system messages sent mid-conversation, so pi must not collapse them.
it("keeps mid-conversation system messages in place", async () => {
const resumed = normalizeContext({
systemPrompt: "first",
messages: [
{ role: "user", content: "hi", timestamp: Date.now() },
{ role: "system", content: "second", timestamp: Date.now() },
{ role: "user", content: "again", timestamp: Date.now() },
],
});
await azure.stream(deepSeekModel(), resumed, { apiKey: "test-key" }).result();
expect(mockState.lastParams?.messages?.map((message) => message.role)).toEqual([
"system",
"user",
"system",
"user",
]);
});
it("omits prompt cache parameters even when long retention is requested", async () => {
await azure
.stream(deepSeekModel(), context, { apiKey: "test-key", cacheRetention: "long", sessionId: "session-1" })
.result();
expect(mockState.lastParams?.prompt_cache_key).toBeUndefined();
expect(mockState.lastParams?.prompt_cache_retention).toBeUndefined();
});
it("replays reasoning_content on assistant turns so the cached prefix is unchanged", async () => {
const assistant: AssistantMessage = {
role: "assistant",
content: [
{ type: "thinking", thinking: "internal reasoning", thinkingSignature: "reasoning_content" },
{ type: "text", text: "answer" },
],
provider: "azure",
api: "openai-completions",
model: "deepseek-v4-pro",
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",
};
const resumed: Context = {
systemPrompt: "sys",
messages: [
{ role: "user", content: "first", timestamp: Date.now() },
assistant,
{ role: "user", content: "second", timestamp: Date.now() },
],
};
await azure.stream(deepSeekModel(), normalizeContext(resumed), { apiKey: "test-key" }).result();
const assistantMessage = mockState.lastParams?.messages?.find((message) => message.role === "assistant");
expect(assistantMessage?.reasoning_content).toBe("internal reasoning");
});
});
describe("azure Chat Completions endpoint resolution", () => {
it("normalizes the Azure endpoint the completions client is built with", async () => {
await azure.stream(deepSeekModel(), context, { apiKey: "test-key" }).result();
expect(mockState.lastClientOptions?.baseURL).toBe("https://my-resource.services.ai.azure.com/openai/v1");
});
it("surfaces an unconfigured endpoint as an error event rather than throwing out of stream()", async () => {
delete process.env.AZURE_OPENAI_BASE_URL;
const result = await azure.stream(deepSeekModel(), context, { apiKey: "test-key" }).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Azure OpenAI base URL is required");
});
// The id is persisted on the assistant message and read back by name, so it has to stay a catalog id.
it("sends the model id as the request model", async () => {
const result = await azure.stream(deepSeekModel(), context, { apiKey: "test-key" }).result();
expect(mockState.lastParams?.model).toBe("deepseek-v4-pro");
expect(result.model).toBe("deepseek-v4-pro");
});
it("sends the mapped deployment name while keeping the catalog id on the message", async () => {
process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP = "deepseek-v4-pro=my-deepseek";
const result = await azure.streamSimple(deepSeekModel(), context, { apiKey: "test-key" }).result();
expect(mockState.lastParams?.model).toBe("my-deepseek");
expect(result.model).toBe("deepseek-v4-pro");
});
it("passes the deployment name through a caller's onPayload", async () => {
process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP = "deepseek-v4-pro=my-deepseek";
let seenModel: unknown;
await azure
.stream(deepSeekModel(), context, {
apiKey: "test-key",
onPayload: (payload) => {
seenModel = (payload as CapturedCompletionsPayload).model;
return { ...(payload as object), temperature: 0.1 };
},
})
.result();
expect(seenModel).toBe("my-deepseek");
expect(mockState.lastParams).toMatchObject({ model: "my-deepseek", temperature: 0.1 });
});
});
describe("azure api map", () => {
it("still routes Responses models to the Responses api", async () => {
await azure.stream(getModel("azure", "gpt-4o-mini"), context, { apiKey: "test-key" }).result();
expect(mockState.dispatchedTo).toBe("responses");
});
it("routes openai-completions models to chat completions", async () => {
await azure.stream(deepSeekModel(), context, { apiKey: "test-key" }).result();
expect(mockState.dispatchedTo).toBe("chat.completions");
});
});
@@ -1,15 +1,16 @@
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 type { AssistantMessage, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
function createModel(): Model<"azure-openai-responses"> {
return {
id: "gpt-5-mini",
name: "GPT-5 Mini",
api: "azure-openai-responses",
provider: "azure-openai-responses",
provider: "azure",
baseUrl: "https://example.invalid",
reasoning: true,
input: ["text"],
@@ -67,14 +68,14 @@ async function* createEvents(
}
function getReplayedReasoning(model: Model<"azure-openai-responses">, assistant: AssistantMessage) {
const context: Context = {
const context = normalizeContext({
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"]));
});
const input = convertResponsesMessages(model, context, new Set(["azure"]));
return input.find((item) => item.type === "reasoning");
}
@@ -2,12 +2,13 @@ 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";
import { normalizeContext } from "../src/utils/transcript.ts";
const model: Model<"azure-openai-responses"> = {
id: "test-deployment",
name: "Test Deployment",
api: "azure-openai-responses",
provider: "azure-openai-responses",
provider: "azure",
baseUrl: "http://127.0.0.1:9/openai/v1",
reasoning: false,
input: ["text"],
@@ -21,7 +22,7 @@ describe("Azure OpenAI tool choice", () => {
let payload: unknown;
const result = stream(
model,
{
normalizeContext({
messages: [{ role: "user", content: "Summarize this", timestamp: 1 }],
tools: [
{
@@ -30,7 +31,7 @@ describe("Azure OpenAI tool choice", () => {
parameters: Type.Object({ path: Type.String() }),
},
],
},
}),
{
apiKey: "test-key",
toolChoice: "required",
@@ -51,7 +52,7 @@ describe("Azure OpenAI tool choice", () => {
let payload: unknown;
const result = streamSimple(
model,
{
normalizeContext({
messages: [{ role: "user", content: "Summarize this", timestamp: 1 }],
tools: [
{
@@ -60,7 +61,7 @@ describe("Azure OpenAI tool choice", () => {
parameters: Type.Object({ path: Type.String() }),
},
],
},
}),
{
apiKey: "test-key",
toolChoice: "none",
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
class BedrockRuntimeServiceException extends Error {}
class BedrockRuntimeClient {
middlewareStack = { add: () => undefined };
async send(): Promise<unknown> {
return {
$metadata: { httpStatusCode: 200 },
stream: (async function* () {
yield { messageStart: { role: "assistant" } };
yield {
metadata: {
usage: {
inputTokens: 100,
outputTokens: 5,
totalTokens: 1_000_105,
cacheWriteInputTokens: 1_000_000,
cacheDetails: [
{ ttl: "1h", inputTokens: 150_000 },
{ ttl: "5m", inputTokens: 600_000 },
{ ttl: "1h", inputTokens: 250_000 },
],
},
},
};
yield { messageStop: { stopReason: "end_turn" } };
})(),
};
}
}
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: { FIVE_MINUTES: "5m", ONE_HOUR: "1h" },
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, normalizeContext } from "../src/compat.ts";
const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const context = normalizeContext({ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] });
describe("Bedrock 1h cache write cost", () => {
it("prices the 1h cache details at 2x while preserving the total cache write", async () => {
// Regression test for https://github.com/earendil-works/pi/issues/9457
const result = await streamBedrock(model, context, { cacheRetention: "none" }).result();
expect(result.usage.cacheWrite).toBe(1_000_000);
expect(result.usage.cacheWrite1h).toBe(400_000);
const expectedCacheWriteCost = (600_000 * model.cost.cacheWrite + 400_000 * model.cost.input * 2) / 1_000_000;
expect(result.usage.cost.cacheWrite).toBeCloseTo(expectedCacheWriteCost, 10);
});
});
@@ -57,6 +57,7 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
import type { Context, Message, Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const baseModel: Model<"bedrock-converse-stream"> = {
id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
@@ -82,7 +83,7 @@ const novaModel: Model<"bedrock-converse-stream"> = {
async function capturePayload(context: Context, model = baseModel): Promise<unknown> {
let capturedPayload: unknown;
const s = streamBedrock(model, context, {
const s = streamBedrock(model, normalizeContext(context), {
cacheRetention: "none",
signal: AbortSignal.abort(),
onPayload: (payload) => {
@@ -151,7 +152,7 @@ describe("Bedrock tool arguments", () => {
try {
const message = await streamBedrock(
baseModel,
{ messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }] },
normalizeContext({ messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }] }),
{ cacheRetention: "none" },
).result();
@@ -324,6 +325,98 @@ describe("bedrock convertMessages skips unknown content types", () => {
expect(p.messages[0].content[0].toolResult.content).toEqual([{ text: "<empty>" }]);
});
// Regression for #10794: Claude rejects error tool results that contain images.
it("moves images of error tool results after the tool results", async () => {
const messages: Message[] = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call-1", name: "codemode", arguments: {} },
{ type: "toolCall", id: "call-2", name: "read", arguments: {} },
],
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: "call-1",
toolName: "codemode",
content: [
{ type: "text", text: "Script failed" },
{ type: "image", data: "AAAA", mimeType: "image/png" },
{ type: "text", text: "Script error" },
],
isError: true,
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "call-2",
toolName: "read",
content: [{ type: "image", data: "BBBB", mimeType: "image/png" }],
isError: false,
timestamp: Date.now(),
},
];
const payload = (await capturePayload({ messages })) as {
messages: Array<{ role: string; content: Array<Record<string, any>> }>;
};
const content = payload.messages[1].content;
expect(content).toHaveLength(4);
expect(content[0].toolResult).toEqual({
toolUseId: "call-1",
content: [
{ text: "Script failed" },
{ text: "(image attached after the tool results)" },
{ text: "Script error" },
],
status: "error",
});
expect(content[1].toolResult.status).toBe("success");
expect(content[1].toolResult.content).toEqual([{ image: expect.objectContaining({ format: "png" }) }]);
expect(content[2]).toEqual({ text: "Images from the error result of tool call call-1 (codemode):" });
expect(content[3]).toEqual({ image: expect.objectContaining({ format: "png" }) });
});
it("keeps images in error tool results for non-Claude models", async () => {
const messages: Message[] = [
{
role: "toolResult",
toolCallId: "call-1",
toolName: "codemode",
content: [
{ type: "text", text: "Script failed" },
{ type: "image", data: "AAAA", mimeType: "image/png" },
],
isError: true,
timestamp: Date.now(),
},
];
const payload = (await capturePayload({ messages }, novaModel)) as {
messages: Array<{ role: string; content: Array<Record<string, any>> }>;
};
expect(payload.messages[0].content).toEqual([
{
toolResult: {
toolUseId: "call-1",
content: [{ text: "Script failed" }, { image: expect.objectContaining({ format: "png" }) }],
status: "error",
},
},
]);
});
it("skips assistant messages with only unknown content blocks", async () => {
const messages: Message[] = [
{
@@ -351,7 +444,7 @@ describe("bedrock convertMessages skips unknown content types", () => {
});
it("removes empty property names only from replayed Bedrock input", async () => {
const toolArguments = {
const toolArguments: { path: string; edits: Array<Record<string, string>> } = {
path: "/workspace/foobar/file.js",
edits: [
{ oldText: "first", newText: "updated first" },
@@ -53,12 +53,12 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
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";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { Model } from "../src/types.ts";
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
});
const MIDDLEWARE_NAME = "pi-ai-custom-headers";
@@ -49,17 +49,17 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
});
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 { getModel, normalizeContext } from "../src/compat.ts";
import type { AssistantMessage, 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 = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
});
function getModelFixture(): Model<"bedrock-converse-stream"> {
return getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const bedrockMock = vi.hoisted(() => ({
stopReason: "end_turn" as string,
streamEvents: undefined as unknown[] | undefined,
}));
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
@@ -16,6 +17,10 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
return {
$metadata: { httpStatusCode: 200, requestId: "request-id" },
stream: (async function* () {
if (bedrockMock.streamEvents) {
yield* bedrockMock.streamEvents;
return;
}
yield { messageStart: { role: "assistant" } };
yield { messageStop: { stopReason: bedrockMock.stopReason } };
})(),
@@ -51,13 +56,60 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
});
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
import { getModel } from "../src/compat.ts";
import type { Context } from "../src/types.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { Api, Model } from "../src/types.ts";
beforeEach(() => {
bedrockMock.streamEvents = undefined;
});
const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
});
describe("Bedrock provider stream events", () => {
it("forwards SDK stream items in order before normalizing them", async () => {
bedrockMock.streamEvents = [
{ messageStart: { role: "assistant" } },
{ contentBlockDelta: { contentBlockIndex: 0, delta: { text: "hello" } } },
{ messageStop: { stopReason: "end_turn", additionalModelResponseFields: { source: "test" } } },
{ metadata: { usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } } },
];
const received: unknown[] = [];
const eventModels: Model<Api>[] = [];
const result = await streamBedrock(model, context, {
cacheRetention: "none",
onProviderStreamEvent: async (item, eventModel) => {
await Promise.resolve();
received.push(item);
eventModels.push(eventModel);
},
}).result();
expect(received).toEqual(bedrockMock.streamEvents);
for (const [index, item] of received.entries()) expect(item).toBe(bedrockMock.streamEvents[index]);
expect(eventModels).toEqual([model, model, model, model]);
expect(result.stopReason).toBe("stop");
expect(result.content).toEqual([{ type: "text", text: "hello" }]);
});
it("forwards SDK error items before reporting them", async () => {
const exception = new Error("bedrock stream failed");
bedrockMock.streamEvents = [{ messageStart: { role: "assistant" } }, { internalServerException: exception }];
const received: unknown[] = [];
const result = await streamBedrock(model, context, {
cacheRetention: "none",
onProviderStreamEvent: (item) => {
received.push(item);
},
}).result();
expect(received).toEqual(bedrockMock.streamEvents);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("bedrock stream failed");
});
});
describe("Bedrock raw stop reasons", () => {
it("preserves raw Bedrock stop reasons for successful stops", async () => {
@@ -63,6 +63,7 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
import type { Context, Message, Model, ThinkingContent } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const gptModel: Model<"bedrock-converse-stream"> = {
id: "global.openai.gpt-5.6-terra",
@@ -103,13 +104,15 @@ function redactedReasoningEvents(): unknown[] {
];
}
const helloContext = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: Date.now() }] });
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, {
const s = streamBedrock(gptModel, normalizeContext(context), {
cacheRetention: "none",
signal: AbortSignal.abort(),
onPayload: (payload) => {
@@ -134,9 +137,7 @@ describe("Bedrock redacted reasoning", () => {
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();
const response = await streamBedrock(gptModel, helloContext).result();
expect(response.stopReason, response.errorMessage).not.toBe("error");
// Reasoning precedes the answer, matching the order Bedrock streamed it.
@@ -147,9 +148,7 @@ describe("Bedrock redacted reasoning", () => {
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 response = await streamBedrock(gptModel, helloContext).result();
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
expect(thinking).toBeDefined();
@@ -174,9 +173,7 @@ describe("Bedrock redacted reasoning", () => {
{ messageStop: { stopReason: "end_turn" } },
];
const response = await streamBedrock(gptModel, {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
}).result();
const response = await streamBedrock(gptModel, helloContext).result();
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
@@ -195,9 +192,7 @@ describe("Bedrock redacted reasoning", () => {
{ messageStop: { stopReason: "end_turn" } },
];
const response = await streamBedrock(gptModel, {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
}).result();
const response = await streamBedrock(gptModel, helloContext).result();
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
@@ -1,7 +1,7 @@
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 { getModel, normalizeContext } from "../src/compat.ts";
import type { Model, ProviderResponse } from "../src/types.ts";
const MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0";
@@ -41,7 +41,7 @@ describe("bedrock response headers", () => {
const result = await streamBedrock(
model,
{ messages: [{ role: "user", content: "hello", timestamp: Date.now() }] },
normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: Date.now() }] }),
{
cacheRetention: "none",
env: { AWS_BEDROCK_FORCE_HTTP1: "1", AWS_BEDROCK_SKIP_AUTH: "1" },
@@ -1,17 +1,31 @@
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 { getModel, normalizeContext } 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 };
thinking?: {
type: string;
budget_tokens?: number;
display?: string;
block_binding?: { prefix_mismatch_behavior: string };
};
output_config?: { effort?: string };
anthropic_beta?: string[];
reasoning?: { effort?: string };
reasoning_effort?: string;
};
}
const THINKING_BINDING_CONTROLS_BETA = "thinking-binding-controls-2026-08-01";
const ADAPTIVE_WITH_BINDING = {
type: "adaptive",
display: "summarized",
block_binding: { prefix_mismatch_behavior: "drop_block" },
};
class PayloadCaptured extends Error {
constructor() {
super("payload captured");
@@ -30,7 +44,7 @@ async function capturePayload(
options?: BedrockOptions,
): Promise<BedrockThinkingPayload> {
let capturedPayload: BedrockThinkingPayload | undefined;
const s = streamBedrock(model, makeContext(), {
const s = streamBedrock(model, normalizeContext(makeContext()), {
...options,
reasoning: options?.reasoning ?? "high",
onPayload: (payload) => {
@@ -63,9 +77,9 @@ describe("Bedrock thinking payload", () => {
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING);
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]);
});
it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.8", async () => {
@@ -78,9 +92,9 @@ describe("Bedrock thinking payload", () => {
const payload = await capturePayload(model, { reasoning: "xhigh" });
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING);
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]);
});
it("uses adaptive thinking for Claude Fable 5 when reasoning is enabled", async () => {
@@ -88,9 +102,9 @@ describe("Bedrock thinking payload", () => {
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING);
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]);
});
it("uses adaptive thinking for Claude Sonnet 5 when reasoning is enabled", async () => {
@@ -98,9 +112,9 @@ describe("Bedrock thinking payload", () => {
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING);
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]);
});
it("uses adaptive thinking for Claude Opus 5 when reasoning is enabled", async () => {
@@ -108,9 +122,9 @@ describe("Bedrock thinking payload", () => {
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING);
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]);
});
it("maps xhigh reasoning to effort=xhigh for Claude Opus 5", async () => {
@@ -118,9 +132,9 @@ describe("Bedrock thinking payload", () => {
const payload = await capturePayload(model, { reasoning: "xhigh" });
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING);
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]);
});
it("maps xhigh reasoning to effort=xhigh for Claude Fable 5", async () => {
@@ -128,10 +142,33 @@ describe("Bedrock thinking payload", () => {
const payload = await capturePayload(model, { reasoning: "xhigh" });
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING);
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" });
});
// https://github.com/earendil-works/pi/issues/10324
it("sends block_binding and the binding beta for Claude Opus 5.5", async () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5-5");
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING);
expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]);
});
// Bedrock rejects block_binding on 4.6 models: "Extra inputs are not permitted" (#10324)
it.each(["global.anthropic.claude-opus-4-6-v1", "global.anthropic.claude-sonnet-4-6"] as const)(
"omits block_binding for %s",
async (modelId) => {
const model = getModel("amazon-bedrock", modelId);
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
},
);
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"> = {
@@ -162,6 +199,66 @@ describe("Bedrock thinking payload", () => {
});
});
describe("Bedrock OpenAI reasoning payload", () => {
// Regression for #9331: the configured thinking level never reached OpenAI models on Bedrock.
it.each([
["minimal", "low"],
["low", "low"],
["medium", "medium"],
["high", "high"],
["xhigh", "xhigh"],
["max", "max"],
] as const)("sends reasoning=%s as reasoning.effort=%s for GPT-6 and GPT-5.6", async (reasoning, effort) => {
for (const id of ["global.openai.gpt-6-sol", "us.openai.gpt-6-luna", "global.openai.gpt-5.6-sol"] as const) {
const payload = await capturePayload(getModel("amazon-bedrock", id), { reasoning });
expect(payload.additionalModelRequestFields, id).toEqual({ reasoning: { effort } });
}
});
it("sends reasoning.effort when only model.name identifies a GPT model", async () => {
const model: Model<"bedrock-converse-stream"> = {
...getModel("amazon-bedrock", "global.openai.gpt-6-sol"),
id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile",
name: "GPT-6 Sol",
};
const payload = await capturePayload(model, { reasoning: "medium" });
expect(payload.additionalModelRequestFields).toEqual({ reasoning: { effort: "medium" } });
});
it("sends flat reasoning_effort for gpt-oss, clamped to high", async () => {
const model = getModel("amazon-bedrock", "openai.gpt-oss-120b-1:0");
expect((await capturePayload(model, { reasoning: "minimal" })).additionalModelRequestFields).toEqual({
reasoning_effort: "low",
});
expect((await capturePayload(model, { reasoning: "medium" })).additionalModelRequestFields).toEqual({
reasoning_effort: "medium",
});
expect((await capturePayload(model, { reasoning: "xhigh" })).additionalModelRequestFields).toEqual({
reasoning_effort: "high",
});
});
it("sends no reasoning fields when reasoning is off", async () => {
let captured: BedrockThinkingPayload | undefined;
const s = streamBedrock(getModel("amazon-bedrock", "global.openai.gpt-6-sol"), normalizeContext(makeContext()), {
onPayload: (payload) => {
captured = payload as BedrockThinkingPayload;
throw new PayloadCaptured();
},
});
for await (const event of s) {
if (event.type === "error") break;
}
expect(captured).toBeDefined();
expect(captured?.additionalModelRequestFields).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",
@@ -175,7 +272,7 @@ describe.skipIf(!hasBedrockCredentials())("Bedrock Claude max tokens E2E", () =>
const response = await streamBedrock(
model,
{
normalizeContext({
systemPrompt: "You are a deterministic text generator. Follow the requested output format exactly.",
messages: [
{
@@ -185,7 +282,7 @@ describe.skipIf(!hasBedrockCredentials())("Bedrock Claude max tokens E2E", () =>
timestamp: Date.now(),
},
],
},
}),
{ reasoning: "low" },
).result();
@@ -221,10 +318,10 @@ describe("Application inference profile support", () => {
let capturedPayload: any;
const s = streamBedrock(
model,
{
normalizeContext({
systemPrompt: "You are helpful.",
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
},
}),
{
onPayload: (payload) => {
capturedPayload = payload;
+83 -10
View File
@@ -2,9 +2,9 @@ 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 { getModel, normalizeContext, stream } from "../src/compat.ts";
import { MODELS } from "../src/models.generated.ts";
import type { Context, Model } from "../src/types.ts";
import type { Model } from "../src/types.ts";
class PayloadCaptured extends Error {
constructor() {
@@ -44,10 +44,10 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
}
});
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
});
describe("Anthropic Provider", () => {
it.skipIf(!process.env.ANTHROPIC_API_KEY)(
@@ -240,6 +240,13 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
});
describe("OpenAI Responses Provider", () => {
it.each(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-6-astra", "gpt-6-luna", "gpt-6-sol"] as const)(
"does not enable cache warming from the documented TTL alone for %s",
(modelId) => {
expect(getModel("openai", modelId).promptCache).toBeUndefined();
},
);
it.skipIf(!process.env.OPENAI_API_KEY)(
"should not set prompt_cache_retention when PI_CACHE_RETENTION is not set",
async () => {
@@ -299,7 +306,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
try {
const s = streamOpenAIResponses(proxyModel, context, {
apiKey: "fake-key",
apiKey: "sk-fake-key",
onPayload: stopAfterPayload((payload) => {
capturedPayload = payload;
}),
@@ -326,7 +333,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
apiKey: "sk-fake-key",
cacheRetention: "long",
sessionId: "session-compat-false",
onPayload: stopAfterPayload((payload) => {
@@ -351,7 +358,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
apiKey: "sk-fake-key",
cacheRetention: "none",
sessionId: "session-1",
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
@@ -378,7 +385,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
apiKey: "sk-fake-key",
cacheRetention: "none",
sessionId: "session-1",
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
@@ -401,13 +408,15 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
it.each([
["gpt-4o-mini", "24h", undefined],
["gpt-6-astra", undefined, { ttl: "30m" }],
["gpt-6-sol", undefined, { ttl: "30m" }],
["gpt-6-luna", 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",
apiKey: "sk-fake-key",
cacheRetention: "long",
sessionId: "session-2",
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
@@ -501,7 +510,6 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
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;
@@ -528,5 +536,70 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
expect(capturedPayload?.prompt_cache_key).toBeUndefined();
expect(capturedPayload?.prompt_cache_retention).toBeUndefined();
});
it.each([MODELS.cerebras["gpt-oss-120b"], MODELS.cerebras["qwen-3.8-27b"]] as const)(
"should omit strict field on tools for cerebras/$id",
async (metadata) => {
const model = metadata as Model<"openai-completions">;
const contextWithTools = {
messages: [
{
role: "system" as const,
content: "test",
toolsAdded: [
{
name: "t1",
description: "strict tool",
parameters: {
type: "object" as const,
properties: { x: { type: "string" } },
required: ["x"],
},
constrainedSampling: { type: "json_schema" as const },
},
{
name: "t2",
description: "non-strict tool",
parameters: {
type: "object" as const,
properties: { y: { type: "string" } },
required: ["y"],
},
},
],
timestamp: 0,
},
{ role: "user" as const, content: "hello", timestamp: 1 },
],
};
let capturedPayload: any;
try {
const s = streamOpenAICompletions(model, contextWithTools as any, {
apiKey: "fake-key",
sessionId: "test",
onPayload: stopAfterPayload((payload: any) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(model.compat?.supportsStrictMode).toBeUndefined();
expect(capturedPayload).toBeDefined();
const tools = capturedPayload?.tools as any[] | undefined;
expect(tools).toBeDefined();
for (const tool of tools!) {
expect(tool.function).not.toHaveProperty("strict");
}
},
);
});
});
@@ -0,0 +1,256 @@
import { describe, expect, it, vi } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { createModels, createProvider, getModelType } from "../src/models.ts";
import {
builtinModels,
getAllBuiltinModels,
getBuiltinClassifierModel,
getBuiltinClassifierModels,
} from "../src/providers/all.ts";
import type { Api, ClassifierApi, ClassifierContext, ClassifierModel, ClassifierResult, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function classifierModel(provider: string, id: string): ClassifierModel<ClassifierApi> {
return {
type: "classifier",
id,
name: id,
api: "test-classifier",
provider,
baseUrl: "https://example.test/v1",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
};
}
function chatModel(provider: string, id: string): Model<Api> {
return {
id,
name: id,
api: "test-chat",
provider,
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
}
const context: ClassifierContext = {
state: { text: "yes" },
questions: {
approved: {
type: "bool",
instructions: "Does this express approval?",
criteria: { true: "Approval", false: "No approval" },
},
},
};
describe("Models with classifier models", () => {
it("keeps chat and classifier entries with the same provider and id separate", async () => {
const chat = chatModel("test", "shared");
const classifier = classifierModel("test", "shared");
const provider = createProvider({
id: "test",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [chat, classifier],
api: {
"test-chat": {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
},
},
classifiers: {
"test-classifier": {
classify: async (model): Promise<ClassifierResult> => ({
api: model.api,
provider: model.provider,
model: model.id,
answers: { approved: { type: "bool", probability: 0.9 } },
stopReason: "stop",
timestamp: Date.now(),
}),
},
},
});
const models = createModels();
models.setProvider(provider);
const listedChat = models.getModel("test", "shared");
expect(listedChat && getModelType(listedChat)).toBe("chat");
expect(models.getModelOfType("classifier", "test", "shared")?.type).toBe("classifier");
expect(models.getModelsOfType("classifier")).toEqual([classifier]);
expect(models.getAllModels()).toHaveLength(2);
expect(await models.getAvailableOfType("classifier")).toEqual([classifier]);
expect((await models.classify(classifier, context)).answers.approved).toEqual({
type: "bool",
probability: 0.9,
});
});
it("rejects chat models at the classifier entry point at runtime", async () => {
const chat = chatModel("test", "chat");
const models = createModels();
models.setProvider(
createProvider({
id: "test",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [chat],
api: {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
},
}),
);
const result = await models.classify(chat as unknown as ClassifierModel<ClassifierApi>, context);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("is not a classifier model");
});
it("exposes Jev only through classifier catalog accessors", () => {
const jev = getBuiltinClassifierModel("typesafe", "jev-latest");
expect(jev).toMatchObject({
type: "classifier",
api: "typesafe-system-one",
provider: "typesafe",
contextWindow: 64000,
});
expect(getBuiltinClassifierModels("typesafe")).toEqual([jev]);
expect(getAllBuiltinModels("typesafe")).toEqual([jev]);
const models = builtinModels();
expect(models.getModel("typesafe", "jev-latest")).toBeUndefined();
expect(models.getModelOfType("classifier", "typesafe", "jev-latest")).toEqual(jev);
});
it.each([
["vercel-ai-gateway", "typesafe-ai/jev", "https://ai-gateway.vercel.sh/typesafe/v1/systemone"],
["opencode", "jev-1.13", "https://opencode.ai/inference/v1/systemone"],
["opencode", "jev-1.13-free", "https://opencode.ai/inference/v1/systemone"],
])("routes %s Jev (%s) to its TypeSafe-compatible endpoint", async (provider, id, url) => {
const models = builtinModels();
const jev = models.getModelOfType("classifier", provider, id);
if (!jev) throw new Error(`missing ${provider} Jev model`);
expect(jev).toMatchObject({ api: "typesafe-system-one", contextWindow: 32000 });
expect(models.getModel(provider, id)).toBeUndefined();
const requests: Array<{ url: string; body: Record<string, unknown>; authorization: string | null }> = [];
const result = await models.classify(jev, context, {
apiKey: "secret",
fetch: async (input, init) => {
requests.push({
url: String(input),
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
authorization: new Headers(init?.headers).get("authorization"),
});
return Response.json({ model: id, answers: { approved: { type: "noul", noul: 0.8 } } });
},
});
expect(requests).toEqual([
{
url,
body: expect.objectContaining({ model: id, state: context.state }),
authorization: "Bearer secret",
},
]);
expect(result.stopReason).toBe("stop");
expect(result.answers.approved).toEqual({ type: "bool", probability: 0.8 });
});
it("rejects images for classifier models without image input before calling the provider", async () => {
const classifier = classifierModel("test", "text-only");
const classify = vi.fn(
async (): Promise<ClassifierResult> => ({
api: classifier.api,
provider: classifier.provider,
model: classifier.id,
answers: {},
stopReason: "stop",
timestamp: Date.now(),
}),
);
const models = createModels();
models.setProvider(
createProvider({
id: "test",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [classifier],
classifiers: { "test-classifier": { classify } },
}),
);
const result = await models.classify(classifier, {
...context,
images: [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }],
});
const withoutImages = await models.classify(classifier, { ...context, images: [] });
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Model test/text-only does not accept image input");
expect(withoutImages.stopReason).toBe("stop");
expect(classify).toHaveBeenCalledOnce();
});
it("routes OpenAI GPT-6 Luna through the Decisions API with images", async () => {
const models = builtinModels();
const luna = models.getModelOfType("classifier", "openai", "gpt-6-luna");
if (!luna) throw new Error("missing OpenAI Decisions model");
expect(luna).toMatchObject({ api: "openai-decisions", input: ["text", "image"], contextWindow: 922000 });
// The chat entry with the same id stays separate.
expect(models.getModel("openai", "gpt-6-luna")?.api).toBe("openai-responses");
const urls: string[] = [];
const result = await models.classify(
luna,
{ ...context, images: [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }] },
{
apiKey: "secret",
fetch: async (input) => {
urls.push(String(input));
return Response.json({ answers: [{ type: "predicate", name: "approved", probability: 0.8 }] });
},
},
);
expect(urls).toEqual(["https://api.openai.com/v1/decisions"]);
expect(result.stopReason).toBe("stop");
expect(result.answers.approved).toEqual({ type: "bool", probability: 0.8 });
});
it("lists OpenAI Decisions models only for API key credentials", async () => {
const apiKeyStore = new InMemoryCredentialStore();
await apiKeyStore.modify("openai", async () => ({ type: "api_key", key: "secret" }));
const oauthStore = new InMemoryCredentialStore();
await oauthStore.modify("openai", async () => ({
type: "oauth",
access: "access",
refresh: "refresh",
expires: Date.now() + 3_600_000,
}));
const withApiKey = builtinModels({ credentials: apiKeyStore });
const withOAuth = builtinModels({ credentials: oauthStore });
expect((await withApiKey.getAvailableOfType("classifier", "openai")).map((model) => model.id)).toEqual([
"gpt-6-luna",
]);
expect(await withOAuth.getAvailableOfType("classifier", "openai")).toEqual([]);
// Chat models stay available with ChatGPT OAuth.
expect((await withOAuth.getAvailable("openai")).some((model) => model.id === "gpt-6-luna")).toBe(true);
});
it("routes OpenRouter classifier models through the System One API", () => {
const models = builtinModels();
for (const model of getBuiltinClassifierModels("openrouter")) {
expect(model).toMatchObject({ api: "typesafe-system-one", baseUrl: "https://openrouter.ai/api/v1" });
expect(models.getModel("openrouter", model.id)).toBeUndefined();
expect(models.getModelOfType("classifier", "openrouter", model.id)).toEqual(model);
}
});
});
@@ -6,6 +6,7 @@ import {
} from "../src/api/cloudflare-ai-binding.ts";
import { streamSimple as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import type { Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const BINDING_PREFIX = "https://workers-binding.ai/ai-gateway/gateways/my-gateway";
@@ -88,7 +89,7 @@ describe("createAiBindingFetch", () => {
const result = await streamOpenAICompletions(
model,
{ messages: [{ role: "user", content: "hello", timestamp: 1 }] },
normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] }),
{
headers: {
"cf-aig-authorization": `Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}`,
+3 -2
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { cloudflareStreams } from "../src/providers/cloudflare-stream.ts";
import type { Api, Context, Model } from "../src/types.ts";
import type { Api, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const model: Model<Api> = {
id: "model",
@@ -16,7 +17,7 @@ const model: Model<Api> = {
maxTokens: 100,
};
const context: Context = { messages: [] };
const context = normalizeContext({ messages: [] });
describe("Cloudflare provider streams", () => {
it("materializes the model endpoint before dispatch", () => {
@@ -0,0 +1,156 @@
import { describe, expect, it, vi } from "vitest";
import { createModels } from "../src/models.ts";
import { getBuiltinClassifierModel } from "../src/providers/all.ts";
import { cloudflareWorkersAIProvider } from "../src/providers/cloudflare-workers-ai.ts";
import type { ClassifierContext } from "../src/types.ts";
const context: ClassifierContext = {
state: { message: "Help! My payouts have been failing for 3 days." },
questions: {
is_urgent: {
type: "bool",
instructions: "Does this convey urgency?",
criteria: { true: "Explicitly time-sensitive", false: "No urgency expressed" },
},
department: {
type: "choice",
instructions: "Which team should handle this?",
criteria: { billing: "Payments", technical: "Bugs" },
},
},
};
// Model output from https://developers.cloudflare.com/ai/models/typesafe/jev/
const jevOutput = {
model: "jev-1.13.0",
answers: {
is_urgent: { type: "noul", noul: 0.95 },
department: {
type: "choice",
choice: "billing",
confidence: 0.8,
probabilities: { billing: 0.87, technical: 0.13 },
},
},
usage: { input_tokens: 426, output_tokens: 73 },
};
// REST envelope observed from the live /ai/run endpoint.
function restResponse(state: string, result: unknown = jevOutput) {
return {
result: { state, result, gatewayMetadata: { keySource: "Unified" } },
success: true,
errors: [],
messages: [],
};
}
// Cloudflare-hosted output observed from a live /ai/run call, question ids renamed to match `context`.
// The envelope carries the output directly, without a run record.
const clefOutput = {
model: "clef",
answers: {
is_urgent: { type: "noul", noul: 0.9912 },
department: {
type: "choice",
choice: "technical",
probabilities: { billing: 0.1632, technical: 0.8368 },
confidence: 0.4538,
},
},
usage: { input_tokens: 222, output_tokens: 0 },
};
function setup() {
const models = createModels();
models.setProvider(cloudflareWorkersAIProvider());
const jev = models.getModelOfType("classifier", "cloudflare-workers-ai", "typesafe/jev");
if (!jev) throw new Error("missing Cloudflare Jev model");
return { models, jev };
}
const auth = { apiKey: "cf-key", env: { CLOUDFLARE_ACCOUNT_ID: "account-id" } };
describe("Cloudflare Workers AI System One", () => {
it("exposes Jev only through classifier catalog accessors", () => {
const { models, jev } = setup();
expect(jev).toEqual(getBuiltinClassifierModel("cloudflare-workers-ai", "typesafe/jev"));
expect(jev).toMatchObject({ type: "classifier", api: "cloudflare-workers-ai-system-one" });
expect(models.getModel("cloudflare-workers-ai", "typesafe/jev")).toBeUndefined();
});
it("runs Jev through the account-scoped /ai/run endpoint", async () => {
const { models, jev } = setup();
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
const payload = JSON.parse(String(init?.body)) as {
model: string;
input: { state: unknown; questions: Record<string, { type: string }> };
};
expect(payload.model).toBe("typesafe/jev");
expect(payload.input.state).toEqual(context.state);
expect(payload.input.questions.is_urgent?.type).toBe("noul");
expect(payload.input.questions.department?.type).toBe("choice");
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer cf-key");
return Response.json(restResponse("Completed"));
});
const result = await models.classify(jev, context, { ...auth, fetch });
expect(String(fetch.mock.calls[0]?.[0])).toBe("https://api.cloudflare.com/client/v4/accounts/account-id/ai/run");
expect(result.stopReason).toBe("stop");
expect(result.answers.is_urgent).toEqual({ type: "bool", probability: 0.95 });
expect(result.answers.department).toMatchObject({ type: "choice", choice: "billing", confidence: 0.8 });
expect(result.usage).toMatchObject({ input: 426, output: 73, totalTokens: 499 });
});
it.each([
["@cf/cloudflare/clef", 0.24],
["@cf/cloudflare/clef-flash", 0.09],
])("runs %s through /ai/run and parses its direct output", async (id, inputPrice) => {
const { models } = setup();
const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", id);
if (!clef) throw new Error(`missing Cloudflare ${id} model`);
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
const payload = JSON.parse(String(init?.body)) as {
model: string;
input: { state: unknown; questions: Record<string, { type: string }> };
};
expect(payload.model).toBe(id);
expect(payload.input.state).toEqual(context.state);
expect(payload.input.questions.is_urgent?.type).toBe("noul");
return Response.json({ result: clefOutput, success: true, errors: [], messages: [] });
});
const result = await models.classify(clef, context, { ...auth, fetch });
expect(String(fetch.mock.calls[0]?.[0])).toBe("https://api.cloudflare.com/client/v4/accounts/account-id/ai/run");
expect(result.stopReason).toBe("stop");
expect(result.answers.is_urgent).toEqual({ type: "bool", probability: 0.9912 });
expect(result.answers.department).toMatchObject({ type: "choice", choice: "technical", confidence: 0.4538 });
expect(result.usage).toMatchObject({ input: 222, output: 0, totalTokens: 222 });
expect(result.usage?.cost.input).toBeCloseTo((222 * inputPrice) / 1_000_000);
});
it("reports runs that did not complete", async () => {
const { models, jev } = setup();
const result = await models.classify(jev, context, {
...auth,
fetch: async () => Response.json(restResponse("Queued", null)),
});
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("run did not complete (state: Queued)");
});
it("reports Cloudflare envelope errors", async () => {
const { models, jev } = setup();
const result = await models.classify(jev, context, {
...auth,
fetch: async () =>
Response.json({ success: false, errors: [{ code: 5007, message: "No such model" }], result: null }),
});
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Cloudflare Workers AI error: No such model");
});
});
@@ -16,8 +16,8 @@ import {
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";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { AssistantMessage, Message, Model, Tool, ToolResultMessage, Transport } from "../src/types.ts";
type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
@@ -172,12 +172,12 @@ async function main(): Promise<void> {
if (!apiKey) {
throw new Error("No OpenAI Codex API key found in coding-agent auth storage.");
}
const context: Context = {
const context = normalizeContext({
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);
+40
View File
@@ -0,0 +1,40 @@
import type { Static } from "typebox";
import { Compile } from "typebox/compile";
import { describe, expect, expectTypeOf, it } from "vitest";
import {
AnthropicMessagesCompatSchema,
BedrockCompatSchema,
OpenAICompletionsCompatSchema,
OpenAIResponsesCompatSchema,
ProviderCompatSchema,
} from "../src/providers/compat-schema.ts";
type ProviderCompat = Static<typeof ProviderCompatSchema>;
describe("compatibility schemas", () => {
it("preserves property types in the provider superset", () => {
expectTypeOf<ProviderCompat["supportsStore"]>().toEqualTypeOf<boolean | undefined>();
expectTypeOf<ProviderCompat["sessionAffinityFormat"]>().toEqualTypeOf<
"openai" | "openai-nosession" | "openrouter" | undefined
>();
});
it("preserves API-specific defaults", () => {
const completionsDefaults = Compile(OpenAICompletionsCompatSchema).Default({});
expect(completionsDefaults).not.toHaveProperty("thinkingFormat");
expect(completionsDefaults).not.toHaveProperty("supportsLongCacheRetention");
expect(Compile(OpenAIResponsesCompatSchema).Default({})).toMatchObject({ supportsDeveloperRole: true });
const anthropicDefaults = Compile(AnthropicMessagesCompatSchema).Default({});
expect(anthropicDefaults).not.toHaveProperty("sendSessionAffinityHeaders");
expect(anthropicDefaults).toMatchObject({ supportsLongCacheRetention: true });
expect(Compile(BedrockCompatSchema).Default({})).toMatchObject({ supportsStrictMode: false });
});
it("does not assign API-specific defaults to the provider superset", () => {
const defaults = Compile(ProviderCompatSchema).Default({});
expect(defaults).not.toHaveProperty("supportsDeveloperRole");
expect(defaults).not.toHaveProperty("sendSessionAffinityHeaders");
expect(defaults).not.toHaveProperty("supportsLongCacheRetention");
expect(defaults).not.toHaveProperty("supportsStrictMode");
});
});
@@ -11,8 +11,9 @@ import {
convertResponsesTools,
processResponsesStream,
} from "../src/api/openai-responses-shared.ts";
import type { AssistantMessage, Context, Model, Tool, ToolCall } from "../src/types.ts";
import type { AssistantMessage, Model, Tool, ToolCall } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
function makeModel(): Model<"openai-responses"> {
return {
@@ -204,7 +205,7 @@ describe("constrained tool sampling", () => {
name: "sample_tool",
arguments: { payload: "abc" },
};
const context: Context = {
const context = normalizeContext({
messages: [
{
role: "assistant",
@@ -225,8 +226,9 @@ describe("constrained tool sampling", () => {
timestamp: Date.now(),
},
],
};
for (const invalidArguments of [{}, { payload: 42 }]) {
});
const invalidArgumentsList: ToolCall["arguments"][] = [{}, { payload: 42 }];
for (const invalidArguments of invalidArgumentsList) {
replayedToolCall.arguments = invalidArguments;
expect(() =>
convertResponsesMessages(makeModel(), context, new Set(["openai"]), {
@@ -254,6 +256,40 @@ describe("constrained tool sampling", () => {
});
});
// earendil-works/radius#115: a gateway forwards another model's history as a foreign provider.
it("drops foreign item ids when replaying grammar calls as custom Responses items", () => {
const context = normalizeContext({
messages: [
{
role: "assistant",
api: "pi-messages",
provider: "radius",
model: "gpt-other",
content: [{ type: "toolCall", id: "call_1|ctc_1", name: "sample_tool", arguments: { payload: "abc" } }],
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(),
},
],
});
const messages = convertResponsesMessages(makeModel(), context, new Set(["openai"]), {
grammarToolInputProperties: new Map([["sample_tool", "payload"]]),
});
const call = messages.find((item) => "type" in item && item.type === "custom_tool_call");
expect(call).toMatchObject({ type: "custom_tool_call", call_id: "call_1", input: "abc" });
expect(call && "id" in call ? call.id : undefined).toBeUndefined();
});
it("keeps grammar input JSON deltas append-only", () => {
const buffer = { input: "", started: false, closed: false };
const first = appendGrammarToolInputJsonDelta(buffer, "payload", 'a"', false);
+26 -10
View File
@@ -1,7 +1,8 @@
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 type { AssistantMessage, Model, Usage } from "../src/types.ts";
import { estimateContextTokens } from "../src/utils/estimate.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
function createUsage(totalTokens: number): Usage {
return {
@@ -41,27 +42,42 @@ const model: Model<"openai-responses"> = {
};
describe("context token estimation", () => {
// Regression for #10497: large new inputs need more room than chars/4 allows.
it("reserves 3.5 characters per token for new text when limiting output", () => {
const context = normalizeContext({
messages: [createAssistant(100, 2_000), { role: "user", content: "x".repeat(3_500), timestamp: 200 }],
});
expect(estimateContextTokens(context)).toEqual({
tokens: 3_000,
usageTokens: 2_000,
trailingTokens: 1_000,
lastUsageIndex: 0,
});
expect(buildBaseOptions(model, context).maxTokens).toBe(2_904);
});
it("ignores stale assistant usage after a newer message is inserted before it", () => {
const context: Context = {
const context = normalizeContext({
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,
tokens: 1_149,
usageTokens: 0,
trailingTokens: 1_005,
trailingTokens: 1_149,
lastUsageIndex: null,
});
expect(buildBaseOptions(model, context).maxTokens).toBe(4_899);
expect(buildBaseOptions(model, context).maxTokens).toBe(4_755);
});
it("uses assistant usage again after a response to the inserted context", () => {
const context: Context = {
const context = normalizeContext({
messages: [
{ role: "user", content: "summary", timestamp: 200 },
createAssistant(100, 9_500),
@@ -69,12 +85,12 @@ describe("context token estimation", () => {
createAssistant(400, 2_000),
{ role: "user", content: "tail", timestamp: 500 },
],
};
});
expect(estimateContextTokens(context)).toEqual({
tokens: 2_001,
tokens: 2_002,
usageTokens: 2_000,
trailingTokens: 1,
trailingTokens: 2,
lastUsageIndex: 3,
});
});
+6 -6
View File
@@ -187,7 +187,7 @@ describe("Context overflow error handling", () => {
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 model = getModel("azure", "gpt-4o-mini");
const result = await testContextOverflow(model, process.env.AZURE_OPENAI_API_KEY!);
logResult(result);
@@ -338,8 +338,8 @@ describe("Context overflow error handling", () => {
// =============================================================================
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");
it("Kimi-K3 - should detect overflow via isContextOverflow", async () => {
const model = getModel("together", "moonshotai/Kimi-K3");
const result = await testContextOverflow(model, process.env.TOGETHER_API_KEY!);
logResult(result);
@@ -540,8 +540,8 @@ describe("Context overflow error handling", () => {
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");
it("anthropic/claude-sonnet-4.5 via OpenRouter - should detect overflow via isContextOverflow", async () => {
const model = getModel("openrouter", "anthropic/claude-sonnet-4.5");
const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!);
logResult(result);
@@ -563,7 +563,7 @@ describe("Context overflow error handling", () => {
// Mistral backend
it("mistralai/mistral-large-2512 via OpenRouter - should detect overflow via isContextOverflow", async () => {
const model = getModel("openrouter", "mistralai/mistral-large-2512");
const model = getModel("openrouter", "mistralai/mistral-large");
const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!);
logResult(result);
@@ -64,7 +64,7 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [
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" },
{ provider: "azure", model: "gpt-4o-mini", label: "azure-gpt-4o-mini" },
// OpenAI Codex
{ provider: "openai-codex", model: "gpt-5.5", label: "openai-codex-gpt-5.5" },
// GitHub Copilot
@@ -107,11 +107,13 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [
// 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" },
{ provider: "together", model: "moonshotai/Kimi-K3", label: "together-kimi-k3" },
// 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" },
// Meta
{ provider: "meta", model: "muse-spark-1.3", label: "meta-muse-spark-1.3" },
// Mistral
{ provider: "mistral", model: "devstral-medium-latest", label: "mistral-devstral-medium" },
// MiniMax
@@ -175,7 +177,7 @@ async function getApiKey(provider: string): Promise<string | undefined> {
* Synchronous check for API key availability (env vars only, for skipIf)
*/
function hasApiKey(pair: ProviderModelPair): boolean {
if (pair.provider === "azure-openai-responses") {
if (pair.provider === "azure") {
return hasAzureOpenAICredentials();
}
if (pair.provider === "cloudflare-workers-ai") {
-550
View File
@@ -1,550 +0,0 @@
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);
});
});
+2 -2
View File
@@ -205,7 +205,7 @@ describe("AI Providers Empty Message Tests", () => {
});
describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider Empty Messages", () => {
const llm = getModel("azure-openai-responses", "gpt-4o-mini");
const llm = getModel("azure", "gpt-4o-mini");
const azureDeploymentName = resolveAzureDeploymentName(llm.id);
const azureOptions = azureDeploymentName ? { azureDeploymentName } : {};
@@ -367,7 +367,7 @@ describe("AI Providers Empty Message Tests", () => {
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider Empty Messages", () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
const llm = getModel("together", "moonshotai/Kimi-K3");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import type { AssistantMessage } from "../src/types.ts";
import { AssistantMessageEventStream, EventStream } from "../src/utils/event-stream.ts";
// Regression tests for https://github.com/earendil-works/pi/issues/9055
describe("EventStream", () => {
it("drains buffered events in order and ignores events pushed after completion", async () => {
const stream = new EventStream<number, number>(
(event) => event === 3,
(event) => event,
);
stream.push(1);
stream.push(2);
stream.push(3);
stream.push(4);
expect(await stream.result()).toBe(3);
const events: number[] = [];
for await (const event of stream) {
events.push(event);
}
expect(events).toEqual([1, 2, 3]);
});
it("preserves order when events arrive after buffered draining starts", async () => {
const stream = new EventStream<number, number>(
() => false,
(event) => event,
);
stream.push(1);
stream.push(2);
const iterator = stream[Symbol.asyncIterator]();
expect(await iterator.next()).toEqual({ value: 1, done: false });
stream.push(3);
expect(await iterator.next()).toEqual({ value: 2, done: false });
expect(await iterator.next()).toEqual({ value: 3, done: false });
stream.end(3);
expect(await iterator.next()).toEqual({ value: undefined, done: true });
});
it("delivers events to waiting consumers in registration order", async () => {
const stream = new EventStream<number, number>(
() => false,
(event) => event,
);
const firstIterator = stream[Symbol.asyncIterator]();
const secondIterator = stream[Symbol.asyncIterator]();
const firstEvent = firstIterator.next();
const secondEvent = secondIterator.next();
stream.push(1);
stream.push(2);
expect(await firstEvent).toEqual({ value: 1, done: false });
expect(await secondEvent).toEqual({ value: 2, done: false });
});
it("drains buffered events after end and resolves the explicit result", async () => {
const stream = new EventStream<number, string>(
() => false,
(event) => String(event),
);
stream.push(1);
stream.push(2);
stream.end("complete");
expect(await stream.result()).toBe("complete");
const events: number[] = [];
for await (const event of stream) {
events.push(event);
}
expect(events).toEqual([1, 2]);
});
it("wakes all waiting consumers when ended without a result", async () => {
const stream = new EventStream<number, number>(
() => false,
(event) => event,
);
const firstIterator = stream[Symbol.asyncIterator]();
const secondIterator = stream[Symbol.asyncIterator]();
const firstEvent = firstIterator.next();
const secondEvent = secondIterator.next();
stream.end();
expect(await firstEvent).toEqual({ value: undefined, done: true });
expect(await secondEvent).toEqual({ value: undefined, done: true });
});
});
function message(timestamp: number, durationMs?: number): AssistantMessage {
return {
role: "assistant",
content: [],
api: "openai-responses",
provider: "openai",
model: "m",
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,
...(durationMs === undefined ? {} : { durationMs }),
};
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("AssistantMessageEventStream timing", () => {
it("sets durationMs on the final done or error message of a response it saw start", async () => {
const done = new AssistantMessageEventStream();
const answer = message(Date.now());
await sleep(20);
done.push({ type: "done", reason: "stop", message: answer });
expect(answer.durationMs).toBeGreaterThanOrEqual(15);
expect((await done.result()).durationMs).toBe(answer.durationMs);
const failed = new AssistantMessageEventStream();
const error = { ...message(Date.now()), stopReason: "error" as const };
failed.push({ type: "error", reason: "error", error });
expect(error.durationMs).toBeGreaterThanOrEqual(0);
const ended = new AssistantMessageEventStream();
const result = message(Date.now());
ended.end(result);
expect(result.durationMs).toBeGreaterThanOrEqual(0);
});
it("keeps an existing duration, so a forwarding stream keeps the inner measurement", async () => {
const outer = new AssistantMessageEventStream();
await sleep(20);
const inner = new AssistantMessageEventStream();
const answer = message(Date.now());
inner.push({ type: "done", reason: "stop", message: answer });
const measured = answer.durationMs;
outer.push({ type: "done", reason: "stop", message: answer });
expect(answer.durationMs).toBe(measured);
expect(measured).toBeLessThan(20);
const preset = message(Date.now(), 1234);
new AssistantMessageEventStream().push({ type: "done", reason: "stop", message: preset });
expect(preset.durationMs).toBe(1234);
});
it("leaves a message untimed when it started before the stream, such as a fetched deferred result", () => {
const fetched = message(Date.now() - 60_000);
new AssistantMessageEventStream().push({ type: "done", reason: "stop", message: fetched });
expect(fetched.durationMs).toBeUndefined();
});
it("does not time a message pushed after the stream completed", () => {
const stream = new AssistantMessageEventStream();
stream.push({ type: "done", reason: "stop", message: message(Date.now()) });
const late = message(Date.now());
stream.push({ type: "done", reason: "stop", message: late });
expect(late.durationMs).toBeUndefined();
});
});
+21
View File
@@ -324,6 +324,27 @@ describe("faux provider", () => {
expect(second.usage.input + second.usage.cacheRead).toBeGreaterThan(second.usage.input);
});
it("counts cached characters up to the first difference in the joined prompt", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([fauxAssistantMessage("a"), fauxAssistantMessage("b"), fauxAssistantMessage("c")]);
const options = { sessionId: "session-1", cacheRetention: "short" } as const;
const user = (content: string) => ({ role: "user" as const, content, timestamp: 1 });
// Prompt texts: "user:hello world" (16 characters), then 16 + 2 + "user:next" (9) = 27.
await complete(registration.getModel(), { messages: [user("hello world")] }, options);
const extended = await complete(
registration.getModel(),
{ messages: [user("hello world"), user("next")] },
options,
);
expect(extended.usage).toMatchObject({ input: 3, cacheRead: 4, cacheWrite: 3 });
// The first message now differs after "user:hello w" (12 characters): "user:hello wide" + 2 + 9 = 26.
const edited = await complete(registration.getModel(), { messages: [user("hello wide"), user("next")] }, options);
expect(edited.usage).toMatchObject({ input: 4, cacheRead: 3, cacheWrite: 4 });
});
it("does not simulate caching when cacheRetention is none", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
+6 -4
View File
@@ -9,11 +9,12 @@ import { streamSimple as streamOpenAICompletions } from "../src/api/openai-compl
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";
import type { Api, FetchFunction, ImageModel, Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
});
function createModel<TApi extends Api>(api: TApi): Model<TApi> {
return {
@@ -159,8 +160,9 @@ describe("fetch stream option", () => {
it("uses fetch for image generation", async () => {
const { custom, fallback } = mockFetches();
const model: ImagesModel<"openrouter-images"> = {
const model: ImageModel<"openrouter-images"> = {
...createModel("openrouter-images"),
type: "image",
provider: "openrouter",
output: ["image"],
};
@@ -0,0 +1,168 @@
import { spawnSync } from "node:child_process";
import { 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";
import type { ModelsDevReasoningOption } from "../scripts/models-dev-reasoning-options.ts";
import { streamSimple } from "../src/api/anthropic-messages.ts";
import { getSupportedThinkingLevels, hasApi } from "../src/models.ts";
import type { Api, Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
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 });
});
function generateFireworksModels(
options: Record<string, ModelsDevReasoningOption[] | undefined>,
): Record<string, Model<Api>> {
const root = mkdtempSync(join(tmpdir(), "pi-fireworks-generation-"));
temporaryRoots.push(root);
const preloadPath = join(root, "mock-catalog.mjs");
const outputPath = join(root, "catalog");
const catalog = {
"fireworks-ai": {
models: Object.fromEntries(
Object.entries(options).map(([id, reasoning_options]) => [
`accounts/fireworks/models/${id}`,
{ id, tool_call: true, reasoning: true, reasoning_options },
]),
),
},
};
writeFileSync(
preloadPath,
`const catalog = ${JSON.stringify(catalog)};\n` +
`globalThis.fetch = async (input) => {\n` +
` const url = String(input);\n` +
` if (url === "https://models.dev/api.json") return Response.json(catalog);\n` +
` if (url === "https://models.dev/models.json?type=decision") return Response.json({ "typesafe/jev-latest": { name: "Jev", type: "decision", limit: { context: 64000, output: 0 } } });\n` +
` if (url.startsWith("https://openrouter.ai/api/v1/models") || url === "https://ai-gateway.vercel.sh/v1/models") return Response.json({ data: [] });\n` +
` if (url === "https://radius.pi.dev/v1/config") return Response.json({ baseUrl: "https://radius.pi.dev", models: [{ id: "test", name: "Test", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 4096, maxTokens: 4096 }] });\n` +
` throw new Error(\`Unexpected fetch: \${url}\`);\n` +
`};\n`,
);
const result = spawnSync(
process.execPath,
[
"--import",
pathToFileURL(preloadPath).href,
"scripts/generate-models.ts",
"--json-only",
"--json-output",
outputPath,
],
{ cwd: packageRoot, encoding: "utf8", timeout: 10_000 },
);
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
expect(result.stderr).toBe("");
return JSON.parse(readFileSync(join(outputPath, "providers/fireworks.json"), "utf8")) as Record<string, Model<Api>>;
}
describe("Fireworks model generation", () => {
// Regression for #9323: import catalog efforts and correct only the known omissions.
it("combines upstream effort and toggle metadata with narrow corrections", () => {
const models = generateFireworksModels({
"deepseek-v4-flash-0731": [{ type: "toggle" }, { type: "effort", values: ["low", "high", "max"] }],
"deepseek-v4-flash-vision-exp": [{ type: "toggle" }, { type: "effort", values: ["low", "high", "max"] }],
"deepseek-v4-pro-0813": [{ type: "toggle" }, { type: "effort", values: ["high", "max"] }],
"qwen3p8-max": [{ type: "toggle" }],
"qwen3p8-2p4t-a95b": [{ type: "effort", values: ["low", "medium", "xhigh"] }],
"kimi-k2p6": [{ type: "toggle" }],
});
for (const id of ["deepseek-v4-flash-0731", "deepseek-v4-flash-vision-exp", "deepseek-v4-pro-0813"]) {
expect(models[`accounts/fireworks/models/${id}`].thinkingLevelMap).toEqual({
off: "none",
minimal: null,
low: "low",
medium: null,
high: "high",
xhigh: null,
max: "max",
});
}
for (const id of ["qwen3p8-max", "qwen3p8-2p4t-a95b"]) {
expect(models[`accounts/fireworks/models/${id}`].thinkingLevelMap).toEqual({
off: "none",
minimal: null,
low: "low",
medium: "medium",
high: null,
xhigh: "xhigh",
max: null,
});
}
for (const model of Object.values(models)) {
if (!hasApi(model, "anthropic-messages")) throw new Error("Expected Messages model");
expect(model.compat?.allowEmptySignature).toBe(true);
expect(model.compat?.forceAdaptiveThinking).toBe(model.id.endsWith("kimi-k2p6") ? undefined : true);
}
expect(models["accounts/fireworks/models/kimi-k2p6"].thinkingLevelMap).toBeUndefined();
});
// Regression for #9323: new effort-capable models must not require an allowlist update.
it("automatically sends native effort for newly cataloged Messages models", async () => {
const models = generateFireworksModels({
"new-reasoner": [{ type: "toggle" }, { type: "effort", values: ["low", "max"] }],
});
const model = models["accounts/fireworks/models/new-reasoner"];
if (!hasApi(model, "anthropic-messages")) throw new Error("Expected Messages model");
expect(model.compat?.forceAdaptiveThinking).toBe(true);
expect(getSupportedThinkingLevels(model)).toEqual(["off", "low", "max"]);
let payload: Record<string, unknown> | undefined;
await streamSimple(model, normalizeContext({ 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");
},
}).result();
expect(payload?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload?.output_config).toEqual({ effort: "max" });
});
it("does not infer adaptive thinking from toggle, budget, or missing metadata", () => {
const models = generateFireworksModels({
"toggle-only": [{ type: "toggle" }],
"budget-only": [{ type: "budget_tokens", min: 1024 }],
"fixed-reasoning": [],
"missing-metadata": undefined,
});
for (const model of Object.values(models)) {
if (!hasApi(model, "anthropic-messages")) throw new Error("Expected Messages model");
expect(model.compat?.forceAdaptiveThinking).toBeUndefined();
expect(model.thinkingLevelMap).toBeUndefined();
}
});
// Regression for #9323: full hardcoded maps must not override updated catalog efforts.
it("prefers updated upstream efforts over fixed maps and the Qwen fallback", () => {
const models = generateFireworksModels({
"deepseek-v4-flash-0731": [{ type: "effort", values: ["high", "max"] }],
"qwen3p8-max": [{ type: "toggle" }, { type: "effort", values: ["medium", "xhigh"] }],
});
expect(models["accounts/fireworks/models/deepseek-v4-flash-0731"].thinkingLevelMap).toEqual({
off: null,
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: "max",
});
expect(models["accounts/fireworks/models/qwen3p8-max"].thinkingLevelMap).toEqual({
off: "none",
minimal: null,
low: null,
medium: "medium",
high: null,
xhigh: "xhigh",
max: null,
});
});
});
+118 -20
View File
@@ -3,8 +3,9 @@ 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 { getModel, normalizeContext, streamSimple } from "../src/compat.ts";
import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts";
import { getSupportedThinkingLevels } from "../src/models.ts";
import type { Context, Model, Tool } from "../src/types.ts";
const originalFireworksApiKey = process.env.FIREWORKS_API_KEY;
@@ -18,8 +19,8 @@ afterEach(() => {
});
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");
it("registers non-GLM, non-Kimi-K3 models via Anthropic-compatible Messages API", () => {
const model = getModel("fireworks", "accounts/fireworks/models/deepseek-v4p1-flash");
expect(model).toBeDefined();
expect(model.api).toBe("anthropic-messages");
@@ -27,19 +28,11 @@ describe("Fireworks models", () => {
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");
it("aligns GLM 5.3 Fast with GLM 5.3's OpenAI-compatible config", () => {
const base = getModel("fireworks", "accounts/fireworks/models/glm-5p3");
const fast = getModel("fireworks", "accounts/fireworks/routers/glm-5p3-fast");
expect(fast.api).toBe(base.api);
expect(fast.baseUrl).toBe(base.baseUrl);
@@ -47,7 +40,7 @@ describe("Fireworks models", () => {
expect(fast.thinkingLevelMap).toEqual(base.thinkingLevelMap);
});
it.each(["accounts/fireworks/models/glm-5p2", "accounts/fireworks/routers/glm-5p2-fast"] as const)(
it.each(["accounts/fireworks/models/glm-5p3", "accounts/fireworks/routers/glm-5p3-fast"] as const)(
"omits unsupported long cache retention for %s",
async (modelId) => {
const model = getModel("fireworks", modelId);
@@ -78,9 +71,11 @@ describe("Fireworks models", () => {
const compat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsStrictMode: true,
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "openai",
deferredToolsMode: "kimi",
supportsMidConvoSystemMessages: true,
supportsMidConvoToolAdditions: true,
sendSessionAffinityHeaders: true,
supportsLongCacheRetention: false,
};
@@ -88,7 +83,7 @@ describe("Fireworks models", () => {
off: null,
minimal: null,
low: "low",
medium: "medium",
medium: null,
high: "high",
xhigh: null,
max: "max",
@@ -121,6 +116,69 @@ describe("Fireworks models", () => {
expect(payload?.reasoning_effort).toBe("max");
});
// Regression for #9323: native effort must reach Messages without budget-based fallback.
it.each([
["accounts/fireworks/models/deepseek-v4p1-flash", ["off", "low", "high", "max"]],
["accounts/fireworks/models/qwen3p8-max", ["off", "low", "medium", "xhigh"]],
["accounts/fireworks/models/qwen3p8-2p4t-a95b", ["off", "low", "medium", "xhigh"]],
] as const)("sends native Messages effort levels for %s", async (modelId, levels) => {
const model = getModel("fireworks", modelId);
expect(model.api).toBe("anthropic-messages");
expect(model.compat?.forceAdaptiveThinking).toBe(true);
expect(getSupportedThinkingLevels(model)).toEqual(levels);
for (const level of levels) {
let payload: Record<string, unknown> | undefined;
await streamSimple(
model,
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
{
apiKey: "test-fireworks-key",
reasoning: level === "off" ? undefined : level,
onPayload: (value) => {
payload = value as Record<string, unknown>;
throw new Error("payload captured");
},
},
).result();
expect(payload).toBeDefined();
expect(payload?.thinking).toEqual(
level === "off" ? { type: "disabled" } : { type: "adaptive", display: "summarized" },
);
expect(payload?.output_config).toEqual(level === "off" ? undefined : { effort: level });
}
});
// Regression for #9323: accepted aliases are not distinct native effort levels.
it.each([
["accounts/fireworks/models/glm-5p3", ["low", "high", "max"]],
["accounts/fireworks/routers/glm-5p3-fast", ["low", "high", "max"]],
["accounts/fireworks/models/kimi-k3", ["low", "high", "max"]],
["accounts/fireworks/routers/kimi-k3-fast", ["low", "high", "max"]],
] as const)("exposes distinct native effort levels for %s", (modelId, levels) => {
expect(getSupportedThinkingLevels(getModel("fireworks", modelId))).toEqual(levels);
});
it("keeps toggle-only Messages models without a verified fallback on budget-based thinking", async () => {
const model = getModel("fireworks", "accounts/fireworks/models/nemotron-3-ultra-nvfp4");
expect(model.compat?.forceAdaptiveThinking).toBeUndefined();
let payload: Record<string, unknown> | undefined;
await streamSimple(
model,
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
{
apiKey: "test-fireworks-key",
reasoning: "high",
onPayload: (value) => {
payload = value as Record<string, unknown>;
throw new Error("payload captured");
},
},
).result();
expect(payload?.thinking).toEqual({ type: "enabled", budget_tokens: 16384, display: "summarized" });
expect(payload?.output_config).toBeUndefined();
});
it("resolves FIREWORKS_API_KEY from the environment", () => {
process.env.FIREWORKS_API_KEY = "test-fireworks-key";
@@ -129,13 +187,14 @@ describe("Fireworks models", () => {
});
it("sets Fireworks-specific compat for session affinity and unsupported tool fields", () => {
const model = getModel("fireworks", "accounts/fireworks/models/kimi-k2p6");
const model = getModel("fireworks", "accounts/fireworks/models/nemotron-3-ultra-nvfp4");
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);
expect(model.compat?.allowEmptySignature).toBe(true);
});
});
@@ -153,6 +212,7 @@ const tool: Tool = {
};
const FIREWORKS_ANTHROPIC_COMPAT = {
allowEmptySignature: true,
sendSessionAffinityHeaders: true,
supportsEagerToolInputStreaming: false,
supportsCacheControlOnTools: false,
@@ -192,6 +252,15 @@ function createAnthropicModel(): Model<"anthropic-messages"> {
};
}
function createOpenRouterModel(): Model<"anthropic-messages"> {
return {
...createAnthropicModel(),
id: "anthropic/claude-opus-4.8",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api",
};
}
function createContext(tools: Tool[] = [tool]): Context {
return {
messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }],
@@ -234,7 +303,7 @@ async function captureAnthropicRequest(
// 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, {
const stream = streamAnthropic(localModel, normalizeContext(context), {
apiKey: "test-key",
cacheRetention: (options?.cacheRetention as "none" | "short" | "long") ?? "short",
sessionId: options?.sessionId,
@@ -263,7 +332,7 @@ function getTools(body: Record<string, unknown>): Record<string, unknown>[] {
return tools as Record<string, unknown>[];
}
describe("Fireworks Anthropic session affinity and tool compat", () => {
describe("Anthropic-compatible 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
@@ -293,6 +362,35 @@ describe("Fireworks Anthropic session affinity and tool compat", () => {
expect(request.headers["x-session-affinity"]).toBeUndefined();
});
// Regression test for https://github.com/earendil-works/pi/issues/9102
it("sends only x-session-id for OpenRouter models", async () => {
const request = await captureAnthropicRequest(createOpenRouterModel(), createContext(), {
sessionId: "openrouter-session-1",
});
expect(request.headers["x-session-id"]).toBe("openrouter-session-1");
expect(request.headers["x-session-affinity"]).toBeUndefined();
});
it("omits OpenRouter session headers when cacheRetention is none", async () => {
const request = await captureAnthropicRequest(createOpenRouterModel(), createContext(), {
sessionId: "openrouter-session-2",
cacheRetention: "none",
});
expect(request.headers["x-session-id"]).toBeUndefined();
expect(request.headers["x-session-affinity"]).toBeUndefined();
});
it("allows OpenRouter session headers to be disabled", async () => {
const model = { ...createOpenRouterModel(), compat: { sendSessionAffinityHeaders: false } };
const request = await captureAnthropicRequest(model, createContext(), {
sessionId: "openrouter-session-3",
});
expect(request.headers["x-session-id"]).toBeUndefined();
});
it("omits cache_control on tools for Fireworks models", async () => {
const model = createFireworksModel();
const request = await captureAnthropicRequest(model, createContext());
@@ -1,8 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { getModel } from "../src/compat.ts";
import { getModel, normalizeContext } 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,
@@ -52,10 +51,10 @@ vi.mock("@anthropic-ai/sdk", () => {
});
describe("Copilot Claude via Anthropic Messages", () => {
const context: Context = {
const context = normalizeContext({
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");
@@ -70,6 +69,11 @@ describe("Copilot Claude via Anthropic Messages", () => {
expect(getSupportedThinkingLevels(opus5)).toContain("xhigh");
expect(getSupportedThinkingLevels(opus5)).toContain("max");
const opus55 = getModel("github-copilot", "claude-opus-5.5");
expect(opus55.api).toBe("anthropic-messages");
expect(opus55.contextWindow).toBe(1000000);
expect(getSupportedThinkingLevels(opus55)).toEqual(["low", "medium", "high", "xhigh", "max"]);
const sonnet46 = getModel("github-copilot", "claude-sonnet-4.6");
expect(sonnet46.thinkingLevelMap).toMatchObject({ minimal: "low", max: "max" });
expect(getSupportedThinkingLevels(sonnet46)).toContain("max");
@@ -1,10 +1,11 @@
import { arch, platform, release } from "node:os";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const googleGenAiMock = vi.hoisted(() => ({
constructorCalls: [] as Array<Record<string, unknown>>,
finishReason: "MALFORMED_FUNCTION_CALL",
includeFunctionCall: false,
streamChunks: undefined as Array<Record<string, unknown>> | undefined,
}));
vi.mock("@google/genai", () => {
@@ -15,6 +16,10 @@ vi.mock("@google/genai", () => {
models = {
generateContentStream: async function* () {
if (googleGenAiMock.streamChunks) {
yield* googleGenAiMock.streamChunks;
return;
}
yield {
responseId: "google-response-id",
candidates: [
@@ -87,14 +92,18 @@ vi.mock("@google/genai", () => {
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";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { Api, Model, StreamOptions } from "../src/types.ts";
beforeEach(() => {
googleGenAiMock.streamChunks = undefined;
});
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
});
async function captureGoogleHeaders(headers?: Record<string, string>): Promise<Record<string, string>> {
googleGenAiMock.constructorCalls.length = 0;
@@ -183,6 +192,63 @@ describe("Google raw stop reasons", () => {
});
});
describe("Google provider stream events", () => {
const googleModel = getModel("google", "gemini-2.5-flash");
const vertexModel = getModel("google-vertex", "gemini-3-flash-preview");
const adapters = [
{
name: "Google Generative AI",
model: googleModel,
createStream: (onProviderStreamEvent: StreamOptions["onProviderStreamEvent"]) =>
streamGoogleGenerativeAi(googleModel, context, {
apiKey: "test-api-key",
onProviderStreamEvent,
}),
},
{
name: "Google Vertex",
model: vertexModel,
createStream: (onProviderStreamEvent: StreamOptions["onProviderStreamEvent"]) =>
streamGoogleVertex(vertexModel, context, {
project: "test-project",
location: "us-central1",
onProviderStreamEvent,
}),
},
];
it.each(adapters)(
"forwards each SDK chunk in order before normalizing it for $name",
async ({ model, createStream }) => {
googleGenAiMock.streamChunks = [
{
responseId: "resp_google",
candidates: [{ content: { parts: [{ text: "hello" }] } }],
},
{
candidates: [{ finishReason: "STOP" }],
usageMetadata: { promptTokenCount: 2, candidatesTokenCount: 1, totalTokenCount: 3 },
},
];
const received: unknown[] = [];
const eventModels: Model<Api>[] = [];
const result = await createStream(async (chunk, eventModel) => {
await Promise.resolve();
received.push(chunk);
eventModels.push(eventModel);
}).result();
expect(received).toEqual(googleGenAiMock.streamChunks);
expect(received[0]).toBe(googleGenAiMock.streamChunks[0]);
expect(received[1]).toBe(googleGenAiMock.streamChunks[1]);
expect(eventModels).toEqual([model, model]);
expect(result.stopReason).toBe("stop");
expect(result.responseId).toBe("resp_google");
expect(result.content).toEqual([{ type: "text", text: "hello" }]);
},
);
});
describe("Google Generative AI user agent", () => {
it("uses pi's User-Agent by default", async () => {
expect((await captureGoogleHeaders())["User-Agent"]).toBe(PI_USER_AGENT);
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { convertMessages, requiresToolCallId } from "../src/api/google-shared.ts";
import type { Context, Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
function makeGemini3Model<TApi extends "google-generative-ai" | "google-vertex">(
api: TApi,
@@ -84,7 +85,7 @@ describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () =>
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 contents = convertMessages(model, normalizeContext(context));
const functionCallIds = contents
.flatMap((content) => content.parts ?? [])
.flatMap((part) => (part.functionCall?.id ? [part.functionCall.id] : []));
@@ -98,7 +99,7 @@ describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () =>
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 contents = convertMessages(model, normalizeContext(makeContext({ ...model, id: "other-model" })));
const modelTurn = contents.find((c) => c.role === "model");
expect(modelTurn).toBeTruthy();
@@ -116,7 +117,7 @@ describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () =>
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 contents = convertMessages(model, normalizeContext(makeContext(model)));
const modelTurn = contents.find((c) => c.role === "model");
const functionCallParts = modelTurn?.parts?.filter((p) => p.functionCall !== undefined) ?? [];
@@ -129,7 +130,7 @@ describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () =>
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 contents = convertMessages(model, normalizeContext(makeContext(model, validSig)));
const modelTurn = contents.find((c) => c.role === "model");
const functionCallParts = modelTurn?.parts?.filter((p) => p.functionCall !== undefined) ?? [];
@@ -140,7 +141,7 @@ describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () =>
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 contents = convertMessages(model, normalizeContext(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
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { convertMessages } from "../src/api/google-shared.ts";
import type { Context, Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
function makeModel<TApi extends "google-generative-ai">(
api: TApi,
@@ -78,7 +79,7 @@ function makeContext(model: { api: string; provider: string; id: string }): Cont
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));
const contents = convertMessages(model, normalizeContext(makeContext(model)));
expect(contents).toHaveLength(5);
expect(contents[2].parts?.every((part) => part.functionResponse)).toBe(true);
@@ -89,7 +90,7 @@ describe("google-shared image tool result routing", () => {
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));
const contents = convertMessages(model, normalizeContext(makeContext(model)));
expect(contents).toHaveLength(3);
const toolResultTurn = contents[2];
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { convertMessages } from "../src/api/google-shared.ts";
import type { AssistantMessage, Context, Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.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
@@ -59,10 +60,12 @@ describe("google-shared convertMessages — signed empty blocks", () => {
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" } },
]),
normalizeContext(
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) ?? [];
@@ -74,10 +77,12 @@ describe("google-shared convertMessages — signed empty blocks", () => {
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" } },
]),
normalizeContext(
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) ?? [];
@@ -88,11 +93,13 @@ describe("google-shared convertMessages — signed 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" } },
]),
normalizeContext(
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);
@@ -103,11 +110,13 @@ describe("google-shared convertMessages — signed empty blocks", () => {
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" } },
]),
normalizeContext(
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);
@@ -3,18 +3,12 @@ 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";
import type { Model, ThinkingBudgets, ThinkingLevel, ThinkingLevelMap } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "Hello", timestamp: 0 }],
};
});
function googleModel(id: string, thinkingLevelMap: ThinkingLevelMap): Model<"google-generative-ai"> {
return {
@@ -50,7 +44,7 @@ function vertexModel(id: string, thinkingLevelMap: ThinkingLevelMap): Model<"goo
async function captureGooglePayload(
model: Model<"google-generative-ai">,
reasoning: ThinkingLevel,
reasoning?: ThinkingLevel,
thinkingBudgets?: ThinkingBudgets,
): Promise<GenerateContentParameters> {
let payload: GenerateContentParameters | undefined;
@@ -71,7 +65,7 @@ async function captureGooglePayload(
async function captureVertexPayload(
model: Model<"google-vertex">,
reasoning: ThinkingLevel,
reasoning?: ThinkingLevel,
thinkingBudgets?: ThinkingBudgets,
): Promise<GenerateContentParameters> {
let payload: GenerateContentParameters | undefined;
@@ -90,19 +84,29 @@ async function captureVertexPayload(
return payload;
}
const googleAdapters = [
{
name: "Google Generative AI",
capture: (id: string, thinkingLevelMap: ThinkingLevelMap, reasoning?: ThinkingLevel) =>
captureGooglePayload(googleModel(id, thinkingLevelMap), reasoning),
},
{
name: "Google Vertex",
capture: (id: string, thinkingLevelMap: ThinkingLevelMap, reasoning?: ThinkingLevel) =>
captureVertexPayload(vertexModel(id, thinkingLevelMap), reasoning),
},
] as const;
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>>;
} as const satisfies Partial<Record<ThinkingLevel, ThinkingLevel>>;
for (const [level, expected] of Object.entries(defaultExpectations)) {
expect(resolveGoogleThinkingLevel(googleModel("gemini-3.7-flash", {}), level as ModelThinkingLevel)).toBe(
expected,
);
expect(resolveGoogleThinkingLevel(googleModel("gemini-3.7-flash", {}), level as ThinkingLevel)).toBe(expected);
}
const mappedExpectations = {
@@ -131,6 +135,44 @@ describe("Google thinking level maps", () => {
);
});
// Regression test for https://github.com/earendil-works/pi/issues/9455
it.each(googleAdapters)("uses the lowest supported $name level when reasoning is omitted", async ({ capture }) => {
const payload = await capture("gemini-3.8-flash", {
off: null,
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: null,
max: null,
});
expect(payload.config?.thinkingConfig).toEqual({ thinkingLevel: "LOW" });
});
it.each(googleAdapters)("preserves native medium effort for Gemini 3.1 Pro on $name", async ({ capture }) => {
const payload = await capture(
"gemini-3.1-pro-preview",
{ off: null, minimal: null, low: "low", medium: "medium", high: "high", xhigh: null, max: null },
"medium",
);
expect(payload.config?.thinkingConfig).toEqual({ includeThoughts: true, thinkingLevel: "MEDIUM" });
});
it.each(googleAdapters)("disables Gemini 2.5 thinking when reasoning is omitted on $name", async ({ capture }) => {
const payload = await capture("gemini-2.5-flash", {});
expect(payload.config?.thinkingConfig).toEqual({ thinkingBudget: 0 });
});
// Regression test for https://github.com/earendil-works/pi/issues/10890
it.each(googleAdapters)("uses thinking levels for Gemini 4 models on $name", async ({ capture }) => {
const payload = await capture("gemini-4-argon", {}, "medium");
expect(payload.config?.thinkingConfig).toEqual({ includeThoughts: true, thinkingLevel: "MEDIUM" });
});
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" }),
@@ -47,14 +47,14 @@ vi.mock("@google/genai", () => {
});
import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts";
import { getModel } from "../src/compat.ts";
import type { Context, Model } from "../src/types.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { 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 = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
});
const originalGoogleCloudApiKey = process.env.GOOGLE_CLOUD_API_KEY;
+75 -23
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { parseOpenRouterImageModels } from "../scripts/generate-image-models.ts";
import { buildOpenRouterCatalog, type OpenRouterModelListItem } from "../scripts/openrouter-catalog.ts";
const validImageModel = {
const imageOnly: OpenRouterModelListItem = {
id: "example/image-model",
name: "Example Image Model",
architecture: {
@@ -14,34 +14,86 @@ const validImageModel = {
},
};
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");
});
const chatWithImages: OpenRouterModelListItem = {
id: "example/multimodal",
name: "Example Multimodal",
supported_parameters: ["tools"],
architecture: {
modality: "text+image->text+image",
input_modalities: ["text", "image"],
output_modalities: ["text", "image"],
},
context_length: 32000,
};
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");
});
const chatOnly: OpenRouterModelListItem = {
id: "example/chat",
name: "Example Chat",
supported_parameters: ["tools"],
architecture: { modality: "text->text", output_modalities: ["text"] },
};
it("parses a non-empty image model catalog", () => {
expect(parseOpenRouterImageModels({ data: [validImageModel] }, true)).toEqual([
const decisionModel: OpenRouterModelListItem = {
id: "typesafe/jev-1.13",
name: "TypeSafe: Jev 1.13",
supported_parameters: [],
architecture: { modality: "text->decisions", input_modalities: ["text"], output_modalities: ["decisions"] },
pricing: { prompt: "0.000000042", completion: "0" },
context_length: 32000,
top_provider: { context_length: 32000, max_completion_tokens: 28800 },
};
describe("OpenRouter catalog parsing", () => {
it("emits image-only models from the image listing as image models", () => {
const catalog = buildOpenRouterCatalog([], [imageOnly], []);
expect(catalog.chat).toEqual([]);
expect(catalog.images).toEqual([
expect.objectContaining({
type: "image",
id: "example/image-model",
api: "openrouter-images",
input: ["text", "image"],
output: ["image"],
cost: expect.objectContaining({ input: 1, output: 2 }),
}),
]);
});
it("emits separate chat and image entries for an id that supports both operations", () => {
const catalog = buildOpenRouterCatalog([chatWithImages, chatOnly], [chatWithImages, imageOnly], []);
expect(catalog.chat.map((model) => model.id)).toEqual(["example/multimodal", "example/chat"]);
expect(catalog.chat.map((model) => model.type)).toEqual(["chat", "chat"]);
expect(catalog.images.map((model) => model.id)).toEqual(["example/multimodal", "example/image-model"]);
expect(catalog.images.every((model) => model.type === "image")).toBe(true);
expect(catalog.images.map((model) => model.output)).toEqual([["text", "image"], ["image"]]);
});
it("ignores listed models that neither support tools nor emit images", () => {
const catalog = buildOpenRouterCatalog(
[{ ...chatOnly, supported_parameters: [] }],
[{ ...imageOnly, architecture: { output_modalities: ["text"] } }],
[{ ...decisionModel, architecture: { output_modalities: ["text"] } }],
);
expect(catalog.chat).toEqual([]);
expect(catalog.images).toEqual([]);
expect(catalog.classifiers).toEqual([]);
});
it("emits decision models as System One classifier models", () => {
const catalog = buildOpenRouterCatalog([], [], [decisionModel, decisionModel]);
expect(catalog.chat).toEqual([]);
expect(catalog.classifiers).toEqual([
{
type: "classifier",
id: "typesafe/jev-1.13",
name: "TypeSafe: Jev 1.13",
api: "typesafe-system-one",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text"],
cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32000,
},
]);
});
});
+3 -3
View File
@@ -249,7 +249,7 @@ describe("Tool Results with Images", () => {
});
describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider (gpt-4o-mini)", () => {
const llm = getModel("azure-openai-responses", "gpt-4o-mini");
const llm = getModel("azure", "gpt-4o-mini");
const azureDeploymentName = resolveAzureDeploymentName(llm.id);
const azureOptions = azureDeploymentName ? { azureDeploymentName } : {};
@@ -298,8 +298,8 @@ describe("Tool Results with Images", () => {
});
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider (Kimi-K2.6)", () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider (Kimi-K3)", () => {
const llm = getModel("together", "moonshotai/Kimi-K3");
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
+284 -83
View File
@@ -1,8 +1,35 @@
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";
import { getModels as getCompatModels } from "../src/compat.ts";
import {
type CreateProviderOptions,
createModels,
createProvider,
getModelType,
hasApi,
isModelType,
type Provider,
} from "../src/models.ts";
import { InMemoryModelsStore } from "../src/models-store.ts";
import {
builtinModels,
getAllBuiltinModels,
getBuiltinClassifierModels,
getBuiltinImageModel,
getBuiltinImageModels,
getBuiltinModels,
} from "../src/providers/all.ts";
import type {
AnyModel,
Api,
AssistantImages,
ImageApi,
ImageModel,
ImagesContext,
ImagesOptions,
Model,
} from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function fakeAuthContext(env: Record<string, string>): AuthContext {
return {
@@ -11,8 +38,9 @@ function fakeAuthContext(env: Record<string, string>): AuthContext {
};
}
function testImageModel(provider: string, id: string): ImagesModel<ImagesApi> {
function imageModel(provider: string, id: string): ImageModel<ImageApi> {
return {
type: "image",
id,
name: id,
api: "test-images",
@@ -24,7 +52,22 @@ function testImageModel(provider: string, id: string): ImagesModel<ImagesApi> {
};
}
function okResult(model: ImagesModel<ImagesApi>): AssistantImages {
function chatModel(provider: string, id: string): Model<Api> {
return {
id,
name: id,
api: "test-chat",
provider,
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
}
function okResult(model: ImageModel<ImageApi>): AssistantImages {
return {
api: model.api,
provider: model.provider,
@@ -36,17 +79,24 @@ function okResult(model: ImagesModel<ImagesApi>): AssistantImages {
}
interface GenerateCall {
model: ImagesModel<ImagesApi>;
model: ImageModel<ImageApi>;
options: ImagesOptions | undefined;
}
function testProvider(input: {
id: string;
models?: ImagesModel<ImagesApi>[];
models?: AnyModel[];
envVar?: string;
calls?: GenerateCall[];
}): ImagesProvider {
return createImagesProvider({
images?: Record<string, boolean>;
}): Provider {
const generateImages = async (model: ImageModel<ImageApi>, _context: ImagesContext, options?: ImagesOptions) => {
input.calls?.push({ model, options });
return okResult(model);
};
const imageApis = Object.keys(input.images ?? { "test-images": true });
const allModels = input.models ?? [imageModel(input.id, "model-a")];
return createProvider({
id: input.id,
auth: {
apiKey: {
@@ -58,39 +108,102 @@ function testProvider(input: {
},
},
},
models: input.models ?? [testImageModel(input.id, "model-a")],
models: allModels,
api: {
generateImages: async (model, _context, options) => {
input.calls?.push({ model, options });
return okResult(model);
"test-chat": {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
},
},
images: Object.fromEntries(imageApis.map((api) => [api, { generateImages }])),
});
}
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")] }));
describe("model discriminants", () => {
it("treats models without a type as chat models", () => {
const chat = chatModel("p", "c");
const image = imageModel("p", "i");
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();
expect(chat.type).toBeUndefined();
expect(getModelType(chat)).toBe("chat");
expect(getModelType({ ...chat, type: "chat" })).toBe("chat");
expect(getModelType(image)).toBe("image");
expect(isModelType(chat, "chat")).toBe(true);
expect(isModelType(chat, "image")).toBe(false);
expect(isModelType(image, "image")).toBe(true);
models.deleteProvider("p1");
expect(models.getProvider("p1")).toBeUndefined();
// hasApi never matches an image model, even on an equal api string
expect(hasApi({ ...image, api: "test-chat" }, "test-chat")).toBe(false);
expect(hasApi(chat, "test-chat")).toBe(true);
});
});
describe("Models with image models", () => {
it("lists models without a type as chat models at createProvider boundaries", async () => {
const provider = createProvider({
id: "legacy",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [chatModel("legacy", "static"), imageModel("legacy", "static")],
fetchModels: async () => [chatModel("legacy", "dynamic")],
api: {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
},
});
const models = createModels();
models.setProvider(provider);
expect(provider.getModels().map((model) => model.id)).toEqual(["static"]);
await models.refresh({ providers: [provider.id] });
expect(provider.getModels().map((model) => model.id)).toEqual(["static", "dynamic"]);
expect(provider.getModels().map((model) => model.type)).toEqual([undefined, undefined]);
expect(models.getModelsOfType("image", "legacy").map((model) => model.id)).toEqual(["static"]);
});
it("resolves auth through the provider and merges it into requests; explicit options win", async () => {
it("lists chat, image, and all models through typed accessors", () => {
const models = createModels();
models.setProvider(
testProvider({
id: "p1",
models: [chatModel("p1", "c1"), imageModel("p1", "i1"), imageModel("p1", "i2")],
}),
);
models.setProvider(testProvider({ id: "p2", models: [imageModel("p2", "i3")] }));
expect(models.getModels().map((m) => m.id)).toEqual(["c1"]);
expect(models.getModelsOfType("chat").map((m) => m.id)).toEqual(["c1"]);
expect(models.getModelsOfType("image").map((m) => m.id)).toEqual(["i1", "i2", "i3"]);
expect(models.getModelsOfType("image", "p1").map((m) => m.id)).toEqual(["i1", "i2"]);
expect(models.getModelsOfType("classifier")).toEqual([]);
expect(models.getAllModels().map((m) => m.id)).toEqual(["c1", "i1", "i2", "i3"]);
expect(models.getModel("p1", "c1")?.id).toBe("c1");
expect(models.getModel("p1", "i1")).toBeUndefined();
expect(models.getModelOfType("chat", "p1", "c1")?.id).toBe("c1");
expect(models.getModelOfType("image", "p1", "i1")?.id).toBe("i1");
expect(models.getModelOfType("image", "p1", "c1")).toBeUndefined();
});
it("splits available models by type", async () => {
const models = createModels({ authContext: fakeAuthContext({ KEY: "k" }) });
models.setProvider(
testProvider({ id: "p1", envVar: "KEY", models: [chatModel("p1", "c1"), imageModel("p1", "i1")] }),
);
models.setProvider(testProvider({ id: "p2", envVar: "MISSING", models: [imageModel("p2", "i2")] }));
expect((await models.getAvailable()).map((m) => m.id)).toEqual(["c1"]);
expect((await models.getAvailableOfType("chat")).map((m) => m.id)).toEqual(["c1"]);
expect((await models.getAvailableOfType("image")).map((m) => m.id)).toEqual(["i1"]);
expect((await models.getAllAvailable()).map((m) => m.id)).toEqual(["c1", "i1"]);
});
it("resolves auth through the provider and merges it into image requests; explicit options win", async () => {
const calls: GenerateCall[] = [];
const models = createImagesModels({ authContext: fakeAuthContext({ TEST_KEY: "env-key" }) });
const models = createModels({ authContext: fakeAuthContext({ TEST_KEY: "env-key" }) });
models.setProvider(testProvider({ id: "p1", envVar: "TEST_KEY", calls }));
const model = models.getModel("p1", "model-a")!;
const model = models.getModelOfType("image", "p1", "model-a")!;
expect((await models.getAuth(model))?.auth.apiKey).toBe("env-key");
expect((await models.getAuth(model.provider))?.auth.apiKey).toBe("env-key");
@@ -104,35 +217,38 @@ describe("ImagesModels", () => {
expect(calls[1].options?.apiKey).toBe("explicit");
});
it("merges provider-resolved env into image options", async () => {
it("merges provider-resolved env and applies header transforms", async () => {
const calls: GenerateCall[] = [];
const models = createImagesModels();
const models = createModels();
models.setProvider(
createImagesProvider({
createProvider({
id: "p1",
auth: {
apiKey: {
name: "Test key",
resolve: async () => ({
auth: { apiKey: "provider-key" },
auth: { apiKey: "provider-key", headers: { "x-base": "1" } },
env: { PROVIDER_ONLY: "provider", SHARED: "provider" },
}),
},
},
models: [testImageModel("p1", "model-a")],
api: {
generateImages: async (model, _context, options) => {
calls.push({ model, options });
return okResult(model);
models: [imageModel("p1", "model-a")],
images: {
"test-images": {
generateImages: async (model, _context, options) => {
calls.push({ model, options });
return okResult(model);
},
},
},
}),
);
const model = models.getModel("p1", "model-a")!;
const model = models.getModelOfType("image", "p1", "model-a")!;
await models.generateImages(model, context, {
apiKey: "request-key",
env: { REQUEST_ONLY: "request", SHARED: "request" },
transformHeaders: (headers) => ({ ...headers, "x-extra": "2" }),
});
expect(calls[0].options?.apiKey).toBe("request-key");
@@ -141,69 +257,154 @@ describe("ImagesModels", () => {
REQUEST_ONLY: "request",
SHARED: "request",
});
expect(calls[0].options?.headers).toEqual({ "x-base": "1", "x-extra": "2" });
});
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);
it("returns error results instead of rejecting", async () => {
const models = createModels({ authContext: fakeAuthContext({}) });
const ghost = await models.generateImages(imageModel("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
// Unconfigured auth is an error, matching stream().
const calls: GenerateCall[] = [];
models.setProvider(testProvider({ id: "p1", envVar: "MISSING", calls }));
const model = models.getModel("p1", "model-a")!;
const model = models.getModelOfType("image", "p1", "model-a")!;
expect(await models.getAuth(model)).toBeUndefined();
await models.generateImages(model, context);
expect(calls[0].options?.apiKey).toBeUndefined();
});
const unconfigured = await models.generateImages(model, context);
expect(unconfigured.stopReason).toBe("error");
expect(unconfigured.errorMessage).toContain("not configured");
expect(calls).toEqual([]);
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);
const controller = new AbortController();
controller.abort();
const cancelled = await models.generateImages(model, context, { signal: controller.signal });
expect(cancelled.stopReason).toBe("aborted");
expect(calls).toEqual([]);
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
// A provider without any images implementation rejects image models it lists.
models.setProvider(
createImagesProvider({
id: "flaky",
createProvider({
id: "chat-only",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
refreshModels: async () => {
throw new Error("fetch failed");
models: [imageModel("chat-only", "i")],
api: {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
},
api: { generateImages: async (model) => okResult(model) },
}),
);
await expect(models.refresh("flaky")).rejects.toMatchObject({ code: "model_source" });
await expect(models.refresh()).resolves.toBeUndefined();
const unsupported = await models.generateImages(models.getModelOfType("image", "chat-only", "i")!, context);
expect(unsupported.stopReason).toBe("error");
expect(unsupported.errorMessage).toContain("does not support image generation");
// An images map without the model's api yields a provider error result.
models.setProvider(
testProvider({ id: "wrong-api", models: [imageModel("wrong-api", "i")], images: { "other-images": true } }),
);
const missingApi = await models.generateImages(models.getModelOfType("image", "wrong-api", "i")!, context);
expect(missingApi.stopReason).toBe("error");
expect(missingApi.errorMessage).toContain('no image generation implementation for "test-images"');
});
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"]);
it("rejects chat models at the image entry point at runtime", async () => {
const models = createModels();
const chat = chatModel("p1", "chat");
models.setProvider(testProvider({ id: "p1", models: [chat], images: { "test-chat": true } }));
const list = models.getModels("openrouter");
expect(list.length).toBeGreaterThan(0);
expect(list.every((m) => m.api === "openrouter-images")).toBe(true);
const result = await models.generateImages(chat as unknown as ImageModel<ImageApi>, context);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("is not an image model");
});
expect((await models.getAuth(list[0]))?.auth.apiKey).toBe("or-key");
it("rejects image models at the stream entry points at runtime", async () => {
const models = createModels();
models.setProvider(testProvider({ id: "p1" }));
const image = models.getModelOfType("image", "p1", "model-a")!;
const result = await models.streamSimple(image as unknown as Model<Api>, { messages: [] }).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("is not a chat model");
});
it("requires at least one concrete operation implementation", () => {
const createEmptyProvider = (implementations: Pick<CreateProviderOptions, "api" | "images" | "classifiers">) =>
createProvider({
id: "empty",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
...implementations,
});
const message = 'at least one of "api", "images", or "classifiers"';
expect(() => createEmptyProvider({})).toThrow(message);
expect(() => createEmptyProvider({ api: {} })).toThrow(message);
expect(() => createEmptyProvider({ images: {} })).toThrow(message);
expect(() => createEmptyProvider({ classifiers: {} })).toThrow(message);
});
it("supports dynamic providers listing image models via refresh", async () => {
let fetches = 0;
const modelsStore = new InMemoryModelsStore();
const models = createModels({ modelsStore });
models.setProvider(
createProvider({
id: "dyn",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
fetchModels: async () => {
fetches++;
return [imageModel("dyn", "listed"), chatModel("dyn", "chat")];
},
images: { "test-images": { generateImages: async (model) => okResult(model) } },
}),
);
expect(models.getAllModels("dyn")).toEqual([]);
const result = await models.refresh({ providers: ["dyn"] });
expect(result.errors.size).toBe(0);
expect(fetches).toBe(1);
expect(models.getModelOfType("image", "dyn", "listed")).toBeDefined();
expect(models.getModel("dyn", "chat")).toBeDefined();
const stored = await modelsStore.read("dyn");
expect(stored?.models.map((model) => model.id)).toEqual(["listed", "chat"]);
});
it("keeps existing built-in and compat model reads chat-only", () => {
const chat = getBuiltinModels("openrouter");
const images = getBuiltinImageModels("openrouter");
const all = getAllBuiltinModels("openrouter");
const compat = getCompatModels("openrouter");
expect(chat.every((model) => isModelType(model, "chat"))).toBe(true);
expect(images.every((model) => isModelType(model, "image"))).toBe(true);
expect(all.some((model) => isModelType(model, "image"))).toBe(true);
expect(compat).toEqual(chat);
expect(chat.every((model) => model.contextWindow > 0)).toBe(true);
expect(chat.length + images.length + getBuiltinClassifierModels("openrouter").length).toBe(all.length);
expect(getBuiltinImageModel("openrouter", "black-forest-labs/flux.2-pro").type).toBe("image");
});
it("builtinModels exposes OpenRouter image models under the openrouter provider", async () => {
const models = builtinModels({ authContext: fakeAuthContext({ OPENROUTER_API_KEY: "or-key" }) });
const provider = models.getProvider("openrouter")!;
const images = models.getModelsOfType("image", "openrouter");
expect(images.length).toBeGreaterThan(0);
expect(provider.getModels().every((model) => isModelType(model, "chat"))).toBe(true);
expect(provider.getAllModels?.().some((model) => isModelType(model, "image"))).toBe(true);
expect(images.every((m) => m.type === "image" && m.api === "openrouter-images")).toBe(true);
expect(models.getModelsOfType("image").every((m) => m.provider === "openrouter")).toBe(true);
// One upstream id can expose separate chat and image operations.
const chat = models.getModel("openrouter", "google/gemini-3-pro-image");
const image = models.getModelOfType("image", "openrouter", "google/gemini-3-pro-image");
expect(chat?.api).toBe("openai-completions");
expect(image?.api).toBe("openrouter-images");
// One credential covers both.
expect((await models.getAuth(images[0]))?.auth.apiKey).toBe("or-key");
expect((await models.getAuth(chat!))?.auth.apiKey).toBe("or-key");
expect(provider.generateImages).toBeDefined();
});
});
+3 -27
View File
@@ -4,14 +4,14 @@ 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";
import type { ImageContent, ImageModel, ImagesContext, 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) {
async function basicImageGeneration<TApi extends string>(model: ImageModel<TApi>, options?: ImagesOptionsWithExtras) {
const context: ImagesContext = {
input: [{ type: "text", text: "Generate a simple red circle on a plain white background. No text." }],
};
@@ -24,27 +24,7 @@ async function basicImageGeneration<TApi extends string>(model: ImagesModel<TApi
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) {
async function handleImageInput<TApi extends string>(model: ImageModel<TApi>, options?: ImagesOptionsWithExtras) {
if (!model.input.includes("image")) {
console.log(`Skipping image input test - model ${model.id} doesn't support image input`);
return;
@@ -78,10 +58,6 @@ describe("Images E2E Tests", () => {
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);
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { parseStreamingJson, parseToolCallArguments } from "../src/utils/json-parse.ts";
describe("parseStreamingJson", () => {
// #10868
it("keeps invalid \\u escapes as literal text without dropping fields", () => {
expect(parseStreamingJson(String.raw`{"text":"\url{x}"}`)).toEqual({ text: "\\url{x}" });
expect(parseStreamingJson(String.raw`{"path":"a.tex","content":"\usepackage{x}","mode":"w"}`)).toEqual({
path: "a.tex",
content: "\\usepackage{x}",
mode: "w",
});
});
it("decodes valid \\u escapes", () => {
expect(parseStreamingJson(String.raw`{"text":"caf\u00e9 \url"}`)).toEqual({ text: "café \\url" });
});
it("omits a \\u escape that is cut off mid-stream", () => {
expect(parseStreamingJson(String.raw`{"text":"ab\u00`)).toEqual({ text: "ab" });
});
});
// #10897
describe("parseToolCallArguments", () => {
it("parses complete JSON and repairs invalid escapes", () => {
expect(parseToolCallArguments(String.raw`{"kind":"seek","note":"\url"}`)).toEqual({
arguments: { kind: "seek", note: "\\url" },
});
});
it("uses empty arguments for empty text", () => {
expect(parseToolCallArguments("")).toEqual({ arguments: {} });
});
it("does not keep the valid prefix of malformed JSON", () => {
const raw = '{"action":"apply","actions":[{"kind":"seek","time":.5}]}';
const result = parseToolCallArguments(raw);
expect(result.arguments).toEqual({});
expect(result.argumentsError).toMatch(/\n\nReceived arguments:\n/);
expect(result.argumentsError?.endsWith(raw)).toBe(true);
});
it("does not complete cut-off JSON", () => {
const raw = '{"kind":"seek","clipId":"clip-fl';
const result = parseToolCallArguments(raw);
expect(result.arguments).toEqual({});
expect(result.argumentsError?.endsWith(raw)).toBe(true);
});
it("rejects JSON that is not an object", () => {
expect(parseToolCallArguments("[1,2]")).toEqual({
arguments: {},
argumentsError: "Tool arguments must be a JSON object\n\nReceived arguments:\n[1,2]",
});
});
});
@@ -0,0 +1,405 @@
import { describe, expect, it } from "vitest";
import {
answerFromProbabilities,
classify,
labelProbabilities,
llamaServerRoot,
peakConfidence,
renderQuestion,
} from "../src/api/llama-cpp-classify.ts";
import type { ClassifierContext, ClassifierModel, ClassifierOptions } from "../src/types.ts";
let serverCount = 0;
/** A fresh server URL per test: label tokens are cached per server and model. */
function model(): ClassifierModel<"llama-cpp-classify"> {
serverCount++;
return {
type: "classifier",
id: "qwen",
name: "qwen",
api: "llama-cpp-classify",
provider: "llama.cpp",
baseUrl: `http://llama-${serverCount}.test:8080/v1`,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32768,
};
}
interface RecordedRequest {
url: string;
body: Record<string, unknown>;
headers: Headers;
}
interface FakeServerOptions {
/** Log-probabilities of the next token by token text, in rank order. */
next?: (prompt: string, depth: number) => Record<string, number>;
template?: (messages: Array<{ role: string; content: string }>) => string;
tokenize?: (content: string) => number[];
}
/** Token IDs: one per character, the character code. */
function charTokens(content: string): number[] {
return [...content].map((char) => char.codePointAt(0)!);
}
function fakeServer(options: FakeServerOptions = {}) {
const requests: RecordedRequest[] = [];
const fetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url = String(input);
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
requests.push({ url, body, headers: new Headers(init?.headers) });
const path = new URL(url).pathname;
if (path === "/tokenize") {
const content = String(body.content);
return Response.json({ tokens: (options.tokenize ?? charTokens)(content) });
}
if (path === "/apply-template") {
const messages = body.messages as Array<{ role: string; content: string }>;
const prompt =
options.template?.(messages) ??
`${messages.map((message) => `<|${message.role}|>\n${message.content}\n`).join("")}<|assistant|>\n`;
return Response.json({ prompt });
}
if (path === "/completion") {
const next = options.next?.(String(body.prompt), Number(body.n_probs)) ?? { A: -0.1, B: -2.5 };
const top_logprobs = Object.entries(next).map(([token, logprob]) => ({
id: token.codePointAt(0),
token,
bytes: [],
logprob,
}));
return Response.json({ content: "A", completion_probabilities: [{ id: 65, token: "A", top_logprobs }] });
}
return new Response("not found", { status: 404 });
};
return { fetch, requests };
}
/** Completion log-probabilities for bool (Yes/No) and letter labels; the fake tokenizer maps a label to its first character. */
function answerByPrompt(prompt: string): Record<string, number> {
if (prompt.includes("Answer Yes or No.")) return { Y: -0.05, N: -3 };
if (prompt.includes("Answer with one level number.")) return { "2": -0.2, "1": -1.8, "0": -4 };
return { B: -0.3, A: -1.5, C: -3 };
}
const context: ClassifierContext = {
state: { message: "Help! My payouts have been failing for 3 days." },
questions: {
team: {
type: "choice",
instructions: "Which team should handle this?",
criteria: { billing: "Payments and refunds", technical: "Bugs and outages", sales: "" },
},
urgent: {
type: "bool",
instructions: "Does this convey urgency?",
criteria: { true: "The user needs help soon", false: "No time pressure" },
},
severity: { type: "score", instructions: "How severe is this?", criteria: ["low", "medium", "high"] },
},
};
/** Maps multi-character labels to single tokens, as a real vocabulary would. */
function wordTokens(content: string): number[] {
const words: Record<string, number> = { Yes: 89, No: 78 };
const tokens: number[] = [];
for (const part of content.split(/(\n)/u)) {
if (part === "") continue;
tokens.push(...(words[part] !== undefined ? [words[part]] : charTokens(part)));
}
return tokens;
}
describe("llama.cpp classifier", () => {
it("answers choice, bool and score questions from label log-probabilities", async () => {
const server = fakeServer({ next: answerByPrompt, tokenize: wordTokens });
const classifierModel = model();
const result = await classify(classifierModel, context, { apiKey: "local", fetch: server.fetch });
expect(result.errorMessage).toBeUndefined();
expect(result.stopReason).toBe("stop");
const choice = labelProbabilities([-1.5, -0.3, -3], 1);
expect(result.answers.team).toEqual({
type: "choice",
choice: "technical",
probabilities: { billing: choice[0], technical: choice[1], sales: choice[2] },
confidence: peakConfidence(choice),
});
expect(result.answers.urgent).toEqual({ type: "bool", probability: labelProbabilities([-0.05, -3], 1)[0] });
const levels = labelProbabilities([-4, -1.8, -0.2], 1);
expect(result.answers.severity).toEqual({
type: "score",
score: levels[1]! + 2 * levels[2]!,
confidence: peakConfidence(levels),
});
const root = classifierModel.baseUrl.replace(/\/v1$/u, "");
for (const request of server.requests) {
expect(request.url.startsWith(`${root}/`)).toBe(true);
expect(request.body.model).toBe("qwen");
expect(request.headers.get("authorization")).toBe("Bearer local");
}
const completion = server.requests.find((request) => request.url.endsWith("/completion"));
expect(completion?.body).toMatchObject({
n_predict: 1,
n_probs: 256,
post_sampling_probs: false,
cache_prompt: true,
});
const template = server.requests.find((request) => request.url.endsWith("/apply-template"));
expect(template?.body.chat_template_kwargs).toEqual({ enable_thinking: false });
});
it("repeats the state around all questions and ends with this question's labels", () => {
const rendered = renderQuestion(context, "team");
const state = 'State:\n{\n "message": "Help! My payouts have been failing for 3 days."\n}';
expect(rendered.labels).toEqual(["A", "B", "C"]);
expect(rendered.keys).toEqual(["billing", "technical", "sales"]);
expect(rendered.content).toBe(
[
state,
"",
"Task: answer each of the following questions about the state.",
"",
"Question: Which team should handle this?",
"",
"Options:",
"- billing: Payments and refunds",
"- technical: Bugs and outages",
"- sales",
"",
"Question: Does this convey urgency?",
"",
"Yes means: The user needs help soon",
"No means: No time pressure",
"",
"Question: How severe is this?",
"",
"Levels:",
"0. low",
"1. medium",
"2. high",
"",
state,
"",
"Question: Which team should handle this?",
"",
"Options:",
"A. billing: Payments and refunds",
"B. technical: Bugs and outages",
"C. sales",
"",
"Answer with one letter.",
].join("\n"),
);
});
it("shares everything before the final question across the questions of a request", () => {
const prefix = (id: string) => {
const { content } = renderQuestion(context, id);
return content.slice(0, content.lastIndexOf("Question:"));
};
expect(prefix("urgent")).toBe(prefix("team"));
expect(prefix("severity")).toBe(prefix("team"));
expect(
renderQuestion(context, "urgent").content.endsWith("No means: No time pressure\n\nAnswer Yes or No."),
).toBe(true);
expect(renderQuestion(context, "severity").content.endsWith("2. high\n\nAnswer with one level number.")).toBe(
true,
);
});
it("divides label log-probabilities by the temperature", async () => {
const server = fakeServer({ next: () => ({ A: -0.1, B: -2.5 }) });
const questions: ClassifierContext["questions"] = {
pick: { type: "choice", instructions: "Pick one", criteria: { a: "", b: "" } },
};
const result = await classify(model(), { state: {}, questions }, { fetch: server.fetch, temperature: 2 });
const expected = labelProbabilities([-0.1 / 2, -2.5 / 2], 1);
expect(result.answers.pick).toMatchObject({ probabilities: { a: expected[0], b: expected[1] } });
expect(labelProbabilities([-0.1, -2.5], 2)).toEqual(expected);
});
it("rejects non-positive temperatures before sending requests", async () => {
const server = fakeServer();
const result = await classify(model(), context, { fetch: server.fetch, temperature: 0 });
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Temperature must be a positive number, got 0");
expect(server.requests).toEqual([]);
});
it("retries deeper readouts when a label is missing and fails without inventing zeros", async () => {
const questions: ClassifierContext["questions"] = {
pick: { type: "choice", instructions: "Pick one", criteria: { a: "", b: "" } },
};
const deep = fakeServer({
next: (_prompt, depth): Record<string, number> => (depth < 4096 ? { A: -0.1 } : { A: -0.1, B: -9 }),
});
const recovered = await classify(model(), { state: {}, questions }, { fetch: deep.fetch });
expect(recovered.stopReason).toBe("stop");
expect(completionDepths(deep.requests)).toEqual([256, 4096]);
const never = fakeServer({ next: () => ({ A: -0.1 }) });
const failed = await classify(model(), { state: {}, questions }, { fetch: never.fetch });
expect(failed.stopReason).toBe("error");
expect(failed.answers).toEqual({});
expect(failed.errorMessage).toContain("did not rank labels B for pick within the top 32768 tokens");
expect(completionDepths(never.requests)).toEqual([256, 4096, 32768]);
});
it("closes a reasoning block the template leaves open", async () => {
const server = fakeServer({ template: () => "<|assistant|>\n<think>" });
await classify(
model(),
{ state: {}, questions: { pick: { type: "choice", instructions: "Pick", criteria: { a: "", b: "" } } } },
{ fetch: server.fetch },
);
const completion = server.requests.find((request) => request.url.endsWith("/completion"));
expect(completion?.body.prompt).toBe("<|assistant|>\n<think></think>");
});
it("reads labels in reply position and rejects labels that are not one token", async () => {
// A tokenizer that merges a newline with a following letter falls back to the label alone.
const merging = fakeServer({
tokenize: (content) => (content.startsWith("\n") && content.length > 1 ? [1000] : charTokens(content)),
});
const merged = await classify(
model(),
{ state: {}, questions: { pick: { type: "choice", instructions: "Pick", criteria: { a: "", b: "" } } } },
{ fetch: merging.fetch },
);
expect(merged.stopReason).toBe("stop");
// The default fake tokenizer splits "Yes" into three tokens.
const split = fakeServer();
const result = await classify(
model(),
{ state: {}, questions: { ok: { type: "bool", instructions: "OK?", criteria: { true: "", false: "" } } } },
{ fetch: split.fetch },
);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain('Label "Yes" is not a single token for qwen');
});
it("caches label tokens per server and model", async () => {
const classifierModel = model();
const server = fakeServer();
const questions: ClassifierContext["questions"] = {
pick: { type: "choice", instructions: "Pick", criteria: { a: "", b: "" } },
};
await classify(classifierModel, { state: {}, questions }, { fetch: server.fetch });
const firstTokenizations = server.requests.filter((request) => request.url.endsWith("/tokenize")).length;
await classify(classifierModel, { state: {}, questions }, { fetch: server.fetch });
expect(firstTokenizations).toBeGreaterThan(0);
expect(server.requests.filter((request) => request.url.endsWith("/tokenize"))).toHaveLength(firstTokenizations);
});
it("validates option counts before sending requests", async () => {
const server = fakeServer();
const criteria = Object.fromEntries(Array.from({ length: 63 }, (_value, index) => [`option${index}`, ""]));
const tooMany = await classify(
model(),
{ state: {}, questions: { pick: { type: "choice", instructions: "Pick", criteria } } },
{ fetch: server.fetch },
);
const tooFew = await classify(
model(),
{ state: {}, questions: { rate: { type: "score", instructions: "Rate", criteria: ["only"] } } },
{ fetch: server.fetch },
);
expect(tooMany.errorMessage).toContain("A choice question needs 2 to 62 options, got 63");
expect(tooFew.errorMessage).toContain("A score question needs 2 to 10 levels, got 1");
expect(server.requests).toEqual([]);
});
it("passes completion payloads and responses through the request hooks", async () => {
const server = fakeServer();
const payloads: unknown[] = [];
const statuses: number[] = [];
const options: ClassifierOptions = {
fetch: server.fetch,
onPayload: (payload) => {
payloads.push(payload);
return { ...(payload as object), id_slot: 1 };
},
onResponse: (response) => {
statuses.push(response.status);
},
};
await classify(
model(),
{ state: {}, questions: { pick: { type: "choice", instructions: "Pick", criteria: { a: "", b: "" } } } },
options,
);
expect(payloads).toHaveLength(1);
expect(payloads[0]).toMatchObject({ n_predict: 1 });
expect(statuses).toEqual([200]);
expect(server.requests.find((request) => request.url.endsWith("/completion"))?.body.id_slot).toBe(1);
});
it("reports server errors and cancellation", async () => {
const failing = await classify(model(), context, {
maxRetries: 0,
fetch: async () => new Response('{"error":{"message":"context overflow"}}', { status: 400 }),
});
expect(failing.stopReason).toBe("error");
expect(failing.errorMessage).toContain("llama.cpp error (400)");
expect(failing.errorMessage).toContain("context overflow");
const controller = new AbortController();
controller.abort();
const aborted = await classify(model(), context, {
signal: controller.signal,
fetch: async (_input, init) => {
init?.signal?.throwIfAborted();
return Response.json({});
},
});
expect(aborted.stopReason).toBe("aborted");
});
it("rejects models for other classifier APIs", async () => {
const server = fakeServer();
const result = await classify({ ...model(), api: "typesafe-system-one" }, context, { fetch: server.fetch });
expect(result.errorMessage).toContain("Unsupported classifier API: typesafe-system-one");
expect(server.requests).toEqual([]);
});
it("derives the server root from OpenAI-compatible base URLs", () => {
expect(llamaServerRoot("http://127.0.0.1:8080/v1/")).toBe("http://127.0.0.1:8080");
expect(llamaServerRoot("https://example.com/prefix/v1")).toBe("https://example.com/prefix");
expect(llamaServerRoot("http://127.0.0.1:8080")).toBe("http://127.0.0.1:8080");
});
it("computes TypeSafe's confidence and expected scores", () => {
expect(peakConfidence([0.89, 0.06, 0.05])).toBeCloseTo(0.835);
expect(peakConfidence([0.5, 0.5])).toBe(0);
expect(peakConfidence([1, 0, 0])).toBe(1);
expect(
answerFromProbabilities(
{ type: "score", instructions: "", criteria: ["a", "b", "c"] },
["0", "1", "2"],
[0.2, 0.3, 0.5],
),
).toEqual({ type: "score", score: 1.3, confidence: peakConfidence([0.2, 0.3, 0.5]) });
});
});
function completionDepths(requests: RecordedRequest[]): number[] {
return requests
.filter((request) => request.url.endsWith("/completion"))
.map((request) => Number(request.body.n_probs));
}
+23 -20
View File
@@ -1,7 +1,7 @@
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";
import { clampThinkingLevel, getModel, getSupportedThinkingLevels, normalizeContext } from "../src/compat.ts";
import type { Model } from "../src/types.ts";
function mockToken(): string {
const payload = Buffer.from(
@@ -30,7 +30,7 @@ describe("max thinking level", () => {
expect(clampThinkingLevel(model, "max")).toBe("high");
});
it.each(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"] as const)(
it.each(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-6-luna", "gpt-6-sol"] as const)(
"exposes xhigh and max for openai-codex/%s",
(modelId) => {
const model = getModel("openai-codex", modelId);
@@ -67,23 +67,26 @@ describe("max thinking level", () => {
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;
it.each(["gpt-5.6-sol", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna", "gpt-6.1-sol"] as const)(
"sends max to the Codex Responses API for %s",
async (modelId) => {
const model = getModel("openai-codex", modelId)!;
const context = normalizeContext({
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();
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" } });
});
expect(payload).toMatchObject({ reasoning: { effort: "max", summary: "auto" } });
},
);
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import type { Context, JsonValue, Message, ToolResultMessage } from "../src/types.ts";
const base = {
role: "toolResult" as const,
toolCallId: "call-1",
toolName: "read",
content: [],
isError: false,
timestamp: 1,
};
describe("message JSON types", () => {
it("preserves JSON-compatible detail types without requiring index signatures", () => {
interface Details {
path: string;
items: readonly string[];
summary?: {
lines?: number;
};
}
const objectMessage: ToolResultMessage<Details> = {
...base,
details: { path: "a.ts", items: ["first"], summary: { lines: 3 } },
};
const arrayMessage: ToolResultMessage<string[]> = { ...base, details: ["a", "b"] };
const readonlyArrayMessage: ToolResultMessage<readonly [string, { readonly count?: number }]> = {
...base,
details: ["a", { count: 1 }],
};
const primitiveMessage: ToolResultMessage<string> = { ...base, details: "diagnostic" };
const nullMessage: ToolResultMessage<null> = { ...base, details: null };
const transcriptMessage: Message = objectMessage;
const readonlyTranscriptMessage: Message = readonlyArrayMessage;
const context: Context = { messages: [objectMessage, readonlyArrayMessage] };
expect(objectMessage.details?.summary?.lines).toBe(3);
expect(arrayMessage.details).toEqual(["a", "b"]);
expect(readonlyArrayMessage.details).toEqual(["a", { count: 1 }]);
expect(primitiveMessage.details).toBe("diagnostic");
expect(nullMessage.details).toBeNull();
expect(transcriptMessage).toBe(objectMessage);
expect(readonlyTranscriptMessage).toBe(readonlyArrayMessage);
expect(context.messages).toEqual([objectMessage, readonlyArrayMessage]);
expectTypeOf<ToolResultMessage["details"]>().toEqualTypeOf<JsonValue | undefined>();
});
it("rejects non-JSON detail types", () => {
type OptionalAny = { value?: any };
expectTypeOf<ToolResultMessage<OptionalAny>>().toEqualTypeOf<never>();
expectTypeOf<ToolResultMessage<undefined[]>>().toEqualTypeOf<never>();
expectTypeOf<ToolResultMessage<{ callback: () => void }>>().toEqualTypeOf<never>();
expectTypeOf<ToolResultMessage<{ value: unknown }>>().toEqualTypeOf<never>();
expectTypeOf<ToolResultMessage<{ value: Date }>>().toEqualTypeOf<never>();
});
});
+149
View File
@@ -0,0 +1,149 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { metaOAuth } from "../src/auth/oauth/meta.ts";
import type { ProviderAuthInteraction } from "../src/auth/types.ts";
const CLIENT_ID = "1031625952748946";
const DEVICE_AUTHORIZATION_URL = "https://auth.meta.com/oidc/device/authorization/";
const DEVICE_TOKEN_URL = "https://auth.meta.com/oidc/device/token/";
const MINT_URL = "https://api.meta.ai/muse-code/key";
const DAY_MS = 24 * 60 * 60 * 1000;
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 createInteraction(events: Array<Record<string, unknown>>): ProviderAuthInteraction {
return {
signal: new AbortController().signal,
prompt: async () => {
throw new Error("Meta login should not prompt");
},
notify: (event) => events.push(event),
};
}
describe("Meta OAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("logs in with the device flow and mints a Model API key", async () => {
vi.useFakeTimers();
const startTime = new Date("2026-09-03T00:00:00Z");
vi.setSystemTime(startTime);
const events: Array<Record<string, unknown>> = [];
const pollResponses = [
jsonResponse({ error: "authorization_pending" }, 400),
jsonResponse({ access_token: "identity-token", token_type: "Bearer" }),
];
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
const url = getUrl(input);
if (url === DEVICE_AUTHORIZATION_URL) {
expect(init?.method).toBe("POST");
expect(new URLSearchParams(String(init?.body)).get("client_id")).toBe(CLIENT_ID);
return jsonResponse({
device_code: "device-code-123",
user_code: "ABCD-1234",
verification_uri: "https://auth.meta.com/oauth/device/",
verification_uri_complete: "https://auth.meta.com/oauth/device/?code=ABCD-1234",
interval: 5,
expires_in: 600,
});
}
if (url === DEVICE_TOKEN_URL) {
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;
}
if (url === MINT_URL) {
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({ Authorization: "Bearer identity-token" });
return jsonResponse({ api_key: "LLM|minted-key" });
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
const credentialPromise = metaOAuth.login(createInteraction(events));
for (let i = 0; i < 5 && events.length === 0; i++) {
await vi.advanceTimersByTimeAsync(0);
}
expect(events[0]).toEqual({
type: "device_code",
userCode: "ABCD-1234",
verificationUri: "https://auth.meta.com/oauth/device/?code=ABCD-1234",
intervalSeconds: 5,
expiresInSeconds: 600,
});
await vi.advanceTimersByTimeAsync(10000);
await expect(credentialPromise).resolves.toEqual({
type: "oauth",
refresh: "identity-token",
access: "LLM|minted-key",
expires: startTime.getTime() + 10000 + DAY_MS,
});
});
it("re-mints the API key from the stored identity token on refresh", async () => {
vi.useFakeTimers();
const now = new Date("2026-09-03T12:00:00Z");
vi.setSystemTime(now);
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
expect(getUrl(input)).toBe(MINT_URL);
expect(init?.headers).toMatchObject({ Authorization: "Bearer identity-token" });
return jsonResponse({ api_key: "LLM|fresh-key" });
}),
);
await expect(
metaOAuth.refresh(
{ type: "oauth", refresh: "identity-token", access: "LLM|old-key", expires: 1 },
new AbortController().signal,
),
).resolves.toEqual({
type: "oauth",
refresh: "identity-token",
access: "LLM|fresh-key",
expires: now.getTime() + DAY_MS,
});
});
it("reports the setup URL when Meta issues no key", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ require_payment: true, action_url: "https://dev.meta.ai/billing" })),
);
await expect(
metaOAuth.refresh(
{ type: "oauth", refresh: "identity-token", access: "", expires: 1 },
new AbortController().signal,
),
).rejects.toThrow("Complete setup at https://dev.meta.ai/billing");
});
it("uses the minted key as the request api key", async () => {
await expect(
metaOAuth.toAuth({ type: "oauth", refresh: "identity-token", access: "LLM|key", expires: 1 }),
).resolves.toEqual({ apiKey: "LLM|key" });
});
});
+138 -27
View File
@@ -2,8 +2,8 @@ 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";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { Api, FetchFunction, Model, ProviderResponse } from "../src/types.ts";
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
@@ -39,7 +39,7 @@ function createTerminalEvent(finishReason = "stop") {
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 = {
const context = normalizeContext({
systemPrompt: "Be precise",
messages: [
{
@@ -58,7 +58,7 @@ describe("Mistral HTTP transport", () => {
parameters: Type.Object({ query: Type.String() }),
},
],
};
});
let requestUrl: string | undefined;
let requestInit: RequestInit | undefined;
let callbackPayload: Record<string, unknown> | undefined;
@@ -160,7 +160,7 @@ describe("Mistral HTTP transport", () => {
it("serializes assistant thinking, tool calls, and tool results for replay", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
const context = normalizeContext({
messages: [
{
role: "assistant",
@@ -195,7 +195,7 @@ describe("Mistral HTTP transport", () => {
timestamp: 2,
},
],
};
});
let requestInit: RequestInit | undefined;
const fetch: FetchFunction = async (_input, init) => {
requestInit = init;
@@ -237,9 +237,9 @@ describe("Mistral HTTP transport", () => {
it("parses native thinking, text, fragmented tool calls, and cached-token usage", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
});
const events = [
{
id: "response-1",
@@ -322,11 +322,88 @@ describe("Mistral HTTP transport", () => {
expect(message.usage).toMatchObject({ input: 7, output: 4, cacheRead: 3, cacheWrite: 0, totalTokens: 14 });
});
// #9674: GLM models on Mistral send empty content deltas at the start, around tool calls,
// and sometimes mid-thinking. They must not open blocks or split thinking.
it("ignores empty content deltas", async () => {
const model = getModel("mistral", "zai-glm-5-3");
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: 1 }],
});
const thinking = (text: string) => ({ type: "thinking", thinking: [{ type: "text", text }] });
const toolCall = (args: string, first: boolean) => ({
index: 0,
...(first ? { id: "abc123456" } : {}),
function: { name: first ? "read" : "", arguments: args },
});
const deltas = [
{ content: "" },
{ content: [thinking("first part,")] },
{ content: "" },
{ content: [{ type: "text", text: "" }] },
{ content: [thinking(" second part."), { type: "text", text: "Reading." }] },
{ content: "", tool_calls: [toolCall("", true)] },
{ content: "", tool_calls: [toolCall('{"path":', false)] },
{ content: "", tool_calls: [toolCall('"a.txt"}', false)] },
{ content: "" },
];
const events = deltas.map((delta, i) => ({
id: "response-1",
model: model.id,
choices: [{ index: 0, finish_reason: i === deltas.length - 1 ? "tool_calls" : null, delta }],
}));
const message = await streamMistral(model, context, {
apiKey: "test",
fetch: async () => createSseResponse(events),
}).result();
expect(message.content).toEqual([
{ type: "thinking", thinking: "first part, second part." },
{ type: "text", text: "Reading." },
{ type: "toolCall", id: "abc123456", name: "read", arguments: { path: "a.txt" } },
]);
});
it("forwards each parsed SSE payload before normalizing it", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: 1 }],
});
const events = [
{
id: "response-1",
provider_metadata: { request: "test" },
choices: [{ index: 0, finish_reason: null, delta: { content: "hello" } }],
},
{
id: "response-1",
choices: [{ index: 0, finish_reason: "stop", delta: {} }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
},
];
const received: unknown[] = [];
const eventModels: Model<Api>[] = [];
const result = await streamMistral(model, context, {
apiKey: "test",
fetch: async () => createSseResponse(events),
onProviderStreamEvent: async (event, eventModel) => {
await Promise.resolve();
received.push(event);
eventModels.push(eventModel);
},
}).result();
expect(received).toEqual(events);
expect(eventModels).toEqual([model, model]);
expect(result.stopReason).toBe("stop");
expect(result.content).toEqual([{ type: "text", text: "hello" }]);
});
it("parses SSE and UTF-8 sequences split across transport chunks", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
});
const fetch: FetchFunction = async () =>
createBytewiseSseResponse({
id: "response-bytewise",
@@ -346,9 +423,9 @@ describe("Mistral HTTP transport", () => {
...getModel("mistral", "mistral-large-latest"),
headers: { Authorization: "Bearer model-key", "X-Affinity": "model-affinity" },
};
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
});
let requestHeaders: Headers | undefined;
const fetch: FetchFunction = async (_input, init) => {
requestHeaders = new Headers(init?.headers);
@@ -369,9 +446,9 @@ describe("Mistral HTTP transport", () => {
it("aborts while waiting for an SSE chunk", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
});
const controller = new AbortController();
const fetch: FetchFunction = async () =>
new Response(
@@ -392,18 +469,15 @@ describe("Mistral HTTP transport", () => {
expect(message.stopReason).toBe("aborted");
});
it("applies the request timeout while waiting for an SSE chunk", async () => {
it("applies the request timeout while waiting for response headers", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
const fetch: FetchFunction = async () =>
new Response(
new ReadableStream({
start() {},
}),
{ headers: { "content-type": "text/event-stream" } },
);
});
const fetch: FetchFunction = (_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
});
const message = await streamMistral(model, context, {
apiKey: "test",
@@ -412,14 +486,51 @@ describe("Mistral HTTP transport", () => {
}).result();
expect(message.stopReason).toBe("error");
expect(message.errorMessage).toMatch(/timeout/i);
expect(message.errorMessage).toBe("Mistral response headers timed out after 5ms");
});
// Regression test for #10609: an active stream must not be cut off after timeoutMs.
it("does not abort an active stream that lasts longer than the request timeout", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: 1 }],
});
const encoder = new TextEncoder();
const thinkingEvent = {
choices: [{ index: 0, delta: { content: [{ type: "thinking", thinking: [{ type: "text", text: "x" }] }] } }],
};
const fetch: FetchFunction = async () =>
new Response(
new ReadableStream({
async start(controller) {
for (let i = 0; i < 5; i++) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(thinkingEvent)}\n\n`));
await new Promise((resolve) => setTimeout(resolve, 10));
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(createTerminalEvent())}\n\ndata: [DONE]\n\n`),
);
controller.close();
},
}),
{ headers: { "content-type": "text/event-stream" } },
);
const message = await streamMistral(model, context, {
apiKey: "test",
fetch,
timeoutMs: 20,
}).result();
expect(message.stopReason).toBe("stop");
expect(message.content).toEqual([{ type: "thinking", thinking: "xxxxx" }]);
});
it("preserves HTTP status and response bodies in errors", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
});
const fetch: FetchFunction = async () =>
new Response('{"message":"blocked by gateway"}', { status: 403, statusText: "Forbidden" });
@@ -1,12 +1,13 @@
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";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { FetchFunction } from "../src/types.ts";
import { isRetryableAssistantError } from "../src/utils/retry.ts";
const model = getModel("mistral", "devstral-medium-latest");
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
});
function createFetch(finishReason: string): FetchFunction {
return async () =>
@@ -33,7 +34,10 @@ function createFetch(finishReason: string): FetchFunction {
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();
const message = await streamMistral(model, context, {
apiKey: "test",
fetch: createFetch("stop"),
}).result();
expect(message.stopReason).toBe("stop");
expect(message.rawStopReason).toBe("stop");
@@ -41,11 +45,16 @@ describe("Mistral raw stop reasons", () => {
});
it("preserves raw Mistral finish reasons for provider error stops", async () => {
const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("error") }).result();
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");
expect(message.errorMessage).toBe("Provider stopped with: error (server error)");
// #10487
expect(isRetryableAssistantError(message)).toBe(true);
});
it("treats unknown Mistral finish reasons as provider error stops", async () => {
@@ -57,5 +66,6 @@ describe("Mistral raw stop reasons", () => {
expect(message.stopReason).toBe("error");
expect(message.rawStopReason).toBe("unmapped_error");
expect(message.errorMessage).toBe("Provider stopped with: unmapped_error");
expect(isRetryableAssistantError(message)).toBe(false);
});
});
@@ -1,13 +1,46 @@
import { describe, expect, it } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import type { Context, Model, SimpleStreamOptions } from "../src/types.ts";
import { streamSimple } from "../src/api/mistral-conversations.ts";
import type { Context, Model, SimpleStreamOptions, ThinkingLevelMap } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
interface MistralPayload {
promptMode?: "reasoning";
reasoningEffort?: "none" | "high";
reasoningEffort?: string;
promptCacheKey?: string;
}
const NONE_HIGH_LEVELS: ThinkingLevelMap = {
off: "none",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: null,
};
const GLM_5_2_LEVELS: ThinkingLevelMap = { ...NONE_HIGH_LEVELS, max: "max" };
const GLM_5_3_LEVELS: ThinkingLevelMap = { ...NONE_HIGH_LEVELS, off: null, low: "low", max: "max" };
function makeModel(
id: string,
reasoning: boolean,
thinkingLevelMap?: ThinkingLevelMap,
): Model<"mistral-conversations"> {
return {
id,
name: id,
api: "mistral-conversations",
provider: "mistral",
baseUrl: "http://127.0.0.1:9",
reasoning,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
};
}
function makeContext(): Context {
return {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
@@ -19,12 +52,7 @@ async function capturePayload(
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(), {
const stream = streamSimple(model, normalizeContext(makeContext()), {
...options,
apiKey: "fake-key",
onPayload: (payload) => {
@@ -43,43 +71,77 @@ async function capturePayload(
}
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" });
it("uses prompt_mode for reasoning models without a thinking level map (Magistral)", async () => {
const payload = await capturePayload(makeModel("magistral-medium-latest", true), { 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" });
it("omits reasoning controls for Magistral when thinking is off", async () => {
const payload = await capturePayload(makeModel("magistral-medium-latest", true));
expect(payload.reasoningEffort).toBe("high");
expect(payload.promptMode).toBeUndefined();
expect(payload.reasoningEffort).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"));
// Regression for #8700 and #9375: Medium and GLM-5.2 ignore Magistral's prompt_mode.
describe.each(["mistral-small-2603", "mistral-medium-latest", "zai-glm-5-2"] as const)("%s", (modelId) => {
const map = modelId === "zai-glm-5-2" ? GLM_5_2_LEVELS : NONE_HIGH_LEVELS;
it("uses reasoning_effort when thinking is enabled", async () => {
const payload = await capturePayload(makeModel(modelId, true, map), { reasoning: "high" });
expect(payload.reasoningEffort).toBe("high");
expect(payload.promptMode).toBeUndefined();
});
it("clamps unsupported levels to a supported effort", async () => {
const payload = await capturePayload(makeModel(modelId, true, map), { reasoning: "low" });
expect(payload.reasoningEffort).toBe("high");
});
it("sends reasoning_effort none when thinking is off", async () => {
const payload = await capturePayload(makeModel(modelId, true, map));
expect(payload.reasoningEffort).toBe("none");
expect(payload.promptMode).toBeUndefined();
});
});
// Regression for #9678: requested levels must reach Mistral-hosted GLM models.
it("sends max for GLM-5.2", async () => {
const payload = await capturePayload(makeModel("zai-glm-5-2", true, GLM_5_2_LEVELS), { reasoning: "max" });
expect(payload.reasoningEffort).toBe("max");
});
describe("zai-glm-5-3", () => {
it.each(["low", "high", "max"] as const)("sends reasoning_effort %s", async (level) => {
const payload = await capturePayload(makeModel("zai-glm-5-3", true, GLM_5_3_LEVELS), { reasoning: level });
expect(payload.reasoningEffort).toBe(level);
expect(payload.promptMode).toBeUndefined();
});
it("maps medium to high", async () => {
const payload = await capturePayload(makeModel("zai-glm-5-3", true, GLM_5_3_LEVELS), { reasoning: "medium" });
expect(payload.reasoningEffort).toBe("high");
});
});
// Regression for #8700: reasoning controls must respect the model's reasoning capability.
it("omits reasoning controls for non-reasoning models", async () => {
const payload = await capturePayload(makeModel("mistral-medium-2505", false), { reasoning: "medium" });
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"), {
const payload = await capturePayload(makeModel("mistral-large-latest", false), {
sessionId: "session-123",
});
@@ -87,7 +149,7 @@ describe("Mistral reasoning mode selection", () => {
});
it("omits prompt cache key when cache retention is disabled", async () => {
const payload = await capturePayload(getModel("mistral", "mistral-large-latest"), {
const payload = await capturePayload(makeModel("mistral-large-latest", false), {
sessionId: "session-123",
cacheRetention: "none",
});
@@ -8,6 +8,8 @@ it("derives model API, ID, and provider literals from grouped model data", () =>
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.7"].api).toEqualTypeOf<"openai-responses">();
expectTypeOf(XAI_MODELS["grok-4.7"].id).toEqualTypeOf<"grok-4.7">();
expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-responses">();
});
@@ -15,3 +17,21 @@ 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");
});
// Regression test for https://github.com/earendil-works/pi/issues/9209
it("routes all GitHub Copilot GPT models through the Responses API", () => {
const gptModels = Object.values(GITHUB_COPILOT_MODELS).filter((model) => model.id.startsWith("gpt-"));
expect(gptModels.length).toBeGreaterThan(0);
expect(gptModels.every((model) => model.api === "openai-responses")).toBe(true);
expectTypeOf(GITHUB_COPILOT_MODELS["gpt-6-astra"].api).toEqualTypeOf<"openai-responses">();
for (const modelId of ["gpt-6-sol", "gpt-6-luna"] as const) {
const model = GITHUB_COPILOT_MODELS[modelId];
expectTypeOf(model.api).toEqualTypeOf<"openai-responses">();
expect(model).toMatchObject({
api: "openai-responses",
contextWindow: 1000000,
maxTokens: 128000,
thinkingLevelMap: { off: "none", max: "max" },
});
}
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { getAiGatewayCost } from "../scripts/ai-gateway-pricing.ts";
import { buildOpenRouterCatalog, type OpenRouterModelListItem } from "../scripts/openrouter-catalog.ts";
function openRouterChatCost(pricing: OpenRouterModelListItem["pricing"]) {
const catalog = buildOpenRouterCatalog(
[{ id: "anthropic/claude-haiku-5.5", name: "Claude Haiku 5.5", supported_parameters: ["tools"], pricing }],
[],
[],
);
return catalog.chat[0]?.cost;
}
describe("OpenRouter pricing overrides", () => {
it("turns prompt-length overrides into tiers", () => {
expect(
openRouterChatCost({
prompt: "0.0000001",
completion: "0.0000005",
input_cache_read: "0.00000001",
input_cache_write: "0.000000125",
overrides: [
{
min_prompt_tokens: 100000,
prompt: "0.0000005",
completion: "0.0000025",
input_cache_read: "0.00000005",
input_cache_write: "0.000000625",
},
],
}),
).toEqual({
input: 0.1,
output: 0.5,
cacheRead: 0.01,
cacheWrite: 0.125,
tiers: [{ inputTokensAbove: 100000, input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 }],
});
});
it("keeps base rates missing from an override", () => {
expect(
openRouterChatCost({
prompt: "0.000002",
completion: "0.000012",
input_cache_read: "0.0000002",
input_cache_write: "0.000000375",
overrides: [{ min_prompt_tokens: 200000, prompt: "0.000004", completion: "0.000018" }],
})?.tiers,
).toEqual([{ inputTokensAbove: 200000, input: 4, output: 18, cacheRead: 0.2, cacheWrite: 0.375 }]);
});
it("skips time-of-day overrides", () => {
expect(
openRouterChatCost({
prompt: "0.000000132",
completion: "0.000000528",
overrides: [
{ utc_start: 0, utc_end: 1600, prompt: "0.000000132", completion: "0.000000528" },
{ utc_days: ["saturday"], min_prompt_tokens: 1000, prompt: "0.0000001" },
],
}),
).toEqual({ input: 0.132, output: 0.528, cacheRead: 0, cacheWrite: 0 });
});
});
describe("Vercel AI Gateway pricing tiers", () => {
it("turns bracket starts into tiers with the rates in effect there", () => {
expect(
getAiGatewayCost({
input: "0.000001",
output: "0.000005",
input_cache_read: "0.0000002",
input_tiers: [
{ cost: "0.000001", min: 0, max: 32001 },
{ cost: "0.0000018", min: 32001, max: 128001 },
{ cost: "0.000003", min: 128001 },
],
output_tiers: [
{ cost: "0.000005", min: 0, max: 32001 },
{ cost: "0.000009", min: 32001, max: 128001 },
{ cost: "0.000015", min: 128001 },
],
}),
).toEqual({
input: 1,
output: 5,
cacheRead: 0.2,
cacheWrite: 0,
tiers: [
{ inputTokensAbove: 32000, input: 1.8, output: 9, cacheRead: 0.2, cacheWrite: 0 },
{ inputTokensAbove: 128000, input: 3, output: 15, cacheRead: 0.2, cacheWrite: 0 },
],
});
});
it("returns base rates without tiers", () => {
expect(getAiGatewayCost({ input: "0.000003", output: 0.000015 })).toEqual({
input: 3,
output: 15,
cacheRead: 0,
cacheWrite: 0,
});
});
});
+184 -6
View File
@@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { hydrateModelCatalog } from "../scripts/hydrate-model-catalog.ts";
import {
assertExactModelIds,
createModelDataManifest,
@@ -32,20 +33,21 @@ function createFixture(): {
mkdirSync(dataDir, { recursive: true });
writeFileSync(
join(packageRoot, "src", "models.generated.ts"),
'import { TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\n',
'import { TEST_PROVIDER_CLASSIFIER_MODELS, TEST_PROVIDER_IMAGE_MODELS, 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',
'import values from "./data/test-provider.json" with { type: "json" };\n',
);
const structure: ModelDataStructure = {
"test-provider": {
"model-a": "openai-completions",
"chat:model-a": "openai-completions",
},
};
const values: Record<string, unknown> = {
"model-a": {
"chat:model-a": {
type: "chat",
id: "model-a",
name: "Model A",
api: "openai-completions",
@@ -77,6 +79,104 @@ function writeFixtureData(
writeFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), `${JSON.stringify(manifest)}\n`);
}
describe("published model catalog hydration", () => {
function chatModel(values: Record<string, unknown>): Record<string, unknown> {
return values["chat:model-a"] as Record<string, unknown>;
}
it("hydrates a clean checkout deterministically without changing generated TypeScript", () => {
const { dataDir, packageRoot, structure, values } = createFixture();
const aggregatorPath = join(packageRoot, "src", "models.generated.ts");
const aggregator = readFileSync(aggregatorPath, "utf8");
const catalogPath = join(packageRoot, "catalog.json");
const models = [chatModel(values)];
writeFileSync(catalogPath, JSON.stringify({ "test-provider": models, "extra-provider": models }));
rmSync(dataDir, { recursive: true });
hydrateModelCatalog(packageRoot, catalogPath);
expect(() => validateModelDataDirectory(structure, dataDir)).not.toThrow();
expect(readModelDataStructure(packageRoot)).toEqual(structure);
expect(readFileSync(aggregatorPath, "utf8")).toBe(aggregator);
const manifest = readFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), "utf8");
const provider = readFileSync(join(dataDir, "test-provider.json"), "utf8");
hydrateModelCatalog(packageRoot, catalogPath);
expect(readFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), "utf8")).toBe(manifest);
expect(readFileSync(join(dataDir, "test-provider.json"), "utf8")).toBe(provider);
});
it("groups models by API and keys them by type and id", () => {
const { packageRoot, values } = createFixture();
const catalogPath = join(packageRoot, "catalog.json");
const model = chatModel(values);
writeFileSync(
catalogPath,
JSON.stringify({
"test-provider": [model, { ...model, id: "model-b", api: "anthropic-messages" }],
}),
);
hydrateModelCatalog(packageRoot, catalogPath);
expect(readModelDataStructure(packageRoot)).toEqual({
"test-provider": { "chat:model-a": "openai-completions", "chat:model-b": "anthropic-messages" },
});
});
it("keys chat and image models with the same id separately", () => {
const { packageRoot, values } = createFixture();
const image = {
type: "image",
id: "model-a",
name: "Model A Image",
api: "openrouter-images",
provider: "test-provider",
baseUrl: "https://example.test/v1",
input: ["text"],
output: ["image"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
};
const catalogPath = join(packageRoot, "catalog.json");
writeFileSync(catalogPath, JSON.stringify({ "test-provider": [chatModel(values), image] }));
hydrateModelCatalog(packageRoot, catalogPath);
expect(readModelDataStructure(packageRoot)).toEqual({
"test-provider": { "chat:model-a": "openai-completions", "image:model-a": "openrouter-images" },
});
});
it("validates without replacing existing data", () => {
const { dataDir, packageRoot, values } = createFixture();
const original = readFileSync(join(dataDir, "test-provider.json"), "utf8");
const catalogPath = join(packageRoot, "catalog.json");
const model = chatModel(values);
writeFileSync(catalogPath, JSON.stringify({ "test-provider": [model, { ...model, id: "model-b" }] }));
hydrateModelCatalog(packageRoot, catalogPath, { validateOnly: true });
expect(readFileSync(join(dataDir, "test-provider.json"), "utf8")).toBe(original);
writeFileSync(catalogPath, JSON.stringify({ "other-provider": [model] }));
expect(() => hydrateModelCatalog(packageRoot, catalogPath, { validateOnly: true })).toThrow(/missing provider/);
});
it.each([null, [], {}, { "test-provider": [] }, { "test-provider": {} }, { "test-provider": [{ id: "x" }] }])(
"rejects incomplete catalogs without replacing existing data: %j",
(catalog) => {
const { dataDir, packageRoot } = createFixture();
const catalogPath = join(packageRoot, "catalog.json");
const original = readFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), "utf8");
writeFileSync(catalogPath, JSON.stringify(catalog));
expect(() => hydrateModelCatalog(packageRoot, catalogPath)).toThrow();
expect(readFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), "utf8")).toBe(original);
},
);
it.each(["api", "provider", "cost"])("validates model %s before replacing existing data", (field) => {
const { dataDir, packageRoot, values } = createFixture();
const original = readFileSync(join(dataDir, "test-provider.json"), "utf8");
const model = chatModel(values);
model[field] = null;
const catalogPath = join(packageRoot, "catalog.json");
writeFileSync(catalogPath, JSON.stringify({ "test-provider": [model] }));
expect(() => hydrateModelCatalog(packageRoot, catalogPath)).toThrow();
expect(readFileSync(join(dataDir, "test-provider.json"), "utf8")).toBe(original);
});
});
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(
@@ -108,12 +208,90 @@ describe("generated model data validation", () => {
["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>;
const model = fixture.values["chat: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 without a known type", () => {
const fixture = createFixture();
const model = fixture.values["chat:model-a"] as Record<string, unknown>;
delete model.type;
writeFixtureData(fixture.dataDir, fixture.structure, fixture.values);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow(
'expected "chat", "image", or "classifier"',
);
});
it("validates image models with output modalities and without chat limits", () => {
const fixture = createFixture();
const structure: ModelDataStructure = { "test-provider": { "image:image-a": "test-images" } };
const image: Record<string, unknown> = {
type: "image",
id: "image-a",
name: "Image A",
api: "test-images",
provider: "test-provider",
baseUrl: "https://example.test/v1",
input: ["text"],
output: ["image", "text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
};
const validate = () => {
writeFixtureData(
fixture.dataDir,
structure,
{ "image:image-a": image },
MODEL_DATA_SCHEMA_VERSION,
"test-images",
);
validateModelDataDirectory(structure, fixture.dataDir);
};
expect(validate).not.toThrow();
delete image.output;
expect(validate).toThrow("invalid output modalities");
image.output = ["text"];
expect(validate).toThrow("invalid output modalities");
});
it("rejects output modalities on chat models", () => {
const fixture = createFixture();
const model = fixture.values["chat:model-a"] as Record<string, unknown>;
model.output = ["text"];
writeFixtureData(fixture.dataDir, fixture.structure, fixture.values);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow(
"unsupported output modalities",
);
});
it("validates classifier models without chat output limits", () => {
const fixture = createFixture();
const structure: ModelDataStructure = {
"test-provider": { "classifier:classifier-a": "test-classifier" },
};
const classifier = {
type: "classifier",
id: "classifier-a",
name: "Classifier A",
api: "test-classifier",
provider: "test-provider",
baseUrl: "https://example.test/v1",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
};
writeFixtureData(
fixture.dataDir,
structure,
{ "classifier:classifier-a": classifier },
MODEL_DATA_SCHEMA_VERSION,
"test-classifier",
);
expect(() => validateModelDataDirectory(structure, fixture.dataDir)).not.toThrow();
});
it("rejects a model in the wrong API group", () => {
const fixture = createFixture();
writeFixtureData(
@@ -170,7 +348,7 @@ describe("generated model data validation", () => {
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',
'import { TEST_PROVIDER_CLASSIFIER_MODELS, TEST_PROVIDER_IMAGE_MODELS, TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\nimport { MISSING_CLASSIFIER_MODELS, MISSING_IMAGE_MODELS, MISSING_MODELS } from "./providers/missing.models.ts";\n',
);
expect(() => readModelDataStructure(packageRoot)).toThrow("aggregator and provider shards do not match");
});
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import { getModel as getCompatModel } from "../src/compat.ts";
import {
createModels,
createProvider,
getModelType,
hasApi,
isModelType,
modelsAreEqual,
type Provider,
} from "../src/models.ts";
import { InMemoryModelsStore, type ModelsStoreEntry } from "../src/models-store.ts";
import { getBuiltinModel } from "../src/providers/all.ts";
import { fauxAssistantMessage, fauxProvider } from "../src/providers/faux.ts";
import type { Api, ImageApi, ImageModel, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function chatModel(provider: string, id: string): Model<Api> {
return {
id,
name: id,
api: "test-chat",
provider,
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
}
function imageModel(provider: string, id: string): ImageModel<ImageApi> {
return {
type: "image",
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 },
};
}
const noAuth = { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } };
const chatStreams = {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
};
describe("chat models without a type", () => {
it("work through a handwritten provider without getAllModels", async () => {
const faux = fauxProvider({ provider: "handwritten" });
const handwritten: Provider = {
id: "handwritten",
name: "Handwritten",
auth: faux.provider.auth,
getModels: () => faux.models,
stream: faux.provider.stream,
streamSimple: faux.provider.streamSimple,
};
const models = createModels();
models.setProvider(handwritten);
const model = models.getModel("handwritten", faux.models[0].id);
expect(model).toBeDefined();
expect(model?.type).toBeUndefined();
expect(getModelType(model!)).toBe("chat");
expect(hasApi(model!, model!.api)).toBe(true);
expect(modelsAreEqual(model, { ...model!, type: "chat" })).toBe(true);
expect(modelsAreEqual(model, { ...imageModel(model!.provider, model!.id) })).toBe(false);
expect(models.getModelsOfType("chat", "handwritten")).toEqual(faux.models);
expect(models.getAllModels("handwritten")).toEqual(faux.models);
expect(models.getModelsOfType("image", "handwritten")).toEqual([]);
faux.setResponses([fauxAssistantMessage("hi")]);
const result = await models.complete(model!, { messages: [{ role: "user", content: "hi", timestamp: 0 }] });
expect(result.stopReason).toBe("stop");
});
it("narrow mixed lists with isModelType", () => {
const mixed = [chatModel("p", "c"), { ...chatModel("p", "typed"), type: "chat" as const }, imageModel("p", "i")];
expect(mixed.filter((model) => isModelType(model, "chat")).map((model) => model.id)).toEqual(["c", "typed"]);
expect(mixed.filter((model) => isModelType(model, "image")).map((model) => model.id)).toEqual(["i"]);
expect(mixed.filter((model) => isModelType(model, "classifier"))).toEqual([]);
});
});
describe("built-in catalog getters", () => {
it("return model shapes that can be reassigned within one api", () => {
// Compile-time regression check: the return type must not carry literal model ids.
let model = getBuiltinModel("openai", "gpt-4o-mini");
model = getBuiltinModel("openai", "gpt-4o");
let compat = getCompatModel("openai", "gpt-4o-mini");
compat = getCompatModel("openai", "gpt-4o");
expect(model.id).toBe("gpt-4o");
expect(compat.id).toBe("gpt-4o");
});
});
describe("stored and fetched models of unknown types", () => {
it("are dropped instead of failing the refresh", async () => {
const modelsStore = new InMemoryModelsStore();
const stored = {
models: [
chatModel("dyn", "stored-chat"),
imageModel("dyn", "stored-image"),
{ ...chatModel("dyn", "future-embedding"), type: "embedding" },
{ ...imageModel("dyn", "future-video"), type: "video" },
],
} as unknown as ModelsStoreEntry;
await modelsStore.write("dyn", stored);
let fetched: unknown[] = [];
const models = createModels({ modelsStore });
models.setProvider(
createProvider({
id: "dyn",
auth: noAuth,
models: [],
fetchModels: async () => fetched as Model<Api>[],
api: chatStreams,
}),
);
const restored = await models.refresh({ providers: ["dyn"], allowNetwork: false });
expect(restored.errors.size).toBe(0);
expect(models.getAllModels("dyn").map((model) => model.id)).toEqual(["stored-chat", "stored-image"]);
fetched = [chatModel("dyn", "fetched-chat"), { ...imageModel("dyn", "fetched-video"), type: "video" }];
const refreshed = await models.refresh({ providers: ["dyn"] });
expect(refreshed.errors.size).toBe(0);
expect(models.getAllModels("dyn").map((model) => model.id)).toEqual(["fetched-chat"]);
expect(await modelsStore.read("dyn")).toMatchObject({ models: [{ id: "fetched-chat" }] });
});
});
+58
View File
@@ -0,0 +1,58 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
const sourceEntry = new URL("../src/models.ts", import.meta.url).href;
// Install before linking the static imports, so unused barrel re-exports are checked too.
const preload = `data:text/javascript,${encodeURIComponent(`
import { registerHooks } from "node:module";
const forbidden = [
"/node_modules/typebox/",
"/node_modules/@anthropic-ai/sdk/",
"/node_modules/openai/",
"/node_modules/@google/genai/",
"/node_modules/@aws-sdk/",
"/providers/data/",
"/models.generated.",
];
registerHooks({
load(url, context, nextLoad) {
if (forbidden.some((part) => url.includes(part))) {
throw new Error("Heavy dependency loaded by models entry: " + url);
}
return nextLoad(url, context);
},
});
`)}`;
describe("lightweight models entry", () => {
it.each([sourceEntry, "@earendil-works/pi-ai/models"])(
"runs a faux completion without TypeBox, catalogs, or SDKs through %s",
(entry) => {
const script = `
import assert from "node:assert/strict";
import { createModels, createProvider } from ${JSON.stringify(entry)};
import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
assert.equal(typeof createProvider, "function");
const models = createModels();
assert.deepEqual(models.getModels(), []);
assert.deepEqual(models.getProviders(), []);
const faux = fauxProvider();
models.setProvider(faux.provider);
faux.setResponses([fauxAssistantMessage("OK")]);
const response = await models.completeSimple(faux.getModel(), { messages: [] });
assert.equal(response.stopReason, "stop");
assert.deepEqual(response.content, [{ type: "text", text: "OK" }]);
`;
const result = spawnSync(process.execPath, ["--import", preload, "--input-type=module", "--eval", script], {
cwd: packageRoot,
encoding: "utf8",
timeout: 10_000,
});
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
},
);
});
+83 -28
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import type { ApiKeyAuth, CredentialStore, OAuthAuth, OAuthCredential, ProviderAuth } from "../src/auth/types.ts";
import type { ApiKeyAuth, CredentialStore, OAuthAuth, ProviderAuth } from "../src/auth/types.ts";
import { calculateCost, createModels, createProvider, hasApi, type Provider } from "../src/models.ts";
import { InMemoryModelsStore, type ModelsStore, type ModelsStoreEntry } from "../src/models-store.ts";
import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions, StreamOptions, Usage } from "../src/types.ts";
@@ -198,6 +198,20 @@ describe("Models runtime", () => {
}
});
it("keeps chat reads independent from the all-model catalog", async () => {
const provider = testProvider({ id: "chat-only" });
provider.getAllModels = () => {
throw new Error("all models unavailable");
};
const models = createModels();
models.setProvider(provider);
expect(models.getModels("chat-only").map((model) => model.id)).toEqual(["model-a"]);
expect(models.getModel("chat-only", "model-a")?.id).toBe("model-a");
expect((await models.getAvailable("chat-only")).map((model) => model.id)).toEqual(["model-a"]);
expect(models.getAllModels("chat-only")).toEqual([]);
});
it("swallows provider source failures for both all-provider and single-provider listing", () => {
const models = createModels();
models.setProvider(
@@ -425,6 +439,26 @@ describe("Models runtime", () => {
expect(unconfiguredRefreshes).toBe(0);
});
it("passes the registered provider's base URL, including wrapper overrides, to refreshModels", async () => {
// A models.json baseUrl (e.g. a regional OpenRouter host) is applied by a wrapper around the
// built-in provider. The built-in refresh must still request that URL.
const baseUrls: (string | undefined)[] = [];
const inner: Provider = {
...testProvider({
id: "dynamic",
refreshModels: async (context) => {
baseUrls.push(context.baseUrl);
},
}),
baseUrl: "https://default.example.test/v1",
};
const models = createModels();
models.setProvider({ ...inner, baseUrl: "https://regional.example.test/v1" });
await models.refresh();
expect(baseUrls).toEqual(["https://regional.example.test/v1", "https://regional.example.test/v1"]);
});
it("refreshes expired OAuth before refreshing models", async () => {
const credentials = new InMemoryCredentialStore();
let modelRefreshCredential: unknown;
@@ -459,6 +493,37 @@ describe("Models runtime", () => {
expect(await credentials.read("oauth-dynamic")).toMatchObject({ access: "fresh", refresh: "rotated" });
});
it("persists an OAuth refresh that started before the model refresh was cancelled", async () => {
const credentials = new InMemoryCredentialStore();
await credentials.modify("p1", async () => ({
type: "oauth",
access: "old",
refresh: "old-refresh",
expires: 0,
}));
const controller = new AbortController();
const models = createModels({ credentials });
models.setProvider(
testProvider({
id: "p1",
auth: {
oauth: testOAuth({
refresh: async (credential) => {
controller.abort();
return { ...credential, access: "new", refresh: "new-refresh", expires: Date.now() + 60_000 };
},
}),
},
refreshModels: async () => {},
}),
);
expect((await models.refresh({ signal: controller.signal })).aborted).toBe(true);
await vi.waitFor(async () => {
expect(await credentials.read("p1")).toMatchObject({ refresh: "new-refresh" });
});
});
it("always gives providers a concrete signal", async () => {
let receivedSignal: AbortSignal | undefined;
const models = createModels();
@@ -732,46 +797,36 @@ describe("Models runtime", () => {
expect(await credentials.read("p1")).toEqual({ type: "api_key", key: "first" });
});
it("passes cancellation to OAuth refresh and preserves the previous credential", async () => {
// Radius bug report 01a10855-43d8-7447-a710-2acf5ee0b2ee: refresh_token_invalidated after a cancelled refresh.
it("persists an OAuth refresh that started before the request was cancelled", async () => {
const credentials = new InMemoryCredentialStore();
const previous: OAuthCredential = { type: "oauth", access: "old", refresh: "old-refresh", expires: 0 };
await credentials.modify("p1", async () => previous);
let startRefresh: (() => void) | undefined;
let finishRefresh: ((credential: typeof previous) => void) | undefined;
const refreshStarted = new Promise<void>((resolve) => {
startRefresh = resolve;
});
const blockedRefresh = new Promise<typeof previous>((resolve) => {
finishRefresh = resolve;
});
let receivedSignal: AbortSignal | undefined;
await credentials.modify("p1", async () => ({
type: "oauth",
access: "old",
refresh: "old-refresh",
expires: 0,
}));
const controller = new AbortController();
const models = createModels({ credentials });
models.setProvider(
testProvider({
id: "p1",
auth: {
oauth: testOAuth({
refresh: async (_credential, signal) => {
receivedSignal = signal;
startRefresh?.();
return blockedRefresh;
refresh: async (credential) => {
// The provider has rotated old-refresh by the time the request is cancelled.
controller.abort();
return { ...credential, access: "new", refresh: "new-refresh", expires: Date.now() + 60_000 };
},
}),
},
}),
);
const controller = new AbortController();
const auth = models.getAuth("p1", { signal: controller.signal });
await refreshStarted;
controller.abort();
await expect(auth).rejects.toMatchObject({ name: "AbortError" });
expect(receivedSignal).toBeInstanceOf(AbortSignal);
expect(receivedSignal?.aborted).toBe(true);
expect(receivedSignal?.reason).toBe(controller.signal.reason);
finishRefresh?.({ ...previous, access: "new", expires: Date.now() + 60_000 });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(await credentials.read("p1")).toEqual(previous);
await expect(models.getAuth("p1", { signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" });
await vi.waitFor(async () => {
expect(await credentials.read("p1")).toMatchObject({ refresh: "new-refresh" });
});
});
it("resolves auth: stored credential owns the provider, ambient only when nothing stored", async () => {
+20 -1
View File
@@ -3,6 +3,7 @@ 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 { openaiChatGPTOAuth } from "../src/auth/oauth/openai-chatgpt.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";
@@ -10,6 +11,7 @@ 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";
import { openaiProvider } from "../src/providers/openai.ts";
const neverAbortedSignal = new AbortController().signal;
@@ -28,7 +30,14 @@ describe.sequential("OAuthAuth adapters", () => {
});
it("identifies only subscription-backed OAuth flows as subscriptions", () => {
for (const oauth of [anthropicOAuth, openaiCodexOAuth, githubCopilotOAuth, kimiCodingOAuth, xaiOAuth]) {
for (const oauth of [
anthropicOAuth,
openaiChatGPTOAuth,
openaiCodexOAuth,
githubCopilotOAuth,
kimiCodingOAuth,
xaiOAuth,
]) {
expect(oauth.isSubscription).toBe(true);
}
expect(openRouterOAuth.isSubscription).not.toBe(true);
@@ -39,6 +48,16 @@ describe.sequential("OAuthAuth adapters", () => {
expect(auth).toEqual({ apiKey: "token" });
});
it("OpenAI exposes ChatGPT OAuth alongside API-key auth", () => {
const provider = openaiProvider();
expect(provider.auth.apiKey).toBeDefined();
expect(provider.auth.oauth).toMatchObject({
name: "OpenAI (ChatGPT subscription)",
isSubscription: true,
loginLabel: "Sign in with ChatGPT",
});
});
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" });
@@ -0,0 +1,258 @@
import { createServer, type Server } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "../src/auth/oauth/callback-server.ts";
import type { AuthPrompt, ProviderAuthInteraction } from "../src/auth/types.ts";
const nativeFetch = globalThis.fetch;
function callbackUrl(redirectUri: string, params: Record<string, string>): string {
const url = new URL(redirectUri);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
return url.toString();
}
async function page(response: Response): Promise<{ status: number; contentType: string | null; body: string }> {
return { status: response.status, contentType: response.headers.get("content-type"), body: await response.text() };
}
function interaction(prompt: (prompt: AuthPrompt) => Promise<string>, signal?: AbortSignal): ProviderAuthInteraction {
return { signal: signal ?? new AbortController().signal, notify: () => {}, prompt };
}
/** A manual prompt that stays open until its signal aborts. */
function pendingPrompt(onPrompt?: (prompt: AuthPrompt) => void): (prompt: AuthPrompt) => Promise<string> {
return (prompt) => {
onPrompt?.(prompt);
return new Promise((_, reject) => {
prompt.signal?.addEventListener("abort", () => reject(new Error("prompt aborted")), { once: true });
});
};
}
describe.sequential("OAuth callback server", () => {
const servers: { close(): void }[] = [];
const start = async <T>(options: Partial<Parameters<typeof startOAuthCallbackServer<T>>[0]> = {}) => {
const server = await startOAuthCallbackServer<T>({
providerName: "Example",
host: "127.0.0.1",
port: 0,
path: "/callback",
state: "expected-state",
complete: async (code) => `completed:${code}` as T,
...options,
});
servers.push(server);
return server;
};
afterEach(() => {
for (const server of servers.splice(0)) server.close();
});
it("ignores stray requests and resolves with the completed code", async () => {
const server = await start<string>();
expect(server.redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/);
const wrongPath = await page(await nativeFetch(new URL("/other", server.redirectUri)));
expect(wrongPath.status).toBe(404);
const wrongState = await page(await nativeFetch(callbackUrl(server.redirectUri, { code: "c", state: "other" })));
expect(wrongState).toMatchObject({ status: 400, contentType: "text/html; charset=utf-8" });
expect(wrongState.body).toContain("State mismatch.");
const post = await nativeFetch(callbackUrl(server.redirectUri, { code: "c", state: "expected-state" }), {
method: "POST",
});
expect(post.status).toBe(404);
const missingCode = await page(await nativeFetch(callbackUrl(server.redirectUri, { state: "expected-state" })));
expect(missingCode.status).toBe(400);
const success = await page(
await nativeFetch(callbackUrl(server.redirectUri, { code: "the-code", state: "expected-state" })),
);
expect(success).toMatchObject({ status: 200, contentType: "text/html; charset=utf-8" });
expect(success.body).toContain("Authentication successful");
expect(success.body).toContain("Signed in to Example.");
expect(success.body).toContain('fill="#F09082"');
expect(success.body).toContain('fill="#4D9ABF"');
expect(success.body).toContain('fill="#F1BE58"');
await expect(server.wait()).resolves.toBe("completed:the-code");
});
it("uses the redirect host and skips the state check when none is expected", async () => {
const server = await start<string>({ redirectHost: "localhost", state: undefined });
expect(server.redirectUri).toMatch(/^http:\/\/localhost:\d+\/callback$/);
const response = await nativeFetch(callbackUrl(server.redirectUri, { code: "no-state" }));
expect(response.status).toBe(200);
await expect(server.wait()).resolves.toBe("completed:no-state");
});
it("shows completion failures on the page and rejects the wait", async () => {
const server = await start<string>({
complete: async () => {
throw new Error("token exchange failed");
},
});
const failure = await page(
await nativeFetch(callbackUrl(server.redirectUri, { code: "c", state: "expected-state" })),
);
expect(failure.status).toBe(502);
expect(failure.body).toContain("Example sign-in failed.");
expect(failure.body).toContain("token exchange failed");
await expect(server.wait()).rejects.toThrow("token exchange failed");
});
it("rejects the wait when the provider redirects with an error", async () => {
const server = await start<string>();
const failure = await page(
await nativeFetch(
callbackUrl(server.redirectUri, {
error: "access_denied",
error_description: "User denied access",
state: "expected-state",
}),
),
);
expect(failure.status).toBe(400);
expect(failure.body).toContain("User denied access");
await expect(server.wait()).rejects.toThrow("Example authorization failed: User denied access");
});
it("completes only the first callback", async () => {
let finishExchange: ((value: string) => void) | undefined;
const server = await start<string>({
complete: () =>
new Promise<string>((resolve) => {
finishExchange = resolve;
}),
});
const url = callbackUrl(server.redirectUri, { code: "c", state: "expected-state" });
const first = nativeFetch(url);
await vi.waitFor(() => expect(finishExchange).toBeDefined());
const second = await nativeFetch(url);
expect(second.status).toBe(409);
// A claimed callback keeps completing even when the caller switches to manual input.
server.cancel();
finishExchange?.("done");
expect((await first).status).toBe(200);
await expect(server.wait()).resolves.toBe("done");
});
it("resolves with undefined after cancel", async () => {
const server = await start<string>();
server.cancel();
await expect(server.wait()).resolves.toBeUndefined();
const late = await nativeFetch(callbackUrl(server.redirectUri, { code: "c", state: "expected-state" }));
expect(late.status).toBe(409);
});
it("rejects the wait on abort and on timeout", async () => {
const controller = new AbortController();
const aborted = await start<string>({ signal: controller.signal });
controller.abort();
await expect(aborted.wait()).rejects.toThrow("Login cancelled");
const timedOut = await start<string>({ timeoutMs: 10 });
await expect(timedOut.wait()).rejects.toThrow("Example sign-in timed out");
const alreadyAborted = new AbortController();
alreadyAborted.abort();
await expect(start<string>({ signal: alreadyAborted.signal })).rejects.toThrow("Login cancelled");
});
it("fails instead of picking another port when the requested port is taken", async () => {
const blocker: Server = createServer();
await new Promise<void>((resolve) => blocker.listen(0, "127.0.0.1", resolve));
try {
const address = blocker.address();
if (!address || typeof address === "string") throw new Error("blocker did not bind to TCP");
await expect(start<string>({ port: address.port })).rejects.toMatchObject({ code: "EADDRINUSE" });
} finally {
blocker.close();
}
});
});
describe.sequential("waitForCallbackOrManualInput", () => {
it("returns the browser callback and aborts the manual prompt", async () => {
let manualSignal: AbortSignal | undefined;
const server = await startOAuthCallbackServer({
providerName: "Example",
host: "127.0.0.1",
port: 0,
path: "/callback",
complete: async (code) => code,
});
try {
const result = waitForCallbackOrManualInput(
interaction(
pendingPrompt((prompt) => {
manualSignal = prompt.signal;
}),
),
server,
{ message: "paste", placeholder: server.redirectUri },
);
await nativeFetch(callbackUrl(server.redirectUri, { code: "from-browser" }));
await expect(result).resolves.toEqual({ type: "callback", value: "from-browser" });
expect(manualSignal?.aborted).toBe(true);
} finally {
server.close();
}
});
it("returns pasted input and stops waiting for the browser", async () => {
const server = await startOAuthCallbackServer({
providerName: "Example",
host: "127.0.0.1",
port: 0,
path: "/callback",
complete: async (code) => code,
});
try {
const result = await waitForCallbackOrManualInput(
interaction(async () => "pasted"),
server,
{
message: "paste",
placeholder: server.redirectUri,
},
);
expect(result).toEqual({ type: "manual", input: "pasted" });
} finally {
server.close();
}
});
it("uses only the manual prompt without a callback server", async () => {
const result = await waitForCallbackOrManualInput(
interaction(async () => "pasted"),
undefined,
{
message: "paste",
placeholder: "http://localhost/callback",
},
);
expect(result).toEqual({ type: "manual", input: "pasted" });
});
it("propagates manual prompt failures", async () => {
const server = await startOAuthCallbackServer({
providerName: "Example",
host: "127.0.0.1",
port: 0,
path: "/callback",
complete: async (code) => code,
});
try {
const result = waitForCallbackOrManualInput(
interaction(async () => {
throw new Error("prompt cancelled");
}),
server,
{ message: "paste", placeholder: server.redirectUri },
);
await expect(result).rejects.toThrow("prompt cancelled");
} finally {
server.close();
}
});
});
@@ -0,0 +1,196 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { openaiChatGPTOAuth } from "../src/auth/oauth/openai-chatgpt.ts";
import type { OAuthCredential, ProviderAuthInteraction } from "../src/auth/types.ts";
const TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token";
const REQUIRED_SCOPE = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct";
const neverAbortedSignal = new AbortController().signal;
const DEVICE_ID = "e61bbe28-07ef-466d-8e5d-a344f94ab305";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
function tokenResponse(scope = REQUIRED_SCOPE) {
return {
access_token: "access-token",
refresh_token: "refresh-token",
expires_in: 3600,
id_token: "id-token",
scope,
};
}
function stubTokenEndpoint(response: unknown, inspect?: (body: URLSearchParams) => void) {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
expect(input instanceof Request ? input.url : String(input)).toBe(TOKEN_URL);
inspect?.(new URLSearchParams(String(init?.body)));
return jsonResponse(response);
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function loginInteraction(options?: {
callbackClientId?: string;
onAuthorize?: (url: URL) => void;
}): ProviderAuthInteraction {
let authorizeUrl: URL | undefined;
return {
signal: neverAbortedSignal,
notify: (event) => {
if (event.type !== "auth_url") return;
authorizeUrl = new URL(event.url);
options?.onAuthorize?.(authorizeUrl);
},
prompt: async (prompt) => {
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
if (!authorizeUrl) throw new Error("Authorization URL was not emitted before the callback prompt");
const callback = new URL(authorizeUrl.searchParams.get("redirect_uri") ?? "");
callback.searchParams.set("code", "authorization-code");
callback.searchParams.set("state", authorizeUrl.searchParams.get("state") ?? "");
if (options?.callbackClientId) callback.searchParams.set("client_id", options.callbackClientId);
return callback.toString();
},
};
}
function connectedCredential(): OAuthCredential {
return {
type: "oauth",
access: "old-access",
refresh: "old-refresh",
expires: 0,
clientId: "oaiapp_existing",
scopes: REQUIRED_SCOPE.split(" "),
};
}
describe("OpenAI ChatGPT OAuth", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("registers a user-owned client and stores its issued ID and granted scopes", async () => {
let authorizeUrl: URL | undefined;
let exchangeBody: URLSearchParams | undefined;
stubTokenEndpoint(tokenResponse(), (body) => {
exchangeBody = body;
});
const credential = await openaiChatGPTOAuth.login(
loginInteraction({
callbackClientId: "oaiapp_issued",
onAuthorize: (url) => {
authorizeUrl = url;
},
}),
{ getDeviceId: () => DEVICE_ID },
);
expect(authorizeUrl?.searchParams.get("client_id")).toBe("dynamic_agent_client");
expect(authorizeUrl?.searchParams.get("agent_name_hint")).toBe("Pi");
expect(authorizeUrl?.searchParams.get("ext_agent_host_id")).toBe(`urn:uuid:${DEVICE_ID}`);
expect(authorizeUrl?.searchParams.get("scope")).toBe(REQUIRED_SCOPE);
expect(authorizeUrl?.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:1455/auth/callback");
expect(authorizeUrl?.searchParams.get("resource")).toBe("https://api.openai.com/v1");
expect(authorizeUrl?.searchParams.get("code_challenge_method")).toBe("S256");
expect(exchangeBody?.get("client_id")).toBe("oaiapp_issued");
expect(exchangeBody?.get("code")).toBe("authorization-code");
expect(exchangeBody?.get("resource")).toBe("https://api.openai.com/v1");
expect(exchangeBody?.get("code_verifier")).toBeTruthy();
expect(credential).toMatchObject({
type: "oauth",
access: "access-token",
refresh: "refresh-token",
clientId: "oaiapp_issued",
scopes: REQUIRED_SCOPE.split(" "),
});
});
it("uses the app's agent name as the name hint", async () => {
let authorizeUrl: URL | undefined;
stubTokenEndpoint(tokenResponse());
await openaiChatGPTOAuth.login(
loginInteraction({
callbackClientId: "oaiapp_issued",
onAuthorize: (url) => {
authorizeUrl = url;
},
}),
{ getDeviceId: () => DEVICE_ID, agentName: "my-app" },
);
expect(authorizeUrl?.searchParams.get("agent_name_hint")).toBe("my-app");
});
it("rejects registration without an issued client ID", async () => {
const fetchMock = stubTokenEndpoint(tokenResponse());
await expect(openaiChatGPTOAuth.login(loginInteraction(), { getDeviceId: () => DEVICE_ID })).rejects.toThrow(
"registration callback did not contain an issued client ID",
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects a token response that did not grant direct token use", async () => {
stubTokenEndpoint(tokenResponse("openid profile email offline_access resource.invoke"));
await expect(
openaiChatGPTOAuth.login(loginInteraction({ callbackClientId: "oaiapp_issued" }), {
getDeviceId: () => DEVICE_ID,
}),
).rejects.toThrow("grant did not include chatgpt.tokens.use.direct");
});
it("requires a device ID before starting authorization", async () => {
let authorizationStarted = false;
const interaction = loginInteraction({
onAuthorize: () => {
authorizationStarted = true;
},
});
await expect(openaiChatGPTOAuth.login(interaction)).rejects.toThrow("requires a device ID");
await expect(openaiChatGPTOAuth.login(interaction, { getDeviceId: () => "not-a-uuid" })).rejects.toThrow(
"requires a device ID",
);
expect(authorizationStarted).toBe(false);
});
it("requires refresh responses to rotate the refresh token", async () => {
const { refresh_token: _refreshToken, ...responseWithoutRefresh } = tokenResponse();
stubTokenEndpoint(responseWithoutRefresh);
await expect(openaiChatGPTOAuth.refresh(connectedCredential(), neverAbortedSignal)).rejects.toThrow(
"token response has invalid refresh_token",
);
});
it("refreshes with the credential's issued client ID and stores replacement scopes", async () => {
let refreshBody: URLSearchParams | undefined;
stubTokenEndpoint({ ...tokenResponse(), access_token: "new-access", refresh_token: "new-refresh" }, (body) => {
refreshBody = body;
});
const before = Date.now();
const credential = await openaiChatGPTOAuth.refresh(connectedCredential(), neverAbortedSignal);
// expires_in is 3600 seconds; the credential expires 3 minutes early so it is refreshed in time.
expect(credential.expires).toBeGreaterThanOrEqual(before + (3600 - 180) * 1000);
expect(credential.expires).toBeLessThanOrEqual(Date.now() + (3600 - 180) * 1000);
expect(refreshBody?.get("grant_type")).toBe("refresh_token");
expect(refreshBody?.get("client_id")).toBe("oaiapp_existing");
expect(refreshBody?.get("refresh_token")).toBe("old-refresh");
expect(refreshBody?.get("resource")).toBe("https://api.openai.com/v1");
expect(refreshBody?.has("scope")).toBe(false);
expect(credential).toMatchObject({
access: "new-access",
refresh: "new-refresh",
clientId: "oaiapp_existing",
scopes: REQUIRED_SCOPE.split(" "),
});
});
});
@@ -1,3 +1,4 @@
import { createServer } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
@@ -483,4 +484,76 @@ describe("OpenAI Codex OAuth", () => {
).rejects.toThrow(/OpenAI Codex token refresh failed \(401\).*Could not validate your token/);
expect(consoleError).not.toHaveBeenCalled();
});
it("uses the app's agent name as the browser login originator", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
jsonResponse({ access_token: createAccessToken("acct"), refresh_token: "refresh", expires_in: 3600 }),
),
);
let authUrl = "";
await openaiCodexOAuth.login(
{
signal: neverAbortedSignal,
notify: (event) => {
if (event.type === "auth_url") authUrl = event.url;
},
prompt: async (prompt) => {
if (prompt.type === "select") return "browser";
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
const state = new URL(authUrl).searchParams.get("state");
return `http://localhost:1455/auth/callback?code=pasted-code&state=${state}`;
},
},
{ agentName: "my-app" },
);
expect(new URL(authUrl).searchParams.get("originator")).toBe("my-app");
});
it("falls back to the pasted redirect URL when the fixed callback port is taken", async () => {
// Port 1455 is registered with OpenAI; the Codex CLI may hold it. Occupy it unless it already is.
const blocker = createServer();
await new Promise<void>((resolve) => {
blocker.once("error", () => resolve());
blocker.listen(1455, "127.0.0.1", () => resolve());
});
try {
let exchangeBody: URLSearchParams | undefined;
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit) => {
expect(getUrl(input)).toBe("https://auth.openai.com/oauth/token");
exchangeBody = new URLSearchParams(String(init?.body));
return jsonResponse({
access_token: createAccessToken("acct"),
refresh_token: "refresh",
expires_in: 3600,
});
}),
);
let authUrl = "";
const credential = await openaiCodexOAuth.login({
signal: neverAbortedSignal,
notify: (event) => {
if (event.type === "auth_url") authUrl = event.url;
},
prompt: async (prompt) => {
if (prompt.type === "select") return "browser";
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
const state = new URL(authUrl).searchParams.get("state");
return `http://localhost:1455/auth/callback?code=pasted-code&state=${state}`;
},
});
expect(credential.accountId).toBe("acct");
expect(exchangeBody?.get("code")).toBe("pasted-code");
expect(exchangeBody?.get("redirect_uri")).toBe("http://localhost:1455/auth/callback");
} finally {
blocker.close();
}
});
});
+174 -83
View File
@@ -11,7 +11,8 @@ import {
stream as streamOpenAICodexResponses,
streamSimple as streamSimpleOpenAICodexResponses,
} from "../src/api/openai-codex-responses.ts";
import type { Context, Model } from "../src/types.ts";
import type { Api, Context, Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
@@ -97,7 +98,7 @@ function buildSSEPayload({
}
describe("openai-codex streaming", () => {
it("streams SSE responses into AssistantMessageEventStream", async () => {
it("streams SSE responses and forwards raw provider events", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
process.env.PI_CODING_AGENT_DIR = tempDir;
@@ -186,12 +187,21 @@ describe("openai-codex streaming", () => {
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" });
const providerEvents: unknown[] = [];
const providerEventModels: Model<Api>[] = [];
const streamResult = streamOpenAICodexResponses(model, context, {
apiKey: token,
transport: "sse",
onProviderStreamEvent: (event, eventModel) => {
providerEvents.push(event);
providerEventModels.push(eventModel);
},
});
let sawTextDelta = false;
let sawDone = false;
@@ -207,6 +217,14 @@ describe("openai-codex streaming", () => {
expect(sawTextDelta).toBe(true);
expect(sawDone).toBe(true);
expect(providerEvents.map((event) => (event as { type?: string }).type)).toEqual([
"response.output_item.added",
"response.content_part.added",
"response.output_text.delta",
"response.output_item.done",
"response.completed",
]);
expect(providerEventModels).toEqual([model, model, model, model, model]);
});
// Regression test for https://github.com/earendil-works/pi/issues/9047
@@ -225,10 +243,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
const resultStream = streamOpenAICodexResponses(model, context, {
apiKey: token,
transport: "sse",
@@ -284,10 +302,10 @@ describe("openai-codex streaming", () => {
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
const result = await Promise.race([
streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(),
@@ -345,10 +363,10 @@ describe("openai-codex streaming", () => {
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
const result = await Promise.race([
streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(),
@@ -401,10 +419,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
const result = await streamOpenAICodexResponses(model, context, {
apiKey: token,
@@ -498,10 +516,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
const controller = new AbortController();
const events: string[] = [];
@@ -617,12 +635,16 @@ describe("openai-codex streaming", () => {
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, sessionId, transport: "sse" });
const streamResult = streamOpenAICodexResponses(model, context, {
apiKey: token,
sessionId,
transport: "sse",
});
await streamResult.result();
});
@@ -660,10 +682,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
await streamOpenAICodexResponses(model, context, {
apiKey: token,
@@ -710,10 +732,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
await streamOpenAICodexResponses(model, context, {
apiKey: token,
@@ -760,10 +782,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
await streamOpenAICodexResponses(model, context, {
apiKey: token,
@@ -775,6 +797,55 @@ describe("openai-codex streaming", () => {
expect(capturedHeaders?.get("x-client-request-id")).toBe("x".repeat(64));
});
it("lets model and caller headers override originator and User-Agent", async () => {
const token = mockToken();
let capturedHeaders: Headers | undefined;
const encoder = new TextEncoder();
vi.stubGlobal(
"fetch",
vi.fn(async (_input: string | URL, init?: RequestInit) => {
capturedHeaders = init?.headers instanceof Headers ? init.headers : undefined;
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(buildSSEPayload({ status: "completed" })));
controller.close();
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}),
);
const model: Model<"openai-codex-responses"> = {
id: "gpt-5.1-codex",
name: "GPT-5.1 Codex",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
headers: { originator: "my-app" },
};
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
});
await streamOpenAICodexResponses(model, context, {
apiKey: token,
transport: "sse",
headers: { "user-agent": "my-app/1.0", Authorization: "Bearer ignored" },
}).result();
expect(capturedHeaders?.get("originator")).toBe("my-app");
expect(capturedHeaders?.get("User-Agent")).toBe("my-app/1.0");
expect(capturedHeaders?.get("Authorization")).toBe(`Bearer ${token}`);
});
it("preserves gpt-5.5 xhigh reasoning effort from simple options", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
process.env.PI_CODING_AGENT_DIR = tempDir;
@@ -822,10 +893,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
await streamSimpleOpenAICodexResponses(model, context, {
apiKey: token,
@@ -873,7 +944,7 @@ describe("openai-codex streaming", () => {
await streamOpenAICodexResponses(
model,
{
normalizeContext({
messages: [
{ role: "user", content: "Do not call ping. Respond with text instead.", timestamp: Date.now() },
],
@@ -884,7 +955,7 @@ describe("openai-codex streaming", () => {
parameters: Type.Object({ value: Type.String() }),
},
],
},
}),
{ apiKey: token, transport: "sse", toolChoice: "required" },
).result();
@@ -928,7 +999,7 @@ describe("openai-codex streaming", () => {
await streamOpenAICodexResponses(
model,
{
normalizeContext({
messages: [{ role: "user", content: "Use a tool", timestamp: Date.now() }],
tools: [
{
@@ -944,7 +1015,7 @@ describe("openai-codex streaming", () => {
constrainedSampling: { type: "json_schema", strict: "prefer" },
},
],
},
}),
{
apiKey: token,
transport: "sse",
@@ -1046,10 +1117,10 @@ describe("openai-codex streaming", () => {
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
const streamResult = streamOpenAICodexResponses(model, context, {
apiKey: token,
@@ -1142,10 +1213,10 @@ describe("openai-codex streaming", () => {
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
const result = await streamOpenAICodexResponses(model, context, {
apiKey: token,
@@ -1246,18 +1317,23 @@ describe("openai-codex streaming", () => {
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
// No sessionId provided
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" });
const streamResult = streamOpenAICodexResponses(model, context, {
apiKey: token,
transport: "sse",
});
await streamResult.result();
});
it("forwards auto transport from streamSimple options and uses cached websocket context", async () => {
it("forwards auto transport and raw provider events from streamSimple", async () => {
const token = mockToken();
const sentBodies: unknown[] = [];
const providerEvents: unknown[] = [];
const providerEventModels: Model<Api>[] = [];
let capturedWebSocketHeaders: Record<string, string> | undefined;
const fetchMock = vi.fn(async () => new Response("unexpected fetch", { status: 500 }));
@@ -1306,7 +1382,7 @@ describe("openai-codex streaming", () => {
},
},
{
type: "response.completed",
type: "response.done",
response: {
status: "completed",
end_turn: false,
@@ -1349,19 +1425,31 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
};
});
const result = await streamSimpleOpenAICodexResponses(model, context, {
apiKey: token,
sessionId: "session-auto",
transport: "auto",
onProviderStreamEvent: (event, eventModel) => {
providerEvents.push(event);
providerEventModels.push(eventModel);
},
}).result();
expect(result.endTurn).toBe(false);
expect(sentBodies).toHaveLength(1);
expect(providerEvents.map((event) => (event as { type?: string }).type)).toEqual([
"response.output_item.added",
"response.content_part.added",
"response.output_text.delta",
"response.output_item.done",
"response.done",
]);
expect(providerEventModels).toEqual([model, model, model, model, model]);
expect(capturedWebSocketHeaders?.["session-id"]).toBe("session-auto");
expect(capturedWebSocketHeaders?.session_id).toBeUndefined();
expect(capturedWebSocketHeaders?.["x-client-request-id"]).toBe("session-auto");
@@ -1444,12 +1532,21 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = { systemPrompt: "", messages: [] };
const context = normalizeContext({ systemPrompt: "", messages: [] });
const options = { sessionId: "shared-session", transport: "websocket-cached" as const };
await streamOpenAICodexResponses(model, context, { ...options, apiKey: mockToken("account-a") }).result();
await streamOpenAICodexResponses(model, context, { ...options, apiKey: mockToken("account-b") }).result();
await streamOpenAICodexResponses(model, context, { ...options, apiKey: mockToken("account-a") }).result();
await streamOpenAICodexResponses(model, context, {
...options,
apiKey: mockToken("account-a"),
}).result();
await streamOpenAICodexResponses(model, context, {
...options,
apiKey: mockToken("account-b"),
}).result();
await streamOpenAICodexResponses(model, context, {
...options,
apiKey: mockToken("account-a"),
}).result();
expect(connectedHeaders.map((headers) => headers["chatgpt-account-id"])).toEqual(["account-a", "account-b"]);
expect(connectedHeaders.map((headers) => headers.authorization)).toEqual([
@@ -1535,10 +1632,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
};
});
const options = {
apiKey: token,
cacheRetention: "none" as const,
@@ -1618,10 +1715,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
};
});
const resultPromise = streamOpenAICodexResponses(model, context, {
apiKey: token,
@@ -1693,13 +1790,9 @@ describe("openai-codex streaming", () => {
maxTokens: 128000,
};
const result = await streamOpenAICodexResponses(
model,
{ systemPrompt: "", messages: [] },
{
apiKey: token,
},
).result();
const result = await streamOpenAICodexResponses(model, normalizeContext({ systemPrompt: "", messages: [] }), {
apiKey: token,
}).result();
expect(result.stopReason).toBe("stop");
expect(connections).toBe(2);
@@ -1782,10 +1875,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
};
});
const resultPromise = streamOpenAICodexResponses(model, context, {
apiKey: token,
@@ -1873,10 +1966,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
};
});
const resultPromise = streamOpenAICodexResponses(model, context, {
apiKey: token,
@@ -1968,10 +2061,10 @@ describe("openai-codex streaming", () => {
maxTokens: 128000,
};
const sessionId = "aged-ws-session";
const firstContext: Context = {
const firstContext = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
};
});
const first = await streamOpenAICodexResponses(model, firstContext, {
apiKey: token,
@@ -1979,10 +2072,9 @@ describe("openai-codex streaming", () => {
transport: "websocket-cached",
}).result();
vi.setSystemTime(new Date(startedAt.getTime() + 56 * 60 * 1000));
const secondContext: Context = {
systemPrompt: "You are a helpful assistant.",
const secondContext = normalizeContext({
messages: [...firstContext.messages, first, { role: "user", content: "Now finish", timestamp: 2 }],
};
});
await streamOpenAICodexResponses(model, secondContext, {
apiKey: token,
@@ -2117,13 +2209,13 @@ describe("openai-codex streaming", () => {
],
};
const first = await streamOpenAICodexResponses(model, firstContext, {
const first = await streamOpenAICodexResponses(model, normalizeContext(firstContext), {
apiKey: token,
sessionId: "session-1",
transport: "websocket-cached",
}).result();
const secondContext: Context = {
const secondContext = normalizeContext({
...firstContext,
messages: [
...firstContext.messages,
@@ -2138,7 +2230,7 @@ describe("openai-codex streaming", () => {
},
{ role: "user", content: "Now finish", timestamp: 3 },
],
};
});
await streamOpenAICodexResponses(model, secondContext, {
apiKey: token,
sessionId: "session-1",
@@ -2333,20 +2425,19 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const firstContext: Context = {
const firstContext = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
};
});
const first = await streamOpenAICodexResponses(model, firstContext, {
apiKey: token,
sessionId,
transport: "websocket-cached",
}).result();
const secondContext: Context = {
systemPrompt: "You are a helpful assistant.",
const secondContext = normalizeContext({
messages: [...firstContext.messages, first, { role: "user", content: "Now finish", timestamp: 2 }],
};
});
const eventTypes: string[] = [];
const secondStream = streamOpenAICodexResponses(model, secondContext, {
apiKey: token,
@@ -2443,10 +2534,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
const resultPromise = streamOpenAICodexResponses(model, context, {
apiKey: token,
@@ -2485,10 +2576,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
const result = await streamOpenAICodexResponses(model, context, {
apiKey: token,
@@ -2546,10 +2637,10 @@ describe("openai-codex streaming", () => {
const largeText = "compress me ".repeat(400);
await streamOpenAICodexResponses(
model,
{
normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: largeText, timestamp: 1 }],
},
}),
{ apiKey: token, transport: "sse" },
).result();
@@ -2564,10 +2655,10 @@ describe("openai-codex streaming", () => {
capturedBody = undefined;
await streamOpenAICodexResponses(
model,
{
normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "hi", timestamp: 1 }],
},
}),
{ apiKey: token, transport: "sse" },
).result();
@@ -2622,10 +2713,10 @@ describe("openai-codex streaming", () => {
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
});
const retryTimeoutDelays = () =>
setTimeoutSpy.mock.calls
@@ -1,7 +1,7 @@
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 { getModel, normalizeContext } from "../src/compat.ts";
import type { Message, Model } from "../src/types.ts";
interface CacheControl {
@@ -80,7 +80,7 @@ async function capturePayload(
await streamOpenAICompletions(
model,
{
normalizeContext({
systemPrompt: "System prompt",
messages: messages ?? [{ role: "user", content: "Hello", timestamp }],
tools: [
@@ -92,7 +92,7 @@ async function capturePayload(
}),
},
],
},
}),
{ apiKey: "test-key", ...options },
).result();
@@ -4,7 +4,7 @@ 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>
// Regression for https://github.com/earendil-works/pi/issues/3649
const mockState = vi.hoisted(() => ({
lastParams: undefined as unknown,
@@ -140,7 +140,7 @@ describe("openai-completions empty tools handling", () => {
const params = mockState.lastParams as { max_tokens?: number; max_completion_tokens?: number };
expect(params.max_tokens).toBeUndefined();
expect(params.max_completion_tokens).toBe(3904);
expect(params.max_completion_tokens).toBe(3618);
});
it("clamps explicit maxTokens to remaining context", async () => {
@@ -157,7 +157,7 @@ describe("openai-completions empty tools handling", () => {
const params = mockState.lastParams as { max_tokens?: number; max_completion_tokens?: number };
expect(params.max_tokens).toBeUndefined();
expect(params.max_completion_tokens).toBe(3904);
expect(params.max_completion_tokens).toBe(3618);
});
it("uses conservative OpenAI-compatible fields for Cloudflare AI Gateway /compat models", async () => {
@@ -1,6 +1,6 @@
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 { getModel, normalizeContext } from "../src/compat.ts";
import type { Model } from "../src/types.ts";
interface FakeOpenAIClientOptions {
@@ -99,10 +99,10 @@ describe("openai-completions prompt caching", () => {
) {
await streamOpenAICompletions(
model,
{
normalizeContext({
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
}),
{ apiKey: "test-key", ...options },
).result();
@@ -171,7 +171,7 @@ describe("openai-completions prompt caching", () => {
expect(headers["x-session-affinity"]).toBe("session-affinity");
});
it.each(["accounts/fireworks/models/glm-5p2", "accounts/fireworks/routers/glm-5p2-fast"] as const)(
it.each(["accounts/fireworks/models/glm-5p3", "accounts/fireworks/routers/glm-5p3-fast"] as const)(
"sends Fireworks session affinity for %s",
async (modelId) => {
const model = getModel("fireworks", modelId);
@@ -181,6 +181,14 @@ describe("openai-completions prompt caching", () => {
},
);
it("sends Baseten session affinity for built-in catalog models", async () => {
const model = getModel("baseten", "zai-org/GLM-5.2");
const { headers } = await captureRequest({ sessionId: "baseten-catalog-session" }, model);
expect(headers["x-session-affinity"]).toBe("baseten-catalog-session");
expect(headers["x-client-request-id"]).toBe("baseten-catalog-session");
});
it("uses OpenAI no-session format when configured", async () => {
const model = createModel({
compat: { sendSessionAffinityHeaders: true, sessionAffinityFormat: "openai-nosession" },
@@ -210,12 +218,8 @@ describe("openai-completions prompt caching", () => {
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 },
});
it("sends OpenRouter session-affinity header by default for built-in OpenRouter models", async () => {
const model = getModel("openrouter", "auto");
const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model);
expect(payload?.session_id).toBeUndefined();
@@ -230,6 +234,7 @@ describe("openai-completions prompt caching", () => {
const model = createModel({
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
compat: { sendSessionAffinityHeaders: false },
});
const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model);
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { completeSimple } from "../src/compat.ts";
import type { Model } from "../src/types.ts";
const mockState = vi.hoisted(() => ({
chunks: [] as unknown[],
}));
vi.mock("openai", () => {
class FakeOpenAI {
chat = {
completions: {
create: () => {
const stream = {
async *[Symbol.asyncIterator]() {
for (const chunk of mockState.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({ "x-request-id": "req-1" }) },
});
return promise;
},
},
};
}
return { default: FakeOpenAI };
});
function openRouterModel(): 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 provider stream events", () => {
beforeEach(() => {
mockState.chunks = [];
});
// Regression test for #9784.
it("exposes provider chunks including OpenRouter metadata", async () => {
const firstChunk = {
id: "chatcmpl-1",
model: "anthropic/claude-sonnet-4.6",
choices: [{ index: 0, delta: { content: "hello" } }],
};
const finalChunk = {
id: "chatcmpl-1",
model: "anthropic/claude-sonnet-4.6",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 10,
completion_tokens: 2,
total_tokens: 12,
cost: 0.0012,
is_byok: false,
},
openrouter_metadata: { strategy: "direct", region: "iad" },
};
mockState.chunks = [firstChunk, finalChunk];
const events: unknown[] = [];
const message = await completeSimple(
openRouterModel(),
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] },
{
apiKey: "test",
onProviderStreamEvent: (data) => {
events.push(data);
},
},
);
expect(message.content).toEqual([{ type: "text", text: "hello" }]);
expect(events).toEqual([firstChunk, finalChunk]);
});
});
@@ -1,6 +1,7 @@
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";
import type { Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const mockState = vi.hoisted(() => ({
chunks: [] as unknown[],
@@ -48,9 +49,9 @@ const model: Model<"openai-completions"> = {
maxTokens: 4096,
};
const context: Context = {
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
});
describe("OpenAI completions raw stop reasons", () => {
beforeEach(() => {
@@ -2,6 +2,7 @@ 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";
import { normalizeContext } from "../src/utils/transcript.ts";
const mockState = vi.hoisted(() => ({
chunkSets: [] as unknown[][],
@@ -96,7 +97,9 @@ function toolCallChunk(): unknown {
}
async function runOpenAICompletionsStream(messages: AssistantMessage[] = []): Promise<AssistantMessage> {
return await streamOpenAICompletions(model(), { messages, tools: [readTool] }, { apiKey: "test" }).result();
return await streamOpenAICompletions(model(), normalizeContext({ messages, tools: [readTool] }), {
apiKey: "test",
}).result();
}
function getAssistantPayload(payload: unknown): { reasoning?: unknown; reasoning_details?: unknown } | undefined {
@@ -249,3 +252,28 @@ describe("openai-completions reasoning_details streaming", () => {
expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual(expectedReasoningDetails);
});
});
describe("openai-completions reasoning and content in one delta", () => {
beforeEach(() => {
mockState.chunkSets = [];
mockState.payloads = [];
});
// https://github.com/earendil-works/pi/issues/10926
it("places thinking before text when one delta carries reasoning_content and content", async () => {
mockState.chunkSets = [[chunk({ role: "assistant", reasoning_content: "thinking", content: "answer" }, "stop")]];
const assistantMessage = await runOpenAICompletionsStream();
expect(assistantMessage.content).toEqual([
{ type: "thinking", thinking: "thinking", thinkingSignature: "reasoning_content" },
{ type: "text", text: "answer" },
]);
});
it("places thinking before text when one delta carries reasoning_details and content", async () => {
mockState.chunkSets = [[chunk({ reasoning_details: [reasoningDetail], content: "answer" }, "stop")]];
const assistantMessage = await runOpenAICompletionsStream();
expect(assistantMessage.content.map((block) => block.type)).toEqual(["thinking", "text"]);
});
});
@@ -1,6 +1,7 @@
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";
import type { Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const mockState = vi.hoisted(() => ({
requestOptions: [] as unknown[],
@@ -60,11 +61,11 @@ const model: Model<"openai-completions"> = {
maxTokens: 100,
};
const context: Context = {
const context = normalizeContext({
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 });
@@ -11,6 +11,7 @@ import type {
OpenAICompletionsCompat,
Usage,
} from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const emptyUsage: Usage = {
input: 0,
@@ -42,16 +43,17 @@ const compat = {
thinkingTokenBudgetField: undefined,
supportsStrictMode: true,
supportsOpenAIGrammarTools: false,
supportsMidConvoSystemMessages: false,
supportsMidConvoToolAdditions: false,
cacheControlFormat: undefined,
sendSessionAffinityHeaders: false,
sessionAffinityFormat: "openai",
supportsLongCacheRetention: true,
} satisfies Omit<
Required<OpenAICompletionsCompat>,
"cacheControlFormat" | "deferredToolsMode" | "thinkingTokenBudgetField" | "vllmPriority"
"cacheControlFormat" | "thinkingTokenBudgetField" | "vllmPriority"
> & {
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
};
@@ -117,11 +119,13 @@ describe("openai-completions thinking-as-text replay", () => {
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" },
]),
normalizeContext(
buildContext(
buildAssistant([
{ type: "thinking", thinking: "internal reasoning" },
{ type: "text", text: "visible answer" },
]),
),
),
compat,
);
@@ -138,7 +142,7 @@ describe("openai-completions thinking-as-text replay", () => {
it("serializes same-model thinking-only replay as assistant text parts", () => {
const messages = convertMessages(
buildModel(),
buildContext(buildAssistant([{ type: "thinking", thinking: "internal reasoning" }])),
normalizeContext(buildContext(buildAssistant([{ type: "thinking", thinking: "internal reasoning" }]))),
compat,
);
@@ -198,11 +202,13 @@ describe("openai-completions thinking-as-text replay", () => {
const events = await collectEvents(
streamOpenAICompletions(
buildModel(`http://127.0.0.1:${port}`),
buildContext(
buildAssistant([
{ type: "thinking", thinking: "internal reasoning" },
{ type: "text", text: "visible answer" },
]),
normalizeContext(
buildContext(
buildAssistant([
{ type: "thinking", thinking: "internal reasoning" },
{ type: "text", text: "visible answer" },
]),
),
),
{ apiKey: "test-key" },
),
@@ -1,7 +1,7 @@
import { Type } from "typebox";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { convertMessages } from "../src/api/openai-completions.ts";
import { getModel, stream, streamSimple } from "../src/compat.ts";
import { getModel, normalizeContext, stream, streamSimple } from "../src/compat.ts";
import type { AssistantMessage, Model, SimpleStreamOptions, Tool, ToolResultMessage } from "../src/types.ts";
const mockState = vi.hoisted(() => ({
@@ -219,6 +219,82 @@ describe("openai-completions tool_choice", () => {
expect("strict" in (tool ?? {})).toBe(false);
});
it("defaults unknown OpenAI-compatible endpoints to non-strict tools", async () => {
// Regression test for #9816.
const model = {
...localOpenAICompletionsModel,
id: "local-model",
name: "Local Model",
} satisfies Model<"openai-completions">;
const tool: Tool = {
name: "ping",
description: "Ping tool",
parameters: Type.Object({
required: Type.String(),
optional: Type.Optional(Type.String()),
}),
constrainedSampling: { type: "json_schema", strict: "prefer" },
};
let payload: unknown;
await streamSimple(
model,
{
messages: [{ role: "user", content: "Call ping", timestamp: Date.now() }],
tools: [tool],
},
{
apiKey: "test",
onPayload: (params: unknown) => {
payload = params;
},
},
).result();
const params = (payload ?? mockState.lastParams) as {
tools?: Array<{ function?: { strict?: boolean; parameters?: { required?: string[] } } }>;
};
const functionTool = params.tools?.[0]?.function;
expect(functionTool).not.toHaveProperty("strict");
expect(functionTool?.parameters?.required).toEqual(["required"]);
});
it("preserves strict tools for capable built-in Chat Completions models", async () => {
const model = getModel("groq", "openai/gpt-oss-20b")!;
expect(model.compat?.supportsStrictMode).toBe(true);
const tool: Tool = {
name: "ping",
description: "Ping tool",
parameters: Type.Object({
required: Type.String(),
optional: Type.Optional(Type.String()),
}),
constrainedSampling: { type: "json_schema", strict: "prefer" },
};
let payload: unknown;
await streamSimple(
model,
{
messages: [{ role: "user", content: "Call ping", timestamp: Date.now() }],
tools: [tool],
},
{
apiKey: "test",
onPayload: (params: unknown) => {
payload = params;
},
},
).result();
const params = (payload ?? mockState.lastParams) as {
tools?: Array<{ function?: { strict?: boolean; parameters?: { required?: string[] } } }>;
};
const functionTool = params.tools?.[0]?.function;
expect(functionTool?.strict).toBe(true);
expect(functionTool?.parameters?.required).toEqual(["required", "optional"]);
});
it("maps Groq Qwen reasoning levels to default reasoning_effort", async () => {
const model = getModel("groq", "qwen/qwen3.6-27b")!;
let payload: unknown;
@@ -320,21 +396,21 @@ describe("openai-completions tool_choice", () => {
});
it("stores z.ai effort metadata", () => {
for (const provider of ["zai", "zai-coding-cn"] as const) {
for (const modelId of ["glm-5.2", "glm-5.2-highspeed"] as const) {
const model = getModel(provider, modelId)!;
expect(model.compat?.supportsReasoningEffort).toBe(true);
expect(model.thinkingLevelMap).toEqual({
off: "none",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: "max",
});
}
for (const modelId of ["glm-5.2", "glm-5.2-highspeed"] as const) {
const model = getModel("zai", modelId)!;
expect(model.compat?.supportsReasoningEffort).toBe(true);
expect(model.thinkingLevelMap).toEqual({
off: "none",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: "max",
});
}
for (const provider of ["zai", "zai-coding-cn"] as const) {
const glm53 = getModel(provider, "glm-5.3")!;
expect(glm53.compat?.supportsReasoningEffort).toBe(true);
expect(glm53.thinkingLevelMap).toEqual({
@@ -1030,12 +1106,12 @@ describe("openai-completions tool_choice", () => {
]);
expect(response.content).toHaveLength(6);
expect(response.content[0]).toEqual({ type: "text", text: "answer 1 answer 2\n" });
expect(response.content[1]).toEqual({
expect(response.content[0]).toEqual({
type: "thinking",
thinking: "think 1 think 2",
thinkingSignature: "reasoning_content",
});
expect(response.content[1]).toEqual({ type: "text", text: "answer 1 answer 2\n" });
const readCall = response.content[2];
const grepCall = response.content[3];
const listCall = response.content[4];
@@ -1233,7 +1309,7 @@ describe("openai-completions tool_choice", () => {
const replayedAssistant = params.messages?.find((message) => message.role === "assistant");
expect(replayedAssistant).toMatchObject({ role: "assistant", reasoning_content: "" });
expect(params.thinking).toEqual({ type: "enabled" });
expect(params.reasoning_effort).toBe("high");
expect(params.reasoning_effort).toBeUndefined();
});
it("normalizes OpenCode Go reasoning deltas to reasoning_content for replay", async () => {
@@ -1244,7 +1320,7 @@ describe("openai-completions tool_choice", () => {
},
];
const { compat: _compat, ...baseModel } = getModel("opencode-go", "kimi-k2.6")!;
const { compat: _compat, ...baseModel } = getModel("opencode-go", "kimi-k3")!;
const model = { ...baseModel, api: "openai-completions" } as const;
const response = await streamSimple(
model,
@@ -1291,17 +1367,17 @@ describe("openai-completions tool_choice", () => {
});
it("replays OpenCode Go reasoning thinking blocks as reasoning_content", () => {
const { compat: _compat, ...baseModel } = getModel("opencode-go", "kimi-k2.6")!;
const { compat: _compat, ...baseModel } = getModel("opencode-go", "kimi-k3")!;
const model = { ...baseModel, api: "openai-completions" } as Model<"openai-completions">;
const messages = convertMessages(
model,
{
normalizeContext({
messages: [
{
role: "assistant",
api: "openai-completions",
provider: "opencode-go",
model: "kimi-k2.6",
model: "kimi-k3",
content: [
{ type: "thinking", thinking: "think", thinkingSignature: "reasoning" },
{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" } },
@@ -1318,7 +1394,7 @@ describe("openai-completions tool_choice", () => {
timestamp: Date.now(),
},
],
},
}),
{
...model.compat,
supportsStore: false,
@@ -1349,8 +1425,8 @@ describe("openai-completions tool_choice", () => {
expect(messages[0]).not.toHaveProperty("reasoning");
});
it("sends thinking disabled for OpenCode Go Kimi K2.6 when thinking is off", async () => {
const model = getModel("opencode-go", "kimi-k2.6")!;
it("sends thinking disabled for OpenCode Kimi K2.6 when thinking is off", async () => {
const model = getModel("opencode", "kimi-k2.6")!;
let payload: unknown;
await streamSimple(
@@ -1371,8 +1447,8 @@ describe("openai-completions tool_choice", () => {
expect(params.reasoning_effort).toBeUndefined();
});
it("sends thinking enabled for OpenCode Go Kimi K2.6 when thinking is enabled", async () => {
const model = getModel("opencode-go", "kimi-k2.6")!;
it("sends thinking enabled for OpenCode Kimi K2.6 when thinking is enabled", async () => {
const model = getModel("opencode", "kimi-k2.6")!;
let payload: unknown;
await streamSimple(
@@ -1443,7 +1519,7 @@ describe("openai-completions tool_choice", () => {
});
it("sends max_tokens for OpenCode completions models", async () => {
const cases = [getModel("opencode-go", "kimi-k2.6")!, getModel("opencode", "kimi-k2.6")!] as const;
const cases = [getModel("opencode-go", "kimi-k3")!, getModel("opencode", "kimi-k2.6")!] as const;
for (const model of cases) {
let payload: unknown;
@@ -1483,10 +1559,7 @@ describe("openai-completions tool_choice", () => {
name: "Custom Uppercase DeepSeek Model",
baseUrl: "https://API.DeepSeek.COM",
} satisfies Model<"openai-completions">;
const nativeModels = [
getModel("deepseek", "deepseek-v4-flash")!,
getModel("deepseek", "deepseek-v4-pro")!,
] as const;
const nativeModels = [getModel("deepseek", "deepseek-flash")!, getModel("deepseek", "deepseek-v4-pro")!] as const;
const cases = [...nativeModels, customModel, customUppercaseModel] as const;
for (const model of nativeModels) {
@@ -1802,7 +1875,7 @@ describe("openai-completions tool_choice", () => {
thinkingFormat: "ant-ling",
supportsLongCacheRetention: false,
});
expect(model.compat?.supportsStrictMode).toBeUndefined();
expect(model.compat?.supportsStrictMode).toBe(true);
expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBeUndefined();
await streamSimple(
@@ -1,14 +1,7 @@
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";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { AssistantMessage, Model, OpenAICompletionsCompat, ToolResultMessage, Usage } from "../src/types.ts";
const emptyUsage: Usage = {
input: 0,
@@ -19,11 +12,7 @@ const emptyUsage: Usage = {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const compat: Omit<
Required<OpenAICompletionsCompat>,
"deferredToolsMode" | "thinkingTokenBudgetField" | "vllmPriority"
> & {
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
const compat: Omit<Required<OpenAICompletionsCompat>, "thinkingTokenBudgetField" | "vllmPriority"> & {
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
} = {
supportsStore: true,
@@ -46,6 +35,8 @@ const compat: Omit<
thinkingTokenBudgetField: undefined,
supportsStrictMode: true,
supportsOpenAIGrammarTools: false,
supportsMidConvoSystemMessages: false,
supportsMidConvoToolAdditions: false,
cacheControlFormat: "anthropic",
sendSessionAffinityHeaders: false,
sessionAffinityFormat: "openai",
@@ -78,6 +69,35 @@ function buildEmptyToolResult(toolCallId: string, timestamp: number): ToolResult
}
describe("openai-completions convertMessages", () => {
// Regression test for https://github.com/earendil-works/pi/issues/9797
it("omits empty text parts from user messages with images", () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
const model: Model<"openai-completions"> = {
...baseModel,
api: "openai-completions",
input: ["text", "image"],
};
const context = normalizeContext({
messages: [
{
role: "user",
content: [
{ type: "text", text: "" },
{ type: "image", data: "ZmFrZQ==", mimeType: "image/png" },
],
timestamp: Date.now(),
},
],
});
expect(convertMessages(model, context, compat)).toEqual([
{
role: "user",
content: [{ type: "image_url", image_url: { url: "data:image/png;base64,ZmFrZQ==" } }],
},
]);
});
it("batches tool-result images after consecutive tool results", () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
const model: Model<"openai-completions"> = {
@@ -101,14 +121,14 @@ describe("openai-completions convertMessages", () => {
timestamp: now,
};
const context: Context = {
const context = normalizeContext({
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);
@@ -144,13 +164,13 @@ describe("openai-completions convertMessages", () => {
timestamp: now,
};
const context: Context = {
const context = normalizeContext({
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;
@@ -1,6 +1,6 @@
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 { getModel, normalizeContext } from "../src/compat.ts";
import type { Model } from "../src/types.ts";
interface CapturedCompletionsPayload {
@@ -62,10 +62,10 @@ function createModel(overrides: Partial<Model<"openai-completions">> = {}): Mode
async function captureRequest(model: Model<"openai-completions">) {
await streamOpenAICompletions(
model,
{
normalizeContext({
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
}),
{ apiKey: "test-key" },
).result();
+291
View File
@@ -0,0 +1,291 @@
import { describe, expect, it, vi } from "vitest";
import { classify } from "../src/api/openai-decisions.ts";
import type { ClassifierContext, ClassifierModel, ImageContent } from "../src/types.ts";
const model: ClassifierModel<"openai-decisions"> = {
type: "classifier",
id: "gpt-6-luna",
name: "GPT-6 Luna",
api: "openai-decisions",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
input: ["text", "image"],
cost: {
input: 0.1,
output: 0,
cacheRead: 0,
cacheWrite: 0,
tiers: [{ inputTokensAbove: 272000, input: 0.2, output: 0, cacheRead: 0, cacheWrite: 0 }],
},
contextWindow: 922000,
};
const context: ClassifierContext = {
state: { text: "The deployment succeeded, thank you." },
questions: {
category: {
type: "choice",
instructions: "Classify the message",
criteria: { success: "Successful", failure: "" },
},
satisfaction: {
type: "score",
instructions: "Score satisfaction",
criteria: ["low", "neutral", "high"],
},
approved: {
type: "bool",
instructions: "Does the user approve?",
criteria: { true: "Approval", false: "No approval" },
},
},
};
/** Response shape from the API reference and live `gpt-6-luna` requests. */
const wireAnswers = [
{
type: "choice",
name: "category",
choice: "success",
probabilities: [
{ value: "success", probability: 0.9 },
{ value: "failure", probability: 0.1 },
],
confidence: 0.8,
},
{
type: "score",
name: "satisfaction",
score: 1.8,
probabilities: [
{ value: 0, label: "low", probability: 0.05 },
{ value: 1, label: "neutral", probability: 0.1 },
{ value: 2, label: "high", probability: 0.85 },
],
confidence: 0.7,
},
{ type: "predicate", name: "approved", probability: 0.95 },
];
const wireUsage = {
input_tokens: 164,
input_tokens_details: { cached_tokens: 0, cache_write_tokens: 0 },
output_tokens: 0,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 164,
};
const image: ImageContent = { type: "image", data: "aW1hZ2U=", mimeType: "image/png" };
describe("OpenAI Decisions", () => {
it("maps questions to Decisions types and answers back by name", async () => {
const fetch = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) =>
// Answers out of question order: they are matched by name.
Response.json({ model: "gpt-6-luna", answers: [...wireAnswers].reverse(), usage: wireUsage }),
);
const result = await classify(model, context, { apiKey: "secret", fetch, temperature: 1.5 });
expect(fetch).toHaveBeenCalledOnce();
const [url, init] = fetch.mock.calls[0]!;
expect(String(url)).toBe("https://api.openai.com/v1/decisions");
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer secret");
expect(JSON.parse(String(init?.body))).toEqual({
model: "gpt-6-luna",
input: JSON.stringify(context.state),
questions: [
{
type: "choice",
name: "category",
instructions: "Classify the message",
// Empty descriptions are omitted.
choices: [{ value: "success", description: "Successful" }, { value: "failure" }],
},
{
type: "score",
name: "satisfaction",
instructions: "Score satisfaction",
levels: [{ label: "low" }, { label: "neutral" }, { label: "high" }],
},
{
type: "predicate",
name: "approved",
instructions: "Does the user approve?\n\nTrue means: Approval\nFalse means: No approval",
},
],
});
expect(result.stopReason).toBe("stop");
expect(result.answers).toEqual({
category: {
type: "choice",
choice: "success",
probabilities: { success: 0.9, failure: 0.1 },
confidence: 0.8,
},
satisfaction: { type: "score", score: 1.8, confidence: 0.7 },
approved: { type: "bool", probability: 0.95 },
});
expect(result.usage).toMatchObject({ input: 164, output: 0, cacheRead: 0, totalTokens: 164 });
expect(result.usage?.cost.total).toBeCloseTo(0.0000164, 12);
});
it("prices long-context requests at the long-context input rate", async () => {
const result = await classify(model, context, {
apiKey: "secret",
fetch: async () => Response.json({ answers: wireAnswers, usage: { input_tokens: 300000, output_tokens: 0 } }),
});
expect(result.usage?.cost.total).toBeCloseTo(0.06, 12);
});
it("sends images after the state in one user message", async () => {
const fetch = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) =>
Response.json({ answers: wireAnswers }),
);
const result = await classify(
model,
{ ...context, images: [image, { ...image, mimeType: "image/jpeg" }] },
{
apiKey: "secret",
fetch,
},
);
expect(result.stopReason).toBe("stop");
expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body)).input).toEqual([
{
role: "user",
content: [
{ type: "input_text", text: JSON.stringify(context.state) },
{ type: "input_image", image_url: "data:image/png;base64,aW1hZ2U=" },
{ type: "input_image", image_url: "data:image/jpeg;base64,aW1hZ2U=" },
],
},
]);
});
it("rejects more than 128 images before sending", async () => {
const fetch = vi.fn(async () => Response.json({ answers: wireAnswers }));
const result = await classify(
model,
{ ...context, images: Array.from({ length: 129 }, () => image) },
{
apiKey: "secret",
fetch,
},
);
expect(fetch).not.toHaveBeenCalled();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("at most 128 images, got 129");
});
it("fails the result when a question is refused and keeps the billed usage", async () => {
const result = await classify(model, context, {
apiKey: "secret",
fetch: async () =>
Response.json({
answers: [wireAnswers[0], wireAnswers[1], { type: "refusal", name: "approved" }],
usage: wireUsage,
}),
});
expect(result.stopReason).toBe("error");
expect(result.answers).toEqual({});
expect(result.errorMessage).toBe("OpenAI Decisions refused to answer approved");
expect(result.usage).toMatchObject({ input: 164 });
});
it("returns missing and mistyped answers as classifier errors", async () => {
const missing = await classify(model, context, {
apiKey: "secret",
fetch: async () => Response.json({ answers: wireAnswers.slice(0, 2) }),
});
const mistyped = await classify(model, context, {
apiKey: "secret",
fetch: async () =>
Response.json({ answers: [wireAnswers[0], wireAnswers[1], { type: "score", name: "approved" }] }),
});
expect(missing.stopReason).toBe("error");
expect(missing.errorMessage).toContain("did not return an answer for approved");
expect(mistyped.stopReason).toBe("error");
expect(mistyped.errorMessage).toContain("did not return a predicate answer for approved");
});
it("preserves prototype-sensitive question IDs in answers", async () => {
const prototypeContext: ClassifierContext = {
state: {},
questions: JSON.parse(
'{"__proto__":{"type":"bool","instructions":"Is this true?","criteria":{"true":"Yes","false":"No"}}}',
),
};
const result = await classify(model, prototypeContext, {
apiKey: "secret",
fetch: async () => Response.json({ answers: [{ type: "predicate", name: "__proto__", probability: 0.75 }] }),
});
expect(result.stopReason).toBe("stop");
expect(Object.hasOwn(result.answers, "__proto__")).toBe(true);
expect(result.answers.__proto__).toEqual({ type: "bool", probability: 0.75 });
});
it("does not retry gateway timeouts and explains them instead of returning the HTML page", async () => {
const fetch = vi.fn(
async () =>
new Response("<!DOCTYPE html><html>Gateway time-out</html>", {
status: 504,
headers: { "retry-after-ms": "0" },
}),
);
// Default retries: the same input would time out again.
const result = await classify(model, context, { apiKey: "secret", fetch });
expect(fetch).toHaveBeenCalledOnce();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("OpenAI Decisions error (504): the request timed out at the gateway");
expect(result.errorMessage).not.toContain("<html>");
});
it("still retries other server errors", async () => {
let attempt = 0;
const result = await classify(model, context, {
apiKey: "secret",
fetch: async () =>
++attempt === 1
? new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } })
: Response.json({ answers: wireAnswers }),
});
expect(attempt).toBe(2);
expect(result.stopReason).toBe("stop");
});
it("includes the API error body for other HTTP failures", async () => {
const result = await classify(model, context, {
apiKey: "secret",
maxRetries: 0,
fetch: async () =>
Response.json(
{ error: { message: "Decision input exceeds the token limit.", type: "invalid_request_error" } },
{ status: 400 },
),
});
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("OpenAI Decisions error (400)");
expect(result.errorMessage).toContain("Decision input exceeds the token limit.");
});
it("rejects models for other classifier APIs and missing API keys", async () => {
const fetch = vi.fn(async () => Response.json({ answers: wireAnswers }));
const otherApi = await classify({ ...model, api: "typesafe-system-one" }, context, { apiKey: "secret", fetch });
const noKey = await classify(model, context, { fetch });
expect(fetch).not.toHaveBeenCalled();
expect(otherApi.errorMessage).toContain("Unsupported classifier API: typesafe-system-one");
expect(noKey.errorMessage).toContain("No API key for provider: openai");
});
});
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
import type { Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const model: Model<"openai-responses"> = {
id: "gpt-5-mini",
name: "GPT-5 Mini",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const context = normalizeContext({
systemPrompt: "",
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }],
tools: [],
});
async function capturePayload(
apiKey: string,
requestModel: Model<"openai-responses"> = model,
): Promise<Record<string, unknown>> {
let payload: Record<string, unknown> | undefined;
await streamOpenAIResponses(requestModel, context, {
apiKey,
maxTokens: 1000,
temperature: 0.5,
cacheRetention: "long",
onPayload: (params) => {
payload = params as Record<string, unknown>;
},
fetch: async () => new Response(null, { status: 500 }),
}).result();
if (!payload) throw new Error("Request payload was not captured");
return payload;
}
describe("OpenAI Responses with Sign in with ChatGPT", () => {
it("omits request fields that token sharing rejects", async () => {
const payload = await capturePayload("chatgpt-access-token");
expect(payload).not.toHaveProperty("max_output_tokens");
expect(payload).not.toHaveProperty("temperature");
expect(payload.prompt_cache_retention).toBeUndefined();
});
it("omits prompt_cache_options on models with explicit prompt cache mode", async () => {
const explicitCacheModel = { ...model, compat: { supportsExplicitPromptCacheMode: true } };
const signInPayload = await capturePayload("chatgpt-access-token", explicitCacheModel);
const apiKeyPayload = await capturePayload("sk-proj-test", explicitCacheModel);
expect(signInPayload.prompt_cache_options).toBeUndefined();
expect(apiKeyPayload.prompt_cache_options).toEqual({ ttl: "30m" });
});
it.each([
{ name: "OpenAI API keys", apiKey: "sk-proj-test", requestModel: model },
{
name: "other OpenAI-compatible endpoints",
apiKey: "gateway-key",
requestModel: { ...model, baseUrl: "https://gateway.example.com/v1" },
},
])("keeps those fields for $name", async ({ apiKey, requestModel }) => {
const payload = await capturePayload(apiKey, requestModel);
expect(payload.max_output_tokens).toBe(1000);
expect(payload.temperature).toBe(0.5);
expect(payload.prompt_cache_retention).toBe("24h");
});
});
@@ -1,7 +1,7 @@
import { Type } from "typebox";
import { afterEach, describe, expect, it, vi } from "vitest";
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
import { getModel } from "../src/compat.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { Model } from "../src/types.ts";
type CapturedHeaders = Headers | string[][] | Record<string, string | readonly string[]> | undefined;
@@ -53,11 +53,11 @@ async function captureOpenAIResponseHeaders(
const stream = streamOpenAIResponses(
model,
{
normalizeContext({
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ apiKey: "test-key", ...options },
}),
{ apiKey: "sk-test-key", ...options },
);
for await (const event of stream) {
@@ -85,12 +85,12 @@ describe("openai-responses provider defaults", () => {
const stream = streamOpenAIResponses(
model,
{
normalizeContext({
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
onPayload: (payload) => {
capturedPayload = payload;
},
@@ -119,7 +119,7 @@ describe("openai-responses provider defaults", () => {
const stream = streamOpenAIResponses(
getModel("openai", "gpt-5.4"),
{
normalizeContext({
messages: [
{
role: "user",
@@ -134,9 +134,9 @@ describe("openai-responses provider defaults", () => {
parameters: Type.Object({ value: Type.String() }),
},
],
},
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
toolChoice: "required",
onPayload: (payload) => {
capturedPayload = payload;
@@ -167,7 +167,7 @@ describe("openai-responses provider defaults", () => {
const stream = streamOpenAIResponses(
model,
{
normalizeContext({
messages: [{ role: "user", content: "Use a tool.", timestamp: Date.now() }],
tools: [
{
@@ -185,9 +185,9 @@ describe("openai-responses provider defaults", () => {
constrainedSampling: { type: "json_schema", strict: "prefer" },
},
],
},
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
onPayload: (payload) => {
capturedPayload = payload as CapturedResponsesPayload;
},
@@ -216,6 +216,8 @@ describe("openai-responses provider defaults", () => {
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-6-sol",
"gpt-6-luna",
] as const)("sends none reasoning effort for OpenAI %s when no reasoning is requested", async (modelId) => {
const model = getModel("openai", modelId);
let capturedPayload: unknown;
@@ -229,12 +231,12 @@ describe("openai-responses provider defaults", () => {
const stream = streamOpenAIResponses(
model,
{
normalizeContext({
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
onPayload: (payload) => {
capturedPayload = payload;
},
@@ -265,12 +267,12 @@ describe("openai-responses provider defaults", () => {
const stream = streamOpenAIResponses(
model,
{
normalizeContext({
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
onPayload: (payload) => {
capturedPayload = payload;
},
@@ -306,12 +308,12 @@ describe("openai-responses provider defaults", () => {
const stream = streamOpenAIResponses(
getModel("openai", "gpt-5.4"),
{
normalizeContext({
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
sessionId,
onPayload: (payload) => {
capturedPayload = payload as Pick<CapturedResponsesPayload, "prompt_cache_key">;
@@ -476,51 +478,60 @@ describe("openai-responses provider defaults", () => {
});
it.each([
["gpt-5.4", "priority", 2],
["gpt-5.5", "priority", 2.5],
["gpt-5.5", "flex", 0.5],
] as const)("applies %s %s service-tier cost multiplier", async (modelId, serviceTier, multiplier) => {
const model = getModel("openai", modelId);
const tokenCount = 100_000;
const tokenScale = tokenCount / 1_000_000;
const sse = `${[
`data: ${JSON.stringify({
type: "response.completed",
response: {
status: "completed",
service_tier: serviceTier,
usage: {
input_tokens: tokenCount,
output_tokens: tokenCount,
total_tokens: tokenCount * 2,
input_tokens_details: { cached_tokens: 0 },
["gpt-5.4", "priority", "priority", 2],
["gpt-5.5", "priority", "priority", 2.5],
["gpt-5.5", "flex", "flex", 0.5],
// GPT-6 models report Fast mode as "fast" even when "priority" is requested (#10034)
["gpt-6-luna", "priority", "fast", 2],
["gpt-6-luna", "fast", "fast", 2],
] as const)(
"applies %s cost multiplier for requested %s and returned %s service tier",
async (modelId, serviceTier, responseServiceTier, multiplier) => {
const model = getModel("openai", modelId);
const tokenCount = 100_000;
const tokenScale = tokenCount / 1_000_000;
const sse = `${[
`data: ${JSON.stringify({
type: "response.completed",
response: {
status: "completed",
service_tier: responseServiceTier,
usage: {
input_tokens: tokenCount,
output_tokens: tokenCount,
total_tokens: tokenCount * 2,
input_tokens_details: { cached_tokens: 0 },
},
},
},
})}`,
].join("\n\n")}\n\n`;
})}`,
].join("\n\n")}\n\n`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(sse, {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(sse, {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
);
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ apiKey: "test-key", serviceTier },
);
const stream = streamOpenAIResponses(
model,
normalizeContext({
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
}),
{ apiKey: "sk-test-key", serviceTier },
);
const result = await stream.result();
const result = await stream.result();
expect(result.usage.cost.input).toBe(model.cost.input * multiplier * tokenScale);
expect(result.usage.cost.output).toBe(model.cost.output * multiplier * tokenScale);
expect(result.usage.cost.total).toBe((model.cost.input + model.cost.output) * multiplier * tokenScale);
});
expect(result.usage.cost.input).toBeCloseTo(model.cost.input * multiplier * tokenScale, 12);
expect(result.usage.cost.output).toBeCloseTo(model.cost.output * multiplier * tokenScale, 12);
expect(result.usage.cost.total).toBeCloseTo(
(model.cost.input + model.cost.output) * multiplier * tokenScale,
12,
);
},
);
});
describe("openai-responses max_output_tokens compat", () => {
@@ -540,12 +551,12 @@ describe("openai-responses max_output_tokens compat", () => {
const stream = streamOpenAIResponses(
getModel("openai", "gpt-5.4"),
{
normalizeContext({
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
maxTokens: 1024,
onPayload: (payload) => {
capturedPayload = payload as { max_output_tokens?: number };
@@ -577,12 +588,12 @@ describe("openai-responses max_output_tokens compat", () => {
const stream = streamOpenAIResponses(
model,
{
normalizeContext({
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
}),
{
apiKey: "test-key",
apiKey: "sk-test-key",
maxTokens: 1024,
onPayload: (payload) => {
capturedPayload = payload as { max_output_tokens?: number };
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { convertResponsesMessages } from "../src/api/openai-responses-shared.ts";
import { getModel } from "../src/compat.ts";
import type { AssistantMessage, Context, ToolResultMessage, Usage } from "../src/types.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { AssistantMessage, ToolResultMessage, Usage } from "../src/types.ts";
const usage: Usage = {
input: 0,
@@ -38,13 +38,13 @@ describe("OpenAI Responses convertResponsesMessages empty tool result", () => {
timestamp: now,
};
const context: Context = {
const context = normalizeContext({
messages: [
{ role: "user", content: "Run the command", timestamp: now - 1 },
assistant,
buildEmptyToolResult("tool-1", now + 1),
],
};
});
const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"]));
const functionCallOutput = input.find((item) => item.type === "function_call_output") as
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { convertResponsesMessages } from "../src/api/openai-responses-shared.ts";
import { getModel } from "../src/compat.ts";
import type { AssistantMessage, Context, ToolResultMessage, Usage } from "../src/types.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { AssistantMessage, ToolResultMessage, Usage } from "../src/types.ts";
import { shortHash } from "../src/utils/hash.ts";
const COPILOT_RAW_TOOL_CALL_ID =
@@ -44,10 +44,10 @@ describe("OpenAI Responses foreign tool call ID normalization", () => {
isError: false,
timestamp: Date.now() - 1000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are concise.",
messages: [{ role: "user", content: "Use the tool.", timestamp: Date.now() - 3000 }, assistant, toolResult],
};
});
const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"]));
const functionCall = input.find((item) => item.type === "function_call");
@@ -1,8 +1,8 @@
import type { ResponseOutputMessage } from "openai/resources/responses/responses.js";
import { describe, expect, it } from "vitest";
import { convertResponsesMessages } from "../src/api/openai-responses-shared.ts";
import { getModel } from "../src/compat.ts";
import type { AssistantMessage, Context, Usage } from "../src/types.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { AssistantMessage, Usage } from "../src/types.ts";
const usage: Usage = {
input: 0,
@@ -19,7 +19,7 @@ describe("OpenAI Responses message ID conversion", () => {
const assistant: AssistantMessage = {
role: "assistant",
content: [
{ type: "thinking", thinking: "private reasoning" },
{ type: "text", text: "first answer" },
{ type: "text", text: "visible answer" },
],
api: "anthropic-messages",
@@ -29,10 +29,10 @@ describe("OpenAI Responses message ID conversion", () => {
stopReason: "stop",
timestamp: Date.now() - 1000,
};
const context: Context = {
const context = normalizeContext({
systemPrompt: "You are concise.",
messages: [{ role: "user", content: "hello", timestamp: Date.now() - 2000 }, assistant],
};
});
const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"]));
const messageIds = input
@@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest";
import { type OpenAIResponsesOptions, stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
import type { AssistantMessage, Message, Model, ToolResultMessage } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
interface Payload {
input: Array<Record<string, unknown>>;
reasoning?: { effort?: string };
}
const thinkingLevelMap = {
off: "none",
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: "max",
} as const;
function openAIModel(compat: Model<"openai-responses">["compat"] = { supportsMidConvoEffort: true }) {
return {
id: "gpt-6-luna",
name: "GPT-6 Luna",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
thinkingLevelMap,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 272000,
maxTokens: 128000,
compat,
} satisfies Model<"openai-responses">;
}
const user = (text: string): Message => ({ role: "user", content: text, timestamp: 0 });
const toolResult = (callId: string): ToolResultMessage => ({
role: "toolResult",
toolCallId: callId,
toolName: "lookup",
content: [{ type: "text", text: "result" }],
isError: false,
timestamp: 0,
});
const update = (effort: string) => ({ type: "configuration_update", reasoning: { effort } });
function responseEvents(responseId: string, withToolCall: boolean): object[] {
const item = withToolCall
? { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}", status: "completed" }
: {
type: "message",
id: `msg_${responseId}`,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "answer", annotations: [] }],
};
return [
{ type: "response.created", response: { id: responseId } },
{ type: "response.output_item.added", output_index: 0, item },
{ type: "response.output_item.done", output_index: 0, item },
{ type: "response.completed", response: { id: responseId, status: "completed" } },
];
}
async function runOpenAI(
messages: Message[],
reasoningEffort: OpenAIResponsesOptions["reasoningEffort"],
options: { apiKey?: string; model?: Model<"openai-responses">; withToolCall?: boolean } = {},
): Promise<{ payload: Payload; message: AssistantMessage }> {
let payload: Payload | undefined;
const fetchMock = async (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
payload = JSON.parse(String(init?.body)) as Payload;
const body = responseEvents("resp_1", options.withToolCall ?? false)
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join("");
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
};
const message = await streamOpenAIResponses(options.model ?? openAIModel(), normalizeContext({ messages }), {
apiKey: options.apiKey ?? "sk-test",
reasoningEffort,
fetch: fetchMock as typeof globalThis.fetch,
}).result();
if (!payload) throw new Error("Request payload was not captured");
expect(message.stopReason).not.toBe("error");
return { payload, message };
}
describe("OpenAI Responses configuration_update", () => {
it("keeps reasoning.effort constant and only appends input when the level changes", async () => {
const history: Message[] = [user("one")];
const first = await runOpenAI(history, "high");
history.push(first.message, user("two"));
const second = await runOpenAI(history, "high");
history.push(second.message, user("three"));
const third = await runOpenAI(history, "low");
expect(first.payload.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "one" }] },
update("high"),
]);
for (const { payload } of [first, second, third]) {
expect(payload.reasoning?.effort).toBe("medium");
}
expect(second.payload.input.slice(0, first.payload.input.length)).toEqual(first.payload.input);
expect(third.payload.input.slice(0, second.payload.input.length)).toEqual(second.payload.input);
expect(third.payload.input.filter((item) => item.type === "configuration_update")).toEqual([
update("high"),
update("high"),
update("low"),
]);
expect(third.payload.input.at(-1)).toEqual(update("low"));
expect([first, second, third].map(({ message }) => message.providerThinkingLevel)).toEqual([
"high",
"high",
"low",
]);
});
it("applies a level change made during a tool loop to the next reply", async () => {
const history: Message[] = [user("one")];
const toolCall = await runOpenAI(history, "high", { withToolCall: true });
history.push(toolCall.message, toolResult("call_1|fc_1"));
const afterTool = await runOpenAI(history, "low");
expect(afterTool.payload.input.slice(0, toolCall.payload.input.length)).toEqual(toolCall.payload.input);
expect(afterTool.payload.input.slice(-2)).toEqual([
expect.objectContaining({ type: "function_call_output", call_id: "call_1" }),
update("low"),
]);
expect(afterTool.message.providerThinkingLevel).toBe("low");
});
it("adds items only before replies recorded by this model", async () => {
const unstamped = (await runOpenAI([user("one")], "high", { model: openAIModel({}) })).message;
const otherProvider: AssistantMessage = { ...unstamped, provider: "opencode", providerThinkingLevel: "high" };
const otherModel: AssistantMessage = { ...unstamped, model: "gpt-6-sol", providerThinkingLevel: "high" };
const recorded: AssistantMessage = { ...unstamped, providerThinkingLevel: "high" };
const { payload } = await runOpenAI(
[
user("one"),
unstamped,
user("two"),
otherProvider,
user("three"),
otherModel,
user("four"),
recorded,
user("five"),
],
"low",
);
expect(payload.input.map((item) => item.type ?? item.role)).toEqual([
"user",
"message",
"user",
"message",
"user",
"message",
"user",
"configuration_update",
"message",
"user",
"configuration_update",
]);
expect(payload.input.filter((item) => item.type === "configuration_update")).toEqual([
update("high"),
update("low"),
]);
});
it.each([
{
name: "Sign in with ChatGPT",
apiKey: "chatgpt-access-token",
model: openAIModel(),
level: "low",
effort: "low",
},
{
name: "the compat flag turned off",
apiKey: "sk-test",
model: openAIModel({ supportsMidConvoEffort: false }),
level: "low",
effort: "low",
},
{ name: "thinking off", apiKey: "sk-test", model: openAIModel(), level: undefined, effort: "none" },
] as const)("sends the level as reasoning.effort with $name", async ({ apiKey, model, level, effort }) => {
const first = await runOpenAI([user("one")], "high", { apiKey, model });
const { payload, message } = await runOpenAI([user("one"), first.message, user("two")], level, {
apiKey,
model,
});
expect(payload.reasoning?.effort).toBe(effort);
expect(payload.input.some((item) => item.type === "configuration_update")).toBe(false);
expect(message.providerThinkingLevel).toBeUndefined();
});
});
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { convertResponsesMessages, processResponsesStream } from "../src/api/openai-responses-shared.ts";
import type { Api, AssistantMessage, Model, ToolCall } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const model: Model<"openai-responses"> = {
id: "gpt-5.4",
@@ -131,9 +132,11 @@ describe("OpenAI Responses tool-call namespaces", () => {
namespace: "dynamic_tools",
});
const replayed = convertResponsesMessages(model, { messages: [output] }, new Set(["openai"])).find(
(item) => item.type === "function_call",
);
const replayed = convertResponsesMessages(
model,
normalizeContext({ messages: [output] }),
new Set(["openai"]),
).find((item) => item.type === "function_call");
expect(replayed).toMatchObject({
type: "function_call",
id: "fc_test",
@@ -159,7 +162,7 @@ describe("OpenAI Responses tool-call namespaces", () => {
namespace: "dynamic_tools",
});
const replayed = convertResponsesMessages(model, { messages: [output] }, new Set(["openai"]), {
const replayed = convertResponsesMessages(model, normalizeContext({ messages: [output] }), new Set(["openai"]), {
grammarToolInputProperties,
}).find((item) => item.type === "custom_tool_call");
expect(replayed).toMatchObject({
@@ -192,7 +195,7 @@ describe("OpenAI Responses tool-call namespaces", () => {
);
const targetModels: Model<Api>[] = [
{ ...model, id: "gpt-5.2", name: "GPT-5.2" },
{ ...model, provider: "azure-openai-responses" },
{ ...model, provider: "azure" },
{
...model,
api: "openai-codex-responses",
@@ -203,9 +206,14 @@ describe("OpenAI Responses tool-call namespaces", () => {
];
for (const targetModel of targetModels) {
const replayed = convertResponsesMessages(targetModel, { messages: [output] }, new Set(["openai"]), {
grammarToolInputProperties: new Map([["query", "input"]]),
});
const replayed = convertResponsesMessages(
targetModel,
normalizeContext({ messages: [output] }),
new Set(["openai"]),
{
grammarToolInputProperties: new Map([["query", "input"]]),
},
);
const functionCall = replayed.find((item) => item.type === "function_call");
const customToolCall = replayed.find((item) => item.type === "custom_tool_call");
expect(functionCall).toBeDefined();
@@ -224,9 +232,11 @@ describe("OpenAI Responses tool-call namespaces", () => {
arguments: { value: "hello" },
});
const replayed = convertResponsesMessages(model, { messages: [output] }, new Set(["openai"])).find(
(item) => item.type === "function_call",
);
const replayed = convertResponsesMessages(
model,
normalizeContext({ messages: [output] }),
new Set(["openai"]),
).find((item) => item.type === "function_call");
expect(replayed).toBeDefined();
expect(replayed).not.toHaveProperty("namespace");
});
@@ -103,4 +103,30 @@ describe("openai responses partialJson cleanup", () => {
expect(toolCallEnd.toolCall).toBe(persistedToolCall);
expect("partialJson" in toolCallEnd.toolCall).toBe(false);
});
// #10897
it("marks malformed final arguments instead of keeping the parsed prefix", async () => {
const model: Model<"openai-responses"> = {
id: "gpt-5-mini",
name: "GPT-5 Mini",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const output = createOutput(model);
const stream = new AssistantMessageEventStream();
const argumentsJson = '{"path":"README.md","content":"updated","mode":.5}';
await processResponsesStream(createFunctionCallEvents(argumentsJson), output, stream, model);
const toolCall = output.content[0];
if (!toolCall || toolCall.type !== "toolCall") throw new Error("Expected toolCall block");
expect(toolCall.arguments).toEqual({});
expect(toolCall.argumentsError?.endsWith(`Received arguments:\n${argumentsJson}`)).toBe(true);
});
});
@@ -84,7 +84,7 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)(
// This tests the scenario where:
// 1. Model A (gpt-5-mini) generates reasoning + function_call
// 2. User switches to Model B (gpt-5.2-codex) - same provider, different model
// 3. transform-messages: isSameModel=false, thinking converted to text
// 3. transform-messages: isSameModel=false, thinking dropped
// 4. But tool call ID still has OpenAI pairing history (fc_xxx paired with rs_xxx)
// 5. Without fix: OpenAI returns 400 "function_call without required reasoning item"
// 6. With fix: tool calls/results converted to text, conversation continues
@@ -184,7 +184,7 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)(
// This tests cross-provider handoff:
// 1. Anthropic model generates thinking + function_call (toolu_xxx ID)
// 2. User switches to OpenAI Codex
// 3. transform-messages: isSameModel=false, thinking converted to text
// 3. transform-messages: isSameModel=false, thinking dropped
// 4. Tool call ID is Anthropic format (toolu_xxx), no OpenAI pairing history
// 5. Should work because foreign IDs have no pairing expectation
@@ -2,8 +2,9 @@ import type { ResponseStreamEvent } from "openai/resources/responses/responses.j
import { describe, expect, it, vi } from "vitest";
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
import { processResponsesStream } from "../src/api/openai-responses-shared.ts";
import type { AssistantMessage, AssistantMessageEvent, Context, Model } from "../src/types.ts";
import type { Api, AssistantMessage, AssistantMessageEvent, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
vi.mock("openai", () => {
async function* createMockResponsesStream(): AsyncIterable<ResponseStreamEvent> {
@@ -203,6 +204,42 @@ async function* createPhasedMessageEvents(
} as ResponseStreamEvent;
}
async function* createUnfinishedToolCallEvents(): AsyncIterable<ResponseStreamEvent> {
yield {
type: "response.output_item.added",
sequence_number: 0,
output_index: 0,
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "bash", arguments: "" },
} as ResponseStreamEvent;
yield {
type: "response.function_call_arguments.delta",
sequence_number: 1,
output_index: 0,
item_id: "fc_1",
delta: '{"command":"rm -rf /tmp/build',
} as ResponseStreamEvent;
yield {
type: "response.completed",
sequence_number: 2,
response: { id: "resp_unfinished", status: "completed" },
} as ResponseStreamEvent;
}
// llama.cpp omits output_index from every event and sends both done events after all deltas.
async function* createToolCallsWithoutOutputIndexEvents(): AsyncIterable<ResponseStreamEvent> {
const call = (n: string) => ({ type: "function_call", id: `fc_${n}`, call_id: `call_${n}`, name: "bash" });
const events = [
{ type: "response.output_item.added", item: { ...call("a"), arguments: "" } },
{ type: "response.function_call_arguments.delta", item_id: "fc_a", delta: '{"command":"echo a"}' },
{ type: "response.output_item.added", item: { ...call("b"), arguments: "" } },
{ type: "response.function_call_arguments.delta", item_id: "fc_b", delta: '{"command":"echo b"}' },
{ type: "response.output_item.done", item: { ...call("a"), arguments: '{"command":"echo a"}' } },
{ type: "response.output_item.done", item: { ...call("b"), arguments: '{"command":"echo b"}' } },
{ type: "response.completed", response: { id: "resp_no_output_index", status: "completed" } },
];
for (const event of events) yield event as unknown as ResponseStreamEvent;
}
describe("OpenAI Responses terminal event handling", () => {
it("rejects streams that end before a terminal response event", async () => {
const model = createModel();
@@ -214,13 +251,69 @@ describe("OpenAI Responses terminal event handling", () => {
);
});
it("emits an error final result when the wrapper stream ends before a terminal response event", async () => {
it("rejects completed streams whose tool call never received output_item.done", async () => {
const model = createModel();
const context: Context = {
await expect(
processResponsesStream(
createUnfinishedToolCallEvents(),
createOutput(model),
new AssistantMessageEventStream(),
model,
),
).rejects.toThrow("OpenAI Responses stream completed with an unfinished tool call: bash (call_1|fc_1)");
});
// https://github.com/earendil-works/pi/issues/9974
it("rejects parallel tool calls without output_index instead of running mixed-up calls", async () => {
const model = createModel();
await expect(
processResponsesStream(
createToolCallsWithoutOutputIndexEvents(),
createOutput(model),
new AssistantMessageEventStream(),
model,
),
).rejects.toThrow("OpenAI Responses stream completed with an unfinished tool call: bash (call_a|fc_a)");
});
it("forwards parsed provider stream events in order", async () => {
const model = createModel();
const context = normalizeContext({
systemPrompt: "",
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }],
tools: [],
};
});
const providerEvents: unknown[] = [];
const eventModels: Model<Api>[] = [];
const stream = streamOpenAIResponses(model, context, {
apiKey: "test",
onProviderStreamEvent: async (event, eventModel) => {
await Promise.resolve();
providerEvents.push(event);
eventModels.push(eventModel);
},
});
await stream.result();
expect(providerEvents).toHaveLength(3);
expect(providerEvents.map((event) => (event as ResponseStreamEvent).type)).toEqual([
"response.created",
"response.output_item.added",
"response.reasoning_text.delta",
]);
expect(eventModels).toEqual([model, model, model]);
});
it("emits an error final result when the wrapper stream ends before a terminal response event", async () => {
const model = createModel();
const context = normalizeContext({
systemPrompt: "",
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }],
tools: [],
});
const stream = streamOpenAIResponses(model, context, { apiKey: "test" });
const events: AssistantMessageEvent[] = [];
let initialStopReason: AssistantMessage["stopReason"] | undefined;
@@ -153,7 +153,7 @@ describe("Responses API tool result images", () => {
});
describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider (gpt-4o-mini)", () => {
const model = getModel("azure-openai-responses", "gpt-4o-mini");
const model = getModel("azure", "gpt-4o-mini");
const azureDeploymentName = resolveAzureDeploymentName(model.id);
const azureOptions = azureDeploymentName ? { azureDeploymentName } : {};
Loaded 100 of 1743 files, more files were not shown because too many files have changed in this diff. Show more