feat(coding-agent): merge origin/main into model runtime facade
This commit is contained in:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
+197
-54
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user