feat(coding-agent): merge origin/main into model runtime facade

This commit is contained in:
Mario Zechner
2026-07-15 12:25:36 +02:00
119 changed files with 4275 additions and 631 deletions
@@ -1,6 +1,6 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { complete, getModels, getProviders } from "../src/compat.ts";
import { type BuiltinProvider, complete, getModels, getProviders } from "../src/compat.ts";
import { getEnvApiKey } from "../src/env-api-keys.ts";
import type { Api, KnownProvider, Model, ProviderStreamOptions, Tool } from "../src/types.ts";
import { resolveApiKey } from "./oauth.ts";
@@ -19,7 +19,7 @@ const echoTool: Tool<typeof echoToolSchema> = {
interface AnthropicEagerE2ECase {
name: string;
provider: KnownProvider;
provider: BuiltinProvider;
model: Model<"anthropic-messages">;
apiKey: string | undefined;
}
@@ -31,7 +31,7 @@ function getE2EApiKey(provider: KnownProvider): string | undefined {
return getEnvApiKey(provider);
}
function getAnthropicMessagesModels(provider: KnownProvider): Model<"anthropic-messages">[] {
function getAnthropicMessagesModels(provider: BuiltinProvider): Model<"anthropic-messages">[] {
const models = getModels(provider) as Model<Api>[];
return models.filter((model) => model.api === "anthropic-messages") as Model<"anthropic-messages">[];
}
@@ -64,7 +64,7 @@ function getProbePriority(model: Model<"anthropic-messages">): number {
}
function selectOneCasePerProvider(cases: AnthropicEagerE2ECase[]): AnthropicEagerE2ECase[] {
const byProvider = new Map<KnownProvider, AnthropicEagerE2ECase[]>();
const byProvider = new Map<BuiltinProvider, AnthropicEagerE2ECase[]>();
for (const testCase of cases) {
const providerCases = byProvider.get(testCase.provider) ?? [];
providerCases.push(testCase);
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { complete, getModels, getProviders } from "../src/compat.ts";
import { type BuiltinProvider, complete, getModels, getProviders } from "../src/compat.ts";
import { getEnvApiKey } from "../src/env-api-keys.ts";
import type { Api, KnownProvider, Model, ProviderStreamOptions } from "../src/types.ts";
import { resolveApiKey } from "./oauth.ts";
@@ -8,7 +8,7 @@ const githubCopilotToken = await resolveApiKey("github-copilot");
interface AnthropicLongCacheRetentionE2ECase {
name: string;
provider: KnownProvider;
provider: BuiltinProvider;
model: Model<"anthropic-messages">;
apiKey: string | undefined;
}
@@ -20,7 +20,7 @@ function getE2EApiKey(provider: KnownProvider): string | undefined {
return getEnvApiKey(provider);
}
function getAnthropicMessagesModels(provider: KnownProvider): Model<"anthropic-messages">[] {
function getAnthropicMessagesModels(provider: BuiltinProvider): Model<"anthropic-messages">[] {
const models = getModels(provider) as Model<Api>[];
return models.filter((model) => model.api === "anthropic-messages") as Model<"anthropic-messages">[];
}
@@ -51,7 +51,7 @@ function getProbePriority(model: Model<"anthropic-messages">): number {
}
function selectOneCasePerProvider(cases: AnthropicLongCacheRetentionE2ECase[]): AnthropicLongCacheRetentionE2ECase[] {
const byProvider = new Map<KnownProvider, AnthropicLongCacheRetentionE2ECase[]>();
const byProvider = new Map<BuiltinProvider, AnthropicLongCacheRetentionE2ECase[]>();
for (const testCase of cases) {
const providerCases = byProvider.get(testCase.provider) ?? [];
providerCases.push(testCase);
@@ -224,6 +224,34 @@ describe("Anthropic raw SSE parsing", () => {
expect(result.errorMessage).toBe(explanation);
});
it("treats message_delta without usage as a no-op for usage accumulation", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const context: Context = {
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
};
const response = createSseResponse(
minimalAnthropicEvents.map((event) =>
event.event === "message_delta"
? {
event: "message_delta",
data: JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" } }),
}
: event,
),
);
const stream = streamAnthropic(model, context, {
client: createFakeAnthropicClient(response),
});
const result = await stream.result();
expect(result.stopReason).toBe("stop");
expect(result.errorMessage).toBeUndefined();
expect(result.content).toEqual([{ type: "text", text: "Hello" }]);
expect(result.usage.input).toBe(12);
expect(result.usage.totalTokens).toBe(12);
});
it("ignores unknown SSE events after message_stop", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const context: Context = {
@@ -0,0 +1,136 @@
import type { ResponseReasoningItem, ResponseStreamEvent } from "openai/resources/responses/responses.js";
import { describe, expect, it } from "vitest";
import { convertResponsesMessages, processResponsesStream } from "../src/api/openai-responses-shared.ts";
import type { AssistantMessage, Context, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function createModel(): Model<"azure-openai-responses"> {
return {
id: "gpt-5-mini",
name: "GPT-5 Mini",
api: "azure-openai-responses",
provider: "azure-openai-responses",
baseUrl: "https://example.invalid",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
}
function createOutput(model: Model<"azure-openai-responses">): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
async function* createEvents(
doneItem: ResponseReasoningItem,
completedItem: ResponseReasoningItem,
): AsyncIterable<ResponseStreamEvent> {
yield {
type: "response.output_item.added",
output_index: 0,
sequence_number: 0,
item: { type: "reasoning", id: doneItem.id, summary: [] },
} as ResponseStreamEvent;
yield {
type: "response.output_item.done",
output_index: 0,
sequence_number: 1,
item: doneItem,
} as ResponseStreamEvent;
yield {
type: "response.completed",
sequence_number: 2,
response: {
id: "resp_test",
status: "completed",
output: [completedItem],
},
} as ResponseStreamEvent;
}
function getReplayedReasoning(model: Model<"azure-openai-responses">, assistant: AssistantMessage) {
const context: Context = {
messages: [
{ role: "user", content: "first", timestamp: Date.now() - 1 },
assistant,
{ role: "user", content: "follow-up", timestamp: Date.now() },
],
};
const input = convertResponsesMessages(model, context, new Set(["azure-openai-responses"]));
return input.find((item) => item.type === "reasoning");
}
describe("Azure OpenAI Responses reasoning replay", () => {
it("preserves existing encrypted_content from output_item.done", async () => {
const model = createModel();
const output = createOutput(model);
const doneItem: ResponseReasoningItem = {
type: "reasoning",
id: "rs_done",
summary: [],
encrypted_content: "from-output-item-done",
};
const completedItem: ResponseReasoningItem = {
...doneItem,
encrypted_content: "from-response-completed",
};
await processResponsesStream(
createEvents(doneItem, completedItem),
output,
new AssistantMessageEventStream(),
model,
);
expect(getReplayedReasoning(model, output)).toMatchObject({
type: "reasoning",
id: "rs_done",
encrypted_content: "from-output-item-done",
});
});
it("fills encrypted_content when output_item.done omitted it", async () => {
const model = createModel();
const output = createOutput(model);
const doneItem: ResponseReasoningItem = {
type: "reasoning",
id: "rs_missing",
summary: [],
};
const completedItem: ResponseReasoningItem = {
...doneItem,
encrypted_content: "from-response-completed",
};
await processResponsesStream(
createEvents(doneItem, completedItem),
output,
new AssistantMessageEventStream(),
model,
);
expect(getReplayedReasoning(model, output)).toMatchObject({
type: "reasoning",
id: "rs_missing",
encrypted_content: "from-response-completed",
});
});
});
@@ -44,8 +44,8 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
};
});
import { type BedrockOptions, stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
import { getModel } from "../src/compat.ts";
import type { BedrockOptions } from "../src/api/bedrock-converse-stream.ts";
import { getModel, stream as streamBedrock } from "../src/compat.ts";
import type { Context, Model } from "../src/types.ts";
const context: Context = {
@@ -181,4 +181,28 @@ describe("bedrock endpoint resolution", () => {
expect(config.region).toBe("us-gov-west-1");
});
it("preserves ambient AWS auth for custom model IDs through compat dispatch", async () => {
process.env.AWS_PROFILE = "bedrock-profile";
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/example",
};
const config = await captureClientConfig(model);
expect(config.profile).toBe("bedrock-profile");
expect(config.token).toBeUndefined();
expect(config.authSchemePreference).toBeUndefined();
});
it("uses the generic API key option as a Bedrock bearer token", async () => {
const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const config = await captureClientConfig(model, { apiKey: "bedrock-api-key" });
expect(config.token).toEqual({ token: "bedrock-api-key" });
expect(config.authSchemePreference).toEqual(["httpBearerAuth"]);
});
});
+387
View File
@@ -0,0 +1,387 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
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 OpenAIPayload {
tools?: Array<{ name?: string; function?: { name: string } }>;
input?: Array<OpenAIToolSearchCall | OpenAIToolSearchOutput | { type?: string }>;
}
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,
};
}
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("loads an OpenAI Responses tool through client tool search", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const payload = await capturePayload<OpenAIPayload>(getModel("openai", "gpt-5.4"), 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 }]);
});
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("uses tool search only for supported Codex models", async () => {
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
const supported = await capturePayload<OpenAIPayload>(
getModel("openai-codex", "gpt-5.4"),
context,
makeCodexToken(),
);
const unsupported = await capturePayload<OpenAIPayload>(
getModel("openai-codex", "gpt-5.3-codex-spark"),
context,
makeCodexToken(),
);
expect(openAIToolNames(supported)).toEqual(["base_tool"]);
expect(supported.input?.some((item) => item.type === "tool_search_output")).toBe(true);
expect(openAIToolNames(unsupported)).toEqual(["base_tool", "late_tool"]);
expect(unsupported.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);
});
});
+120 -13
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import type { ApiKeyAuth, CredentialStore, OAuthAuth, ProviderAuth } from "../src/auth/types.ts";
import { calculateCost, createModels, hasApi, type Provider } from "../src/models.ts";
import { calculateCost, createModels, createProvider, hasApi, type Provider } from "../src/models.ts";
import { InMemoryModelsStore } from "../src/models-store.ts";
import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions, StreamOptions, Usage } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
@@ -56,7 +57,7 @@ function testProvider(input: {
models?: Model<Api>[];
auth?: ProviderAuth;
getModels?: () => readonly Model<Api>[];
refreshModels?: () => Promise<void>;
refreshModels?: Provider["refreshModels"];
calls?: ProviderCall[];
}): Provider {
const models = input.models ?? [testModel(input.id, "model-a")];
@@ -215,7 +216,7 @@ describe("Models runtime", () => {
expect(() => models.getProvider("broken")?.getModels()).toThrow("boom");
});
it("refresh() updates dynamic providers; single-provider refresh failures reject", async () => {
it("refresh() updates every configured dynamic provider and reports failures", async () => {
let list = [testModel("dyn", "before")];
let refreshes = 0;
const models = createModels();
@@ -232,17 +233,12 @@ describe("Models runtime", () => {
models.setProvider(testProvider({ id: "static", models: [testModel("static", "s1")] }));
expect(models.getModel("dyn", "before")).toBeDefined();
await models.refresh("dyn");
const first = await models.refresh();
expect(first.errors.size).toBe(0);
expect(refreshes).toBe(1);
expect(models.getModel("dyn", "after")).toBeDefined();
expect(models.getModel("dyn", "before")).toBeUndefined();
// static providers are no-ops; refresh-all is best-effort
await models.refresh("static");
await models.refresh();
expect(refreshes).toBe(2);
// single-provider refresh failures reject with ModelsError
models.setProvider(
testProvider({
id: "flaky",
@@ -251,9 +247,120 @@ describe("Models runtime", () => {
},
}),
);
await expect(models.refresh("flaky")).rejects.toMatchObject({ code: "model_source" });
// refresh-all swallows the same failure
await expect(models.refresh()).resolves.toBeUndefined();
const second = await models.refresh();
expect(refreshes).toBe(2);
expect(second.errors.get("flaky")?.message).toBe("fetch failed");
});
it("persists dynamic catalogs and restores them without network access", async () => {
const credentials = new InMemoryCredentialStore();
const modelsStore = new InMemoryModelsStore();
await credentials.modify("dynamic", async () => ({ type: "api_key", key: "key" }));
const createDynamicProvider = (fetchModels: (() => Promise<readonly Model<Api>[]>) | undefined) =>
createProvider({
id: "dynamic",
auth: { apiKey: envKeyAuth(undefined) },
models: [],
fetchModels: fetchModels ? () => fetchModels() : undefined,
api: {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
},
});
const online = createModels({ credentials, modelsStore });
online.setProvider(createDynamicProvider(async () => [testModel("dynamic", "fetched")]));
expect((await online.refresh()).errors.size).toBe(0);
expect(online.getModel("dynamic", "fetched")).toBeDefined();
const offline = createModels({ credentials, modelsStore });
offline.setProvider(
createDynamicProvider(async () => {
throw new Error("must not fetch");
}),
);
expect((await offline.refresh({ allowNetwork: false })).errors.size).toBe(0);
expect(offline.getModel("dynamic", "fetched")).toBeDefined();
});
it("passes effective API-key credentials and skips unconfigured providers", async () => {
let effectiveCredential: unknown;
let unconfiguredRefreshes = 0;
const models = createModels();
models.setProvider(
testProvider({
id: "configured",
auth: { apiKey: envKeyAuth("ambient-key") },
refreshModels: async (context) => {
effectiveCredential = context.credential;
},
}),
);
models.setProvider(
testProvider({
id: "unconfigured",
auth: { apiKey: envKeyAuth(undefined) },
refreshModels: async () => {
unconfiguredRefreshes++;
},
}),
);
await models.refresh();
expect(effectiveCredential).toEqual({ type: "api_key", key: "ambient-key", env: undefined });
expect(unconfiguredRefreshes).toBe(0);
});
it("refreshes expired OAuth before refreshing models", async () => {
const credentials = new InMemoryCredentialStore();
let modelRefreshCredential: unknown;
await credentials.modify("oauth-dynamic", async () => ({
type: "oauth",
access: "expired",
refresh: "refresh",
expires: 0,
}));
const models = createModels({ credentials });
models.setProvider(
testProvider({
id: "oauth-dynamic",
auth: {
oauth: testOAuth({
refresh: async () => ({
type: "oauth",
access: "fresh",
refresh: "rotated",
expires: Date.now() + 60_000,
}),
}),
},
refreshModels: async (context) => {
modelRefreshCredential = context.credential;
},
}),
);
expect((await models.refresh()).errors.size).toBe(0);
expect(modelRefreshCredential).toMatchObject({ type: "oauth", access: "fresh", refresh: "rotated" });
expect(await credentials.read("oauth-dynamic")).toMatchObject({ access: "fresh", refresh: "rotated" });
});
it("returns aborted state without reporting cancellation as a provider error", async () => {
const controller = new AbortController();
const models = createModels();
models.setProvider(
testProvider({
id: "dynamic",
refreshModels: async ({ signal }) => {
controller.abort();
if (signal?.aborted) return;
},
}),
);
const result = await models.refresh({ signal: controller.signal });
expect(result.aborted).toBe(true);
expect(result.errors.size).toBe(0);
});
it("resolves auth: stored credential owns the provider, ambient only when nothing stored", async () => {
@@ -2,6 +2,7 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { zstdDecompressSync } from "node:zlib";
import { Type } from "typebox";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
closeOpenAICodexWebSocketSessions,
@@ -700,6 +701,61 @@ describe("openai-codex streaming", () => {
expect(requestedReasoning).toEqual({ effort: "xhigh", summary: "auto" });
});
it("forwards required tool choice", async () => {
const token = mockToken();
const encoder = new TextEncoder();
const sse = buildSSEPayload({ status: "completed" });
let requestedToolChoice: unknown;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: string | URL, init?: RequestInit) => {
requestedToolChoice = decodeCodexRequestBody(init?.body)?.tool_choice;
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(sse));
controller.close();
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}),
);
const model: Model<"openai-codex-responses"> = {
id: "gpt-5.5",
name: "GPT-5.5",
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,
};
await streamOpenAICodexResponses(
model,
{
messages: [
{ role: "user", content: "Do not call ping. Respond with text instead.", timestamp: Date.now() },
],
tools: [
{
name: "ping",
description: "Ping",
parameters: Type.Object({ value: Type.String() }),
},
],
},
{ apiKey: token, transport: "sse", toolChoice: "required" },
).result();
expect(requestedToolChoice).toBe("required");
});
it.each(["gpt-5.3-codex", "gpt-5.4", "gpt-5.5"])("clamps %s minimal reasoning effort to low", async (modelId) => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
process.env.PI_CODING_AGENT_DIR = tempDir;
@@ -13,6 +13,7 @@ interface FakeOpenAIClientOptions {
interface CapturedCompletionsPayload {
prompt_cache_key?: string;
prompt_cache_retention?: "24h" | "in-memory" | null;
session_id?: string;
}
const mockState = vi.hoisted(() => ({
@@ -170,6 +171,63 @@ describe("openai-completions prompt caching", () => {
expect(headers["x-session-affinity"]).toBe("session-affinity");
});
it("uses OpenAI no-session format when configured", async () => {
const model = createModel({
compat: { sendSessionAffinityHeaders: true, sessionAffinityFormat: "openai-nosession" },
});
const { payload, headers } = await captureRequest({ sessionId: "session-nosession" }, model);
expect(payload?.session_id).toBeUndefined();
expect(payload?.prompt_cache_key).toBe("session-nosession");
expect(headers.session_id).toBeUndefined();
expect(headers["x-client-request-id"]).toBe("session-nosession");
expect(headers["x-session-affinity"]).toBe("session-nosession");
expect(headers["x-session-id"]).toBeUndefined();
});
it("uses OpenRouter session-affinity header when configured", async () => {
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
compat: { sendSessionAffinityHeaders: true, sessionAffinityFormat: "openrouter" },
});
const { payload, headers } = await captureRequest({ sessionId: "session-proxy" }, model);
expect(payload?.session_id).toBeUndefined();
expect(payload?.prompt_cache_key).toBeUndefined();
expect(headers["x-session-id"]).toBe("session-proxy");
expect(headers.session_id).toBeUndefined();
expect(headers["x-client-request-id"]).toBeUndefined();
expect(headers["x-session-affinity"]).toBeUndefined();
});
it("auto-detects OpenRouter session-affinity header for OpenRouter endpoints", async () => {
const model = createModel({
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
compat: { sendSessionAffinityHeaders: true },
});
const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model);
expect(payload?.session_id).toBeUndefined();
expect(payload?.prompt_cache_key).toBeUndefined();
expect(headers["x-session-id"]).toBe("session-openrouter");
expect(headers.session_id).toBeUndefined();
expect(headers["x-client-request-id"]).toBeUndefined();
expect(headers["x-session-affinity"]).toBeUndefined();
});
it("omits OpenRouter session-affinity data when disabled", async () => {
const model = createModel({
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
});
const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model);
expect(payload?.session_id).toBeUndefined();
expect(payload?.prompt_cache_key).toBeUndefined();
expect(headers["x-session-id"]).toBeUndefined();
});
it("omits session-affinity headers when cacheRetention is none", async () => {
const model = createModel({
baseUrl: "https://proxy.example.com/v1",
@@ -39,6 +39,7 @@ const compat = {
supportsStrictMode: true,
cacheControlFormat: undefined,
sendSessionAffinityHeaders: false,
sessionAffinityFormat: "openai",
supportsLongCacheRetention: true,
} satisfies Required<Omit<OpenAICompletionsCompat, "cacheControlFormat">> & {
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
@@ -1247,6 +1247,7 @@ describe("openai-completions tool_choice", () => {
zaiToolStream: false,
supportsStrictMode: true,
sendSessionAffinityHeaders: false,
sessionAffinityFormat: "openai",
supportsLongCacheRetention: true,
},
);
@@ -37,6 +37,7 @@ const compat: Required<OpenAICompletionsCompat> = {
supportsStrictMode: true,
cacheControlFormat: "anthropic",
sendSessionAffinityHeaders: false,
sessionAffinityFormat: "openai",
supportsLongCacheRetention: true,
};
@@ -1,3 +1,4 @@
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";
@@ -5,6 +6,11 @@ import type { Model } from "../src/types.ts";
type CapturedHeaders = Headers | string[][] | Record<string, string | readonly string[]> | undefined;
interface CapturedResponsesPayload {
prompt_cache_key?: string;
session_id?: string;
}
function getHeader(headers: CapturedHeaders, name: string): string | null {
if (!headers) return null;
if (headers instanceof Headers) return headers.get(name);
@@ -24,11 +30,20 @@ function getHeader(headers: CapturedHeaders, name: string): string | null {
async function captureOpenAIResponseHeaders(
options: Parameters<typeof streamOpenAIResponses>[2],
model: Model<"openai-responses"> = getModel("openai", "gpt-5.4"),
): Promise<{ sessionId: string | null; clientRequestId: string | null }> {
const captured = { sessionId: null as string | null, clientRequestId: null as string | null };
): Promise<{
sessionId: string | null;
clientRequestId: string | null;
xSessionId: string | null;
}> {
const captured = {
sessionId: null as string | null,
clientRequestId: null as string | null,
xSessionId: null as string | null,
};
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
captured.sessionId = getHeader(init?.headers, "session_id");
captured.clientRequestId = getHeader(init?.headers, "x-client-request-id");
captured.xSessionId = getHeader(init?.headers, "x-session-id");
return new Response("data: [DONE]\n\n", {
status: 200,
headers: { "content-type": "text/event-stream" },
@@ -91,6 +106,53 @@ describe("openai-responses provider defaults", () => {
});
});
it("forwards required tool choice", async () => {
let capturedPayload: unknown;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("data: [DONE]\n\n", {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
);
const stream = streamOpenAIResponses(
getModel("openai", "gpt-5.4"),
{
messages: [
{
role: "user",
content: "Do not call ping. Respond with text instead.",
timestamp: Date.now(),
},
],
tools: [
{
name: "ping",
description: "Ping",
parameters: Type.Object({ value: Type.String() }),
},
],
},
{
apiKey: "test-key",
toolChoice: "required",
onPayload: (payload) => {
capturedPayload = payload;
},
},
);
for await (const event of stream) {
if (event.type === "done" || event.type === "error") break;
}
expect(capturedPayload).toMatchObject({
tool_choice: "required",
tools: [expect.objectContaining({ name: "ping" })],
});
});
it.each([
"gpt-5.1",
"gpt-5.2",
@@ -176,12 +238,13 @@ describe("openai-responses provider defaults", () => {
it("sets cache-affinity headers for official OpenAI Responses requests with a sessionId", async () => {
const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" });
expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" });
expect(captured.sessionId).toBe("session-123");
expect(captured.clientRequestId).toBe("session-123");
});
it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => {
const sessionId = "x".repeat(67);
let capturedPayload: { prompt_cache_key?: string } | undefined;
let capturedPayload: Pick<CapturedResponsesPayload, "prompt_cache_key"> | undefined;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("data: [DONE]\n\n", {
status: 200,
@@ -199,7 +262,7 @@ describe("openai-responses provider defaults", () => {
apiKey: "test-key",
sessionId,
onPayload: (payload) => {
capturedPayload = payload as { prompt_cache_key?: string };
capturedPayload = payload as Pick<CapturedResponsesPayload, "prompt_cache_key">;
},
},
);
@@ -219,19 +282,125 @@ describe("openai-responses provider defaults", () => {
};
const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, proxyModel);
expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" });
expect(captured.sessionId).toBe("session-123");
expect(captured.clientRequestId).toBe("session-123");
});
it("can omit the session_id header while preserving other cache-affinity headers", async () => {
it("uses OpenRouter session-affinity header when configured", async () => {
const proxyModel: Model<"openai-responses"> = {
...getModel("openai", "gpt-5.4"),
provider: "proxy",
baseUrl: "https://proxy.example.com/v1",
compat: { sessionAffinityFormat: "openrouter" },
};
let capturedPayload: CapturedResponsesPayload | undefined;
const captured = await captureOpenAIResponseHeaders(
{
sessionId: "session-proxy",
onPayload: (payload) => {
capturedPayload = payload as CapturedResponsesPayload;
},
},
proxyModel,
);
expect(captured.sessionId).toBeNull();
expect(captured.clientRequestId).toBeNull();
expect(captured.xSessionId).toBe("session-proxy");
expect(capturedPayload?.session_id).toBeUndefined();
expect(capturedPayload?.prompt_cache_key).toBe("session-proxy");
});
it("auto-detects OpenRouter session-affinity header for OpenRouter Responses endpoints", async () => {
const openRouterModel: Model<"openai-responses"> = {
...getModel("openai", "gpt-5.4"),
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
};
let capturedPayload: CapturedResponsesPayload | undefined;
const captured = await captureOpenAIResponseHeaders(
{
sessionId: "session-openrouter",
onPayload: (payload) => {
capturedPayload = payload as CapturedResponsesPayload;
},
},
openRouterModel,
);
expect(captured.sessionId).toBeNull();
expect(captured.clientRequestId).toBeNull();
expect(captured.xSessionId).toBe("session-openrouter");
expect(capturedPayload?.session_id).toBeUndefined();
expect(capturedPayload?.prompt_cache_key).toBe("session-openrouter");
});
it("uses OpenAI no-session format when configured", async () => {
const proxyModel: Model<"openai-responses"> = {
...getModel("openai", "gpt-5.4"),
provider: "proxy",
baseUrl: "https://proxy.example.com/v1",
compat: { sessionAffinityFormat: "openai-nosession" },
};
let capturedPayload: CapturedResponsesPayload | undefined;
const captured = await captureOpenAIResponseHeaders(
{
sessionId: "session-proxy",
onPayload: (payload) => {
capturedPayload = payload as CapturedResponsesPayload;
},
},
proxyModel,
);
expect(captured.sessionId).toBeNull();
expect(captured.clientRequestId).toBe("session-proxy");
expect(captured.xSessionId).toBeNull();
expect(capturedPayload?.session_id).toBeUndefined();
expect(capturedPayload?.prompt_cache_key).toBe("session-proxy");
});
it("uses OpenAI no-session format for OpenCode Responses models", async () => {
const model = getModel("opencode", "gpt-5.4");
let capturedPayload: CapturedResponsesPayload | undefined;
const captured = await captureOpenAIResponseHeaders(
{
sessionId: "session-opencode",
onPayload: (payload) => {
capturedPayload = payload as CapturedResponsesPayload;
},
},
model,
);
expect(model.compat?.sessionAffinityFormat).toBe("openai-nosession");
expect(captured.sessionId).toBeNull();
expect(captured.clientRequestId).toBe("session-opencode");
expect(captured.xSessionId).toBeNull();
expect(capturedPayload?.prompt_cache_key).toBe("session-opencode");
});
it("can omit OpenAI session_id header while preserving other affinity data", async () => {
const proxyModel: Model<"openai-responses"> = {
...getModel("openai", "gpt-5.4"),
provider: "opencode",
baseUrl: "https://proxy.example.com/v1",
compat: { sendSessionIdHeader: false },
compat: { sessionAffinityFormat: "openai-nosession" },
};
const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, proxyModel);
let capturedPayload: CapturedResponsesPayload | undefined;
const captured = await captureOpenAIResponseHeaders(
{
sessionId: "session-123",
onPayload: (payload) => {
capturedPayload = payload as CapturedResponsesPayload;
},
},
proxyModel,
);
expect(captured).toEqual({ sessionId: null, clientRequestId: "session-123" });
expect(captured.sessionId).toBeNull();
expect(captured.clientRequestId).toBe("session-123");
expect(capturedPayload?.prompt_cache_key).toBe("session-123");
});
it("lets explicit headers override the default OpenAI cache-affinity headers", async () => {
@@ -243,22 +412,25 @@ describe("openai-responses provider defaults", () => {
},
});
expect(captured).toEqual({ sessionId: "override-session", clientRequestId: "override-request" });
expect(captured.sessionId).toBe("override-session");
expect(captured.clientRequestId).toBe("override-request");
});
it("omits OpenAI cache-affinity headers when cacheRetention is none", async () => {
const captured = await captureOpenAIResponseHeaders({ cacheRetention: "none", sessionId: "session-123" });
expect(captured).toEqual({ sessionId: null, clientRequestId: null });
expect(captured.sessionId).toBeNull();
expect(captured.clientRequestId).toBeNull();
});
async function streamServiceTierUsage(
modelId: "gpt-5.4" | "gpt-5.5",
serviceTier: "priority" | "flex",
inputTokens: number,
outputTokens: number,
) {
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",
@@ -266,9 +438,9 @@ describe("openai-responses provider defaults", () => {
status: "completed",
service_tier: serviceTier,
usage: {
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
input_tokens: tokenCount,
output_tokens: tokenCount,
total_tokens: tokenCount * 2,
input_tokens_details: { cached_tokens: 0 },
},
},
@@ -291,39 +463,10 @@ describe("openai-responses provider defaults", () => {
{ apiKey: "test-key", serviceTier },
);
return { model, result: await stream.result() };
}
const result = await stream.result();
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) => {
// Stay below the 272K long-context tier threshold so base rates apply.
const inputTokens = 200000;
const outputTokens = 100000;
const { model, result } = await streamServiceTierUsage(modelId, serviceTier, inputTokens, outputTokens);
const expectedInput = (model.cost.input / 1_000_000) * inputTokens * multiplier;
const expectedOutput = (model.cost.output / 1_000_000) * outputTokens * multiplier;
expect(result.usage.cost.input).toBe(expectedInput);
expect(result.usage.cost.output).toBe(expectedOutput);
expect(result.usage.cost.total).toBe(expectedInput + expectedOutput);
});
it("applies the service-tier multiplier on top of long-context tier pricing", async () => {
// Above the 272K input threshold the long-context tier rates apply, then the multiplier.
const inputTokens = 1000000;
const outputTokens = 100000;
const multiplier = 2;
const { model, result } = await streamServiceTierUsage("gpt-5.4", "priority", inputTokens, outputTokens);
const tier = model.cost.tiers?.find((entry) => inputTokens > entry.inputTokensAbove);
if (!tier) throw new Error("expected gpt-5.4 to define a long-context pricing tier");
const expectedInput = (tier.input / 1_000_000) * inputTokens * multiplier;
const expectedOutput = (tier.output / 1_000_000) * outputTokens * multiplier;
expect(result.usage.cost.input).toBe(expectedInput);
expect(result.usage.cost.output).toBe(expectedOutput);
expect(result.usage.cost.total).toBe(expectedInput + expectedOutput);
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);
});
});
+243
View File
@@ -0,0 +1,243 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { type PiMessagesOptions, stream, streamSimple } from "../src/api/pi-messages.ts";
import type { Api, AssistantMessageEvent, Context, Model } from "../src/types.ts";
type RecordedRequest = {
url: string;
headers: IncomingMessage["headers"];
body: unknown;
};
type ResponderOptions = {
status?: number;
headers?: Record<string, string>;
events?: unknown[];
rawBody?: string;
};
let server: Server | undefined;
afterEach(() => {
server?.close();
server = undefined;
});
async function startServer(options: ResponderOptions): Promise<{ baseUrl: string; requests: RecordedRequest[] }> {
const requests: RecordedRequest[] = [];
server = createServer((request: IncomingMessage, response: ServerResponse) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf-8");
requests.push({
url: request.url ?? "",
headers: request.headers,
body: raw ? JSON.parse(raw) : undefined,
});
if (options.status && options.status !== 200) {
response.statusCode = options.status;
response.setHeader("content-type", "application/json");
response.end(options.rawBody ?? "{}");
return;
}
response.statusCode = 200;
response.setHeader("content-type", "text/event-stream");
for (const [name, value] of Object.entries(options.headers ?? {})) {
response.setHeader(name, value);
}
for (const event of options.events ?? []) {
response.write(`data: ${JSON.stringify(event)}\n\n`);
}
response.end();
});
});
await new Promise<void>((resolve) => {
server!.listen(0, "127.0.0.1", () => resolve());
});
const address = server!.address() as AddressInfo;
return { baseUrl: `http://127.0.0.1:${address.port}/v1`, requests };
}
function createModel(baseUrl: string): Model<"pi-messages"> {
return {
id: "auto",
name: "Radius Auto",
api: "pi-messages",
provider: "radius",
baseUrl,
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 },
contextWindow: 128000,
maxTokens: 16384,
};
}
const context: Context = {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
const usage = {
input: 10,
output: 5,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 15,
cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0, total: 0.3 },
};
describe("pi-messages", () => {
it("streams text and tool calls and resolves the terminal message", async () => {
const { baseUrl, requests } = await startServer({
events: [
{ type: "start" },
{ type: "text_start", contentIndex: 0 },
{ type: "text_delta", contentIndex: 0, delta: "Hel" },
{ type: "text_delta", contentIndex: 0, delta: "lo" },
{ type: "text_end", contentIndex: 0, content: "Hello" },
{ type: "toolcall_start", contentIndex: 1, id: "call_1", toolName: "read" },
{ type: "toolcall_delta", contentIndex: 1, delta: '{"path":' },
{ type: "toolcall_delta", contentIndex: 1, delta: '"a.txt"}' },
{
type: "toolcall_end",
contentIndex: 1,
toolCall: { type: "toolCall", id: "call_1", name: "read", arguments: { path: "a.txt" } },
},
{ type: "done", reason: "toolUse", usage, responseId: "resp_1" },
],
});
const model = createModel(baseUrl);
const events: AssistantMessageEvent[] = [];
const eventStream = stream(model, context, {
apiKey: "test-key",
sessionId: "session-1",
toolChoice: "auto",
maxTokens: 100,
headers: { "x-custom": "1" },
});
for await (const event of eventStream) {
events.push(event);
}
const message = await eventStream.result();
expect(message.stopReason).toBe("toolUse");
expect(message.usage).toEqual(usage);
expect(message.responseId).toBe("resp_1");
expect(message.model).toBe("auto");
expect(message.provider).toBe("radius");
expect(message.content).toEqual([
{ type: "text", text: "Hello", textSignature: undefined },
{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "a.txt" } },
]);
expect(events.some((event) => event.type === "text_delta")).toBe(true);
expect(events.filter((event) => event.type === "toolcall_end")).toHaveLength(1);
expect(requests).toHaveLength(1);
const request = requests[0];
expect(request.url).toBe("/v1/messages");
expect(request.headers.authorization).toBe("Bearer test-key");
expect(request.headers["x-custom"]).toBe("1");
expect(request.body).toEqual({
model: "auto",
context,
options: { maxTokens: 100, sessionId: "session-1", toolChoice: "auto" },
});
});
it("appends debug=1 and reports response headers via onResponse", async () => {
const { baseUrl, requests } = await startServer({
headers: { "x-pi-gateway-upstream-provider": "anthropic" },
events: [{ type: "done", reason: "stop", usage }],
});
const model = createModel(baseUrl);
let observedHeaders: Record<string, string> | undefined;
const options: PiMessagesOptions = {
apiKey: "test-key",
debug: true,
onResponse: (response) => {
observedHeaders = response.headers;
},
};
const message = await streamSimple(model, context, options).result();
expect(message.stopReason).toBe("stop");
expect(requests[0].url).toBe("/v1/messages?debug=1");
expect(observedHeaders?.["x-pi-gateway-upstream-provider"]).toBe("anthropic");
});
it("surfaces backend error responses with diagnostics", async () => {
const { baseUrl } = await startServer({
status: 401,
rawBody: JSON.stringify({ error: { message: "Token expired", code: "unauthorized" } }),
});
const model = createModel(baseUrl);
const message = await stream(model, context, { apiKey: "stale" }).result();
expect(message.stopReason).toBe("error");
expect(message.errorMessage).toContain("401");
expect(message.errorMessage).toContain("Token expired");
expect(message.errorMessage).toContain("unauthorized");
expect(message.diagnostics?.[0]?.type).toBe("pi_messages_response_failure");
expect(message.diagnostics?.[0]?.details?.status).toBe(401);
});
it("propagates server-sent error events", async () => {
const { baseUrl } = await startServer({
events: [{ type: "start" }, { type: "error", reason: "error", usage, errorMessage: "Upstream failed" }],
});
const model = createModel(baseUrl);
const message = await stream(model, context, { apiKey: "test-key" }).result();
expect(message.stopReason).toBe("error");
expect(message.errorMessage).toBe("Upstream failed");
expect(message.usage).toEqual(usage);
});
it("errors when no API key is provided", async () => {
const model = createModel("http://127.0.0.1:1/v1");
const message = await stream(model, context).result();
expect(message.stopReason).toBe("error");
expect(message.errorMessage).toContain("No API key provided");
});
it("errors when the stream ends without a terminal event", async () => {
const { baseUrl } = await startServer({
events: [
{ type: "start" },
{ type: "text_start", contentIndex: 0 },
{ type: "text_delta", contentIndex: 0, delta: "partial" },
],
});
const model = createModel(baseUrl);
const message = await stream(model, context, { apiKey: "test-key" }).result();
expect(message.stopReason).toBe("error");
expect(message.errorMessage).toContain("stream ended without a terminal event");
});
});
describe("pi-messages api registration", () => {
it("is registered as a builtin api provider", async () => {
const { getApiProvider } = await import("../src/compat.ts");
expect(getApiProvider("pi-messages")).toBeDefined();
});
it("is a known api usable on models", () => {
const api: Api = "pi-messages";
expect(api).toBe("pi-messages");
});
});
+17 -5
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { envApiKeyAuth } from "../src/auth/helpers.ts";
import type { AuthContext, AuthEvent } from "../src/auth/types.ts";
import { createModels, createProvider } from "../src/models.ts";
import { InMemoryModelsStore } from "../src/models-store.ts";
import { builtinModels, builtinProviders } from "../src/providers/all.ts";
import { amazonBedrockProvider } from "../src/providers/amazon-bedrock.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
@@ -34,10 +35,11 @@ describe("builtin providers", () => {
const all = models.getModels();
expect(all.length).toBeGreaterThan(500);
// every provider lists at least one model and owns its models
// Static providers list models immediately; Radius is purely dynamic.
for (const provider of providers) {
const list = models.getModels(provider.id);
expect(list.length).toBeGreaterThan(0);
if (provider.id === "radius") expect(list).toEqual([]);
else expect(list.length).toBeGreaterThan(0);
expect(list.every((m) => m.provider === provider.id)).toBe(true);
}
});
@@ -345,7 +347,7 @@ describe("createProvider", () => {
id: "dynamic",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
refreshModels: async () => {
fetchModels: async () => {
fetches++;
await new Promise((resolve) => setTimeout(resolve, 5));
return [testModel("api-a", "listed")];
@@ -353,13 +355,23 @@ describe("createProvider", () => {
api: recordingStreams("a", []),
});
const store = new InMemoryModelsStore();
const refreshContext = {
credential: { type: "api_key" as const },
store: {
read: () => store.read("dynamic"),
write: (listed: readonly Model<Api>[]) => store.write("dynamic", listed),
delete: () => store.delete("dynamic"),
},
allowNetwork: true,
};
expect(provider.getModels()).toEqual([]);
await Promise.all([provider.refreshModels?.(), provider.refreshModels?.()]);
await Promise.all([provider.refreshModels?.(refreshContext), provider.refreshModels?.(refreshContext)]);
expect(fetches).toBe(1);
expect(provider.getModels().map((m) => m.id)).toEqual(["listed"]);
// a later refresh fetches again
await provider.refreshModels?.();
await provider.refreshModels?.(refreshContext);
expect(fetches).toBe(2);
});
});