Merge main into model-registry
This commit is contained in:
@@ -5,9 +5,8 @@ import type { Api, Model } from "../src/types.ts";
|
||||
const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [
|
||||
"anthropic/claude-fable-5",
|
||||
"anthropic/claude-opus-4-8",
|
||||
"opencode/claude-fable-5",
|
||||
"cloudflare-ai-gateway/claude-fable-5",
|
||||
"opencode/claude-opus-4-8",
|
||||
"vercel-ai-gateway/anthropic/claude-fable-5",
|
||||
"vercel-ai-gateway/anthropic/claude-opus-4.8",
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type Anthropic from "@anthropic-ai/sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Context } from "../src/types.ts";
|
||||
|
||||
function createSseResponse(events: Array<{ event: string; data: string }>): Response {
|
||||
const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n");
|
||||
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
function createFakeAnthropicClient(response: Response): Anthropic {
|
||||
return {
|
||||
messages: { create: () => ({ asResponse: async () => response }) },
|
||||
} as unknown as Anthropic;
|
||||
}
|
||||
|
||||
function eventsWithCacheCreation(
|
||||
cacheCreation: Record<string, number> | undefined,
|
||||
): Array<{ event: string; data: string }> {
|
||||
const startUsage: Record<string, unknown> = {
|
||||
input_tokens: 100,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 1_000_000,
|
||||
};
|
||||
if (cacheCreation) startUsage.cache_creation = cacheCreation;
|
||||
return [
|
||||
{
|
||||
event: "message_start",
|
||||
data: JSON.stringify({ type: "message_start", message: { id: "msg_test", usage: startUsage } }),
|
||||
},
|
||||
{
|
||||
event: "content_block_start",
|
||||
data: JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }),
|
||||
},
|
||||
{
|
||||
event: "content_block_delta",
|
||||
data: JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hi" } }),
|
||||
},
|
||||
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) },
|
||||
{
|
||||
event: "message_delta",
|
||||
data: JSON.stringify({
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 1_000_000,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ event: "message_stop", data: JSON.stringify({ type: "message_stop" }) },
|
||||
];
|
||||
}
|
||||
|
||||
// claude-opus-4-8: input 5, cacheWrite (5m) 6.25 per Mtok. 1h write = 2x input = 10.
|
||||
const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] };
|
||||
|
||||
describe("Anthropic 1h cache write cost", () => {
|
||||
it("prices the 1h portion at 2x input and the rest at the 5m rate", async () => {
|
||||
const model = getModel("anthropic", "claude-opus-4-8");
|
||||
const response = createSseResponse(
|
||||
eventsWithCacheCreation({ ephemeral_5m_input_tokens: 600_000, ephemeral_1h_input_tokens: 400_000 }),
|
||||
);
|
||||
const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result();
|
||||
|
||||
expect(result.usage.cacheWrite).toBe(1_000_000);
|
||||
expect(result.usage.cacheWrite1h).toBe(400_000);
|
||||
// 600k * 6.25/Mtok + 400k * 10/Mtok = 3.75 + 4.0 = 7.75
|
||||
expect(result.usage.cost.cacheWrite).toBeCloseTo(7.75, 10);
|
||||
});
|
||||
|
||||
it("falls back to the 5m rate when no breakdown is reported", async () => {
|
||||
const model = getModel("anthropic", "claude-opus-4-8");
|
||||
const response = createSseResponse(eventsWithCacheCreation(undefined));
|
||||
const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result();
|
||||
|
||||
expect(result.usage.cacheWrite).toBe(1_000_000);
|
||||
expect(result.usage.cacheWrite1h ?? 0).toBe(0);
|
||||
// 1M * 6.25/Mtok = 6.25
|
||||
expect(result.usage.cost.cacheWrite).toBeCloseTo(6.25, 10);
|
||||
});
|
||||
});
|
||||
@@ -166,6 +166,64 @@ describe("Anthropic raw SSE parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves refusal stop details from message_delta", async () => {
|
||||
const model = getModel("anthropic", "claude-fable-5");
|
||||
const context: Context = {
|
||||
messages: [{ role: "user", content: "blocked request", timestamp: Date.now() }],
|
||||
};
|
||||
const explanation =
|
||||
"This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, provide feedback, or request an exemption based on how you use Claude, visit our help center: https://support.claude.com/en/articles/14604842-real-time-cyber-safeguards-on-claude.";
|
||||
const response = createSseResponse([
|
||||
{
|
||||
event: "message_start",
|
||||
data: JSON.stringify({
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_01XFUDYJgAACzvnptvVoYEL",
|
||||
usage: {
|
||||
input_tokens: 412,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
event: "message_delta",
|
||||
data: JSON.stringify({
|
||||
type: "message_delta",
|
||||
delta: {
|
||||
stop_reason: "refusal",
|
||||
stop_details: {
|
||||
type: "refusal",
|
||||
category: "cyber",
|
||||
explanation,
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
input_tokens: 412,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
event: "message_stop",
|
||||
data: JSON.stringify({ type: "message_stop" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const stream = streamAnthropic(model, context, {
|
||||
client: createFakeAnthropicClient(response),
|
||||
});
|
||||
const result = await stream.result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe(explanation);
|
||||
});
|
||||
|
||||
it("ignores unknown SSE events after message_stop", async () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
const context: Context = {
|
||||
|
||||
@@ -44,7 +44,7 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
||||
import { type BedrockOptions, stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
|
||||
@@ -83,8 +83,12 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
async function captureClientConfig(model: Model<"bedrock-converse-stream">): Promise<Record<string, unknown>> {
|
||||
await streamBedrock(model, context, { cacheRetention: "none" }).result();
|
||||
async function captureClientConfig(
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
options: BedrockOptions = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
bedrockMock.constructorCalls.length = 0;
|
||||
await streamBedrock(model, context, { cacheRetention: "none", ...options }).result();
|
||||
expect(bedrockMock.constructorCalls).toHaveLength(1);
|
||||
return bedrockMock.constructorCalls[0];
|
||||
}
|
||||
@@ -115,6 +119,29 @@ describe("bedrock endpoint resolution", () => {
|
||||
expect(config.region).toBe("eu-central-1");
|
||||
});
|
||||
|
||||
it("handles missing regions for explicit, scoped, and ambient profiles", async () => {
|
||||
const model = getModel("amazon-bedrock", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||
|
||||
let config = await captureClientConfig(model, { profile: "bedrock-profile" });
|
||||
|
||||
expect(config.profile).toBe("bedrock-profile");
|
||||
expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com");
|
||||
expect(config.region).toBe("eu-central-1");
|
||||
|
||||
config = await captureClientConfig(model, { env: { AWS_PROFILE: "scoped-bedrock-profile" } });
|
||||
|
||||
expect(config.profile).toBe("scoped-bedrock-profile");
|
||||
expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com");
|
||||
expect(config.region).toBe("eu-central-1");
|
||||
|
||||
process.env.AWS_PROFILE = "ambient-bedrock-profile";
|
||||
config = await captureClientConfig(model);
|
||||
|
||||
expect(config.profile).toBe("ambient-bedrock-profile");
|
||||
expect(config.endpoint).toBeUndefined();
|
||||
expect(config.region).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still passes custom Bedrock endpoints through to the SDK client", async () => {
|
||||
process.env.AWS_REGION = "us-west-2";
|
||||
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
|
||||
import { getModel, stream } from "../src/compat.ts";
|
||||
import { MODELS } from "../src/models.generated.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
|
||||
class PayloadCaptured extends Error {
|
||||
@@ -12,6 +13,11 @@ class PayloadCaptured extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenAICompletionsCachePayload {
|
||||
prompt_cache_key?: string;
|
||||
prompt_cache_retention?: string;
|
||||
}
|
||||
|
||||
function stopAfterPayload<TPayload>(capture: (payload: TPayload) => void): (payload: unknown) => never {
|
||||
return (payload: unknown): never => {
|
||||
capture(payload as TPayload);
|
||||
@@ -454,5 +460,39 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||
expect(capturedPayload.prompt_cache_key).toBeUndefined();
|
||||
expect(capturedPayload.prompt_cache_retention).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
MODELS.opencode["deepseek-v4-flash"],
|
||||
MODELS.opencode["deepseek-v4-pro"],
|
||||
MODELS.opencode["kimi-k2.5"],
|
||||
MODELS.opencode["kimi-k2.6"],
|
||||
MODELS.opencode["minimax-m2.7"],
|
||||
MODELS["opencode-go"]["kimi-k2.6"],
|
||||
] as const)("should omit long cache retention for $provider/$id", async (metadata) => {
|
||||
const model = metadata as Model<"openai-completions">;
|
||||
let capturedPayload: OpenAICompletionsCachePayload | undefined;
|
||||
|
||||
try {
|
||||
const s = streamOpenAICompletions(model, context, {
|
||||
apiKey: "fake-key",
|
||||
cacheRetention: "long",
|
||||
sessionId: "session-opencode-long-cache-unsupported",
|
||||
onPayload: stopAfterPayload<OpenAICompletionsCachePayload>((payload) => {
|
||||
capturedPayload = payload;
|
||||
}),
|
||||
});
|
||||
|
||||
for await (const event of s) {
|
||||
if (event.type === "error") break;
|
||||
}
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
expect(model.compat?.supportsLongCacheRetention).toBe(false);
|
||||
expect(capturedPayload).toBeDefined();
|
||||
expect(capturedPayload?.prompt_cache_key).toBeUndefined();
|
||||
expect(capturedPayload?.prompt_cache_retention).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import { getSupportedThinkingLevels } from "../src/models.ts";
|
||||
import type { Context } from "../src/types.ts";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
@@ -54,6 +55,16 @@ describe("Copilot Claude via Anthropic Messages", () => {
|
||||
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
|
||||
};
|
||||
|
||||
it("applies Copilot-specific adaptive thinking effort overrides", () => {
|
||||
const opus47 = getModel("github-copilot", "claude-opus-4.7");
|
||||
expect(opus47.thinkingLevelMap).toMatchObject({ minimal: "low", xhigh: "xhigh" });
|
||||
expect(getSupportedThinkingLevels(opus47)).toContain("xhigh");
|
||||
|
||||
const sonnet46 = getModel("github-copilot", "claude-sonnet-4.6");
|
||||
expect(sonnet46.thinkingLevelMap).toMatchObject({ minimal: "low", xhigh: "max" });
|
||||
expect(getSupportedThinkingLevels(sonnet46)).toContain("xhigh");
|
||||
});
|
||||
|
||||
it("uses Bearer auth, Copilot headers, and valid Anthropic Messages payload", async () => {
|
||||
const model = getModel("github-copilot", "claude-sonnet-4.6");
|
||||
expect(model.api).toBe("anthropic-messages");
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { loginGitHubCopilot } from "../src/utils/oauth/github-copilot.ts";
|
||||
import { getModels } from "../src/compat.ts";
|
||||
import {
|
||||
githubCopilotOAuthProvider,
|
||||
loginGitHubCopilot,
|
||||
refreshGitHubCopilotToken,
|
||||
} from "../src/utils/oauth/github-copilot.ts";
|
||||
|
||||
function jsonResponse(body: unknown, status: number = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
@@ -29,6 +34,57 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("filters models to the authenticated account picker catalog", async () => {
|
||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
||||
const url = getUrl(input);
|
||||
|
||||
if (url.includes("/copilot_internal/v2/token")) {
|
||||
return jsonResponse({
|
||||
token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
|
||||
expires_at: 9999999999,
|
||||
});
|
||||
}
|
||||
|
||||
if (url === "https://api.individual.githubcopilot.com/models") {
|
||||
expect(init?.headers).toMatchObject({
|
||||
Authorization: "Bearer tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
|
||||
});
|
||||
return jsonResponse({
|
||||
data: [
|
||||
{
|
||||
id: "gpt-4.1",
|
||||
model_picker_enabled: true,
|
||||
capabilities: { supports: { tool_calls: true } },
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4.7",
|
||||
model_picker_enabled: true,
|
||||
policy: { state: "disabled" },
|
||||
capabilities: { supports: { tool_calls: true } },
|
||||
},
|
||||
{
|
||||
id: "gpt-5.4-nano",
|
||||
model_picker_enabled: false,
|
||||
capabilities: { supports: { tool_calls: true } },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const credentials = await refreshGitHubCopilotToken("ghu_refresh_token");
|
||||
expect(credentials.availableModelIds).toEqual(["gpt-4.1"]);
|
||||
|
||||
const modifiedModels = githubCopilotOAuthProvider.modifyModels?.(getModels("github-copilot"), credentials) ?? [];
|
||||
expect(modifiedModels.filter((model) => model.provider === "github-copilot").map((model) => model.id)).toEqual([
|
||||
"gpt-4.1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports device-code details through onDeviceCode", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
|
||||
@@ -57,6 +113,10 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (url.endsWith("/models")) {
|
||||
return jsonResponse({ data: [] });
|
||||
}
|
||||
|
||||
if (url.includes("/models/") && url.endsWith("/policy")) {
|
||||
return new Response("", { status: 200 });
|
||||
}
|
||||
@@ -146,6 +206,10 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (url.endsWith("/models")) {
|
||||
return jsonResponse({ data: [] });
|
||||
}
|
||||
|
||||
if (url.includes("/models/") && url.endsWith("/policy")) {
|
||||
return new Response("", { status: 200 });
|
||||
}
|
||||
@@ -231,6 +295,10 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (url.endsWith("/models")) {
|
||||
return jsonResponse({ data: [] });
|
||||
}
|
||||
|
||||
if (url.includes("/models/") && url.endsWith("/policy")) {
|
||||
return new Response("", { status: 200 });
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Context, Model, SimpleStreamOptions } from "../src/types.ts";
|
||||
interface MistralPayload {
|
||||
promptMode?: "reasoning";
|
||||
reasoningEffort?: "none" | "high";
|
||||
promptCacheKey?: string;
|
||||
}
|
||||
|
||||
function makeContext(): Context {
|
||||
@@ -76,4 +77,21 @@ describe("Mistral reasoning mode selection", () => {
|
||||
expect(payload.reasoningEffort).toBeUndefined();
|
||||
expect(payload.promptMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the session id as prompt cache key", async () => {
|
||||
const payload = await capturePayload(getModel("mistral", "mistral-large-latest"), {
|
||||
sessionId: "session-123",
|
||||
});
|
||||
|
||||
expect(payload.promptCacheKey).toBe("session-123");
|
||||
});
|
||||
|
||||
it("omits prompt cache key when cache retention is disabled", async () => {
|
||||
const payload = await capturePayload(getModel("mistral", "mistral-large-latest"), {
|
||||
sessionId: "session-123",
|
||||
cacheRetention: "none",
|
||||
});
|
||||
|
||||
expect(payload.promptCacheKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,17 @@ describe("node HTTP proxy resolution", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers scoped proxy env aliases before process env aliases", () => {
|
||||
resetProxyEnv();
|
||||
process.env.https_proxy = "http://process-proxy.example:8080";
|
||||
|
||||
expect(
|
||||
resolveHttpProxyUrlForTarget("https://bedrock-runtime.us-east-1.amazonaws.com", {
|
||||
HTTPS_PROXY: "http://scoped-proxy.example:8080",
|
||||
})?.toString(),
|
||||
).toBe("http://scoped-proxy.example:8080/");
|
||||
});
|
||||
|
||||
it("rejects SOCKS and PAC proxy URLs explicitly", () => {
|
||||
resetProxyEnv();
|
||||
process.env.HTTPS_PROXY = "socks5://proxy.example:1080";
|
||||
|
||||
@@ -361,13 +361,21 @@ describe("openai-codex streaming", () => {
|
||||
apiKey: token,
|
||||
transport: "sse",
|
||||
}).result();
|
||||
let settled = false;
|
||||
const observedResultPromise = resultPromise.then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
const result = await resultPromise;
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
const result = await observedResultPromise;
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe("Codex SSE response headers timed out after 10000ms");
|
||||
expect(result.errorMessage).toBe("Codex SSE response headers timed out after 20000ms");
|
||||
});
|
||||
|
||||
it("aborts SSE body reads after response headers arrive", async () => {
|
||||
|
||||
@@ -162,6 +162,31 @@ describe("openai-completions empty tools handling", () => {
|
||||
expect(clientOptions.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer test");
|
||||
});
|
||||
|
||||
it("uses provider env before process.env for Cloudflare AI Gateway base URL", async () => {
|
||||
process.env.CLOUDFLARE_ACCOUNT_ID = "process-account";
|
||||
process.env.CLOUDFLARE_GATEWAY_ID = "process-gateway";
|
||||
const model = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6")!;
|
||||
|
||||
await streamSimple(
|
||||
model,
|
||||
{
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
env: {
|
||||
CLOUDFLARE_ACCOUNT_ID: "provider-account",
|
||||
CLOUDFLARE_GATEWAY_ID: "provider-gateway",
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
const clientOptions = mockState.lastClientOptions as { baseURL?: string };
|
||||
expect(clientOptions.baseURL).toBe(
|
||||
"https://gateway.ai.cloudflare.com/v1/provider-account/provider-gateway/compat",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves inline upstream Authorization for Cloudflare AI Gateway BYOK requests", async () => {
|
||||
process.env.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id";
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Type } from "typebox";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import type { AssistantMessage, Model, Tool } from "../src/types.ts";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
chunkSets: [] as unknown[][],
|
||||
payloads: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
chat = {
|
||||
completions: {
|
||||
create: (payload: unknown) => {
|
||||
mockState.payloads.push(payload);
|
||||
const chunks = mockState.chunkSets.shift() ?? [];
|
||||
const stream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const chunk of chunks) {
|
||||
yield chunk;
|
||||
}
|
||||
},
|
||||
};
|
||||
const result = Promise.resolve(stream) as Promise<typeof stream> & {
|
||||
withResponse: () => Promise<{ data: typeof stream; response: { status: number; headers: Headers } }>;
|
||||
};
|
||||
result.withResponse = async () => ({
|
||||
data: stream,
|
||||
response: { status: 200, headers: new Headers() },
|
||||
});
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { default: FakeOpenAI };
|
||||
});
|
||||
|
||||
const reasoningDetail = { type: "reasoning.encrypted", id: "call_1", data: "encrypted-signature" };
|
||||
const readTool: Tool = {
|
||||
name: "read",
|
||||
description: "Read a file",
|
||||
parameters: Type.Object({ path: Type.String() }),
|
||||
};
|
||||
|
||||
function model(): Model<"openai-completions"> {
|
||||
return {
|
||||
id: "google/gemini-test",
|
||||
name: "Gemini Test",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 100_000,
|
||||
maxTokens: 4096,
|
||||
};
|
||||
}
|
||||
|
||||
function chunk(delta: Record<string, unknown>, finishReason: string | null = null): unknown {
|
||||
return {
|
||||
id: "chatcmpl-test",
|
||||
model: "google/gemini-test",
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallChunk(): unknown {
|
||||
return chunk({
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "read", arguments: '{"path":"README.md"}' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function runOpenAICompletionsStream(messages: AssistantMessage[] = []): Promise<AssistantMessage> {
|
||||
return await streamOpenAICompletions(model(), { messages, tools: [readTool] }, { apiKey: "test" }).result();
|
||||
}
|
||||
|
||||
function getAssistantPayload(payload: unknown): { reasoning_details?: unknown } | undefined {
|
||||
const messages = (payload as { messages?: Array<{ role?: string; reasoning_details?: unknown }> }).messages ?? [];
|
||||
return messages.find((message) => message.role === "assistant");
|
||||
}
|
||||
|
||||
describe("openai-completions reasoning_details streaming", () => {
|
||||
beforeEach(() => {
|
||||
mockState.chunkSets = [];
|
||||
mockState.payloads = [];
|
||||
});
|
||||
|
||||
it("preserves reasoning_details that arrive before their matching tool call", async () => {
|
||||
mockState.chunkSets = [
|
||||
[chunk({ reasoning_details: [reasoningDetail] }), toolCallChunk(), chunk({}, "tool_calls")],
|
||||
[chunk({ content: "ok" }), chunk({}, "stop")],
|
||||
];
|
||||
|
||||
const assistantMessage = await runOpenAICompletionsStream();
|
||||
const toolCall = assistantMessage.content.find((block) => block.type === "toolCall");
|
||||
expect(toolCall).toMatchObject({
|
||||
type: "toolCall",
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
arguments: { path: "README.md" },
|
||||
thoughtSignature: JSON.stringify(reasoningDetail),
|
||||
});
|
||||
|
||||
await runOpenAICompletionsStream([assistantMessage]);
|
||||
|
||||
expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual([reasoningDetail]);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ const compat = {
|
||||
thinkingFormat: "openai",
|
||||
openRouterRouting: {},
|
||||
vercelGatewayRouting: {},
|
||||
chatTemplateKwargs: {},
|
||||
zaiToolStream: false,
|
||||
supportsStrictMode: true,
|
||||
cacheControlFormat: undefined,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Type } from "typebox";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { convertMessages } from "../src/api/openai-completions.ts";
|
||||
import { getModel, stream, streamSimple } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Model, Tool, ToolResultMessage } from "../src/types.ts";
|
||||
import type { AssistantMessage, Model, SimpleStreamOptions, Tool, ToolResultMessage } from "../src/types.ts";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
lastParams: undefined as unknown,
|
||||
@@ -63,6 +63,46 @@ vi.mock("openai", () => {
|
||||
return { default: FakeOpenAI };
|
||||
});
|
||||
|
||||
const localOpenAICompletionsModel = {
|
||||
api: "openai-completions",
|
||||
provider: "local-vllm",
|
||||
baseUrl: "http://localhost:8000/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Omit<Model<"openai-completions">, "id" | "name" | "compat">;
|
||||
|
||||
type CapturedParams = {
|
||||
chat_template_kwargs?: Record<string, unknown>;
|
||||
thinking?: unknown;
|
||||
reasoning_effort?: string;
|
||||
};
|
||||
|
||||
async function captureSimpleParams(
|
||||
model: Model<"openai-completions">,
|
||||
reasoning?: SimpleStreamOptions["reasoning"],
|
||||
): Promise<CapturedParams> {
|
||||
let payload: unknown;
|
||||
|
||||
await streamSimple(
|
||||
model,
|
||||
{
|
||||
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
reasoning,
|
||||
onPayload: (params: unknown) => {
|
||||
payload = params;
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
return (payload ?? mockState.lastParams) as CapturedParams;
|
||||
}
|
||||
|
||||
describe("openai-completions tool_choice", () => {
|
||||
beforeEach(() => {
|
||||
mockState.lastParams = undefined;
|
||||
@@ -256,6 +296,86 @@ describe("openai-completions tool_choice", () => {
|
||||
expect(getModel("zai", "glm-4.5-air")?.compat?.zaiToolStream).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores z.ai GLM-5.2 effort metadata", () => {
|
||||
for (const provider of ["zai", "zai-coding-cn"] as const) {
|
||||
const model = getModel(provider, "glm-5.2")!;
|
||||
expect(model.compat?.supportsReasoningEffort).toBe(true);
|
||||
expect(model.thinkingLevelMap).toEqual({
|
||||
minimal: null,
|
||||
low: "high",
|
||||
medium: "high",
|
||||
high: "high",
|
||||
xhigh: "max",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("maps z.ai GLM-5.2 thinking levels to reasoning_effort", async () => {
|
||||
const model = getModel("zai", "glm-5.2")!;
|
||||
const cases = [
|
||||
{ reasoning: "low", effort: "high" },
|
||||
{ reasoning: "medium", effort: "high" },
|
||||
{ reasoning: "high", effort: "high" },
|
||||
{ reasoning: "xhigh", effort: "max" },
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
let payload: unknown;
|
||||
|
||||
await streamSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hi",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
reasoning: testCase.reasoning,
|
||||
onPayload: (params: unknown) => {
|
||||
payload = params;
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string };
|
||||
expect(params.thinking).toEqual({ type: "enabled" });
|
||||
expect(params.reasoning_effort).toBe(testCase.effort);
|
||||
}
|
||||
});
|
||||
|
||||
it("omits z.ai GLM-5.2 reasoning_effort when thinking is off", async () => {
|
||||
const model = getModel("zai", "glm-5.2")!;
|
||||
let payload: unknown;
|
||||
|
||||
await streamSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hi",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
onPayload: (params: unknown) => {
|
||||
payload = params;
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string };
|
||||
expect(params.thinking).toEqual({ type: "disabled" });
|
||||
expect(params.reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits tool_stream for unsupported z.ai models", async () => {
|
||||
const model = getModel("zai", "glm-4.5-air")!;
|
||||
const tools: Tool[] = [
|
||||
@@ -1063,6 +1183,7 @@ describe("openai-completions tool_choice", () => {
|
||||
thinkingFormat: "openai",
|
||||
openRouterRouting: {},
|
||||
vercelGatewayRouting: {},
|
||||
chatTemplateKwargs: {},
|
||||
zaiToolStream: false,
|
||||
supportsStrictMode: true,
|
||||
sendSessionAffinityHeaders: false,
|
||||
@@ -1119,6 +1240,54 @@ describe("openai-completions tool_choice", () => {
|
||||
expect(params.reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits disabled thinking for Moonshot Kimi K2.7 Code models", async () => {
|
||||
const cases = [getModel("moonshotai", "kimi-k2.7-code"), getModel("moonshotai-cn", "kimi-k2.7-code")];
|
||||
|
||||
for (const model of cases) {
|
||||
expect(model).toBeDefined();
|
||||
let payload: unknown;
|
||||
|
||||
await streamSimple(
|
||||
model!,
|
||||
{
|
||||
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
onPayload: (params: unknown) => {
|
||||
payload = params;
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string };
|
||||
expect(params.thinking).toBeUndefined();
|
||||
expect(params.reasoning_effort).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps disabled thinking for Moonshot Kimi K2.6 when thinking is off", async () => {
|
||||
const model = getModel("moonshotai-cn", "kimi-k2.6")!;
|
||||
let payload: unknown;
|
||||
|
||||
await streamSimple(
|
||||
model,
|
||||
{
|
||||
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
onPayload: (params: unknown) => {
|
||||
payload = params;
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string };
|
||||
expect(params.thinking).toEqual({ type: "disabled" });
|
||||
expect(params.reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends max_tokens for OpenCode completions models", async () => {
|
||||
const cases = [getModel("opencode-go", "kimi-k2.6")!, getModel("opencode", "grok-build-0.1")!] as const;
|
||||
|
||||
@@ -1322,6 +1491,77 @@ describe("openai-completions tool_choice", () => {
|
||||
expect(params.reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses configurable chat template boolean thinking kwargs", async () => {
|
||||
const model = {
|
||||
...localOpenAICompletionsModel,
|
||||
id: "deepseek-ai/DeepSeek-V3.1",
|
||||
name: "DeepSeek V3.1 via vLLM",
|
||||
compat: {
|
||||
thinkingFormat: "chat-template",
|
||||
supportsReasoningEffort: false,
|
||||
chatTemplateKwargs: { thinking: { $var: "thinking.enabled" } },
|
||||
},
|
||||
} satisfies Model<"openai-completions">;
|
||||
|
||||
for (const testCase of [
|
||||
{ reasoning: "high" as const, expected: true },
|
||||
{ reasoning: undefined, expected: false },
|
||||
]) {
|
||||
const params = await captureSimpleParams(model, testCase.reasoning);
|
||||
|
||||
expect(params.chat_template_kwargs).toEqual({ thinking: testCase.expected });
|
||||
expect(params.thinking).toBeUndefined();
|
||||
expect(params.reasoning_effort).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses qwen chat template thinking kwargs", async () => {
|
||||
const model = {
|
||||
...localOpenAICompletionsModel,
|
||||
id: "Qwen/Qwen3-Coder",
|
||||
name: "Qwen3 Coder via vLLM",
|
||||
compat: {
|
||||
thinkingFormat: "qwen-chat-template",
|
||||
supportsReasoningEffort: false,
|
||||
},
|
||||
} satisfies Model<"openai-completions">;
|
||||
|
||||
for (const testCase of [
|
||||
{ reasoning: "high" as const, expected: true },
|
||||
{ reasoning: undefined, expected: false },
|
||||
]) {
|
||||
const params = await captureSimpleParams(model, testCase.reasoning);
|
||||
|
||||
expect(params.chat_template_kwargs).toEqual({
|
||||
enable_thinking: testCase.expected,
|
||||
preserve_thinking: true,
|
||||
});
|
||||
expect(params.reasoning_effort).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses configurable chat template effort kwargs with static kwargs", async () => {
|
||||
const model = {
|
||||
...localOpenAICompletionsModel,
|
||||
id: "unsloth/gpt-oss-120b-GGUF",
|
||||
name: "GPT OSS via vLLM",
|
||||
thinkingLevelMap: { xhigh: "max" },
|
||||
compat: {
|
||||
thinkingFormat: "chat-template",
|
||||
supportsReasoningEffort: false,
|
||||
chatTemplateKwargs: {
|
||||
preserve_thinking: true,
|
||||
reasoning_effort: { $var: "thinking.effort", omitWhenOff: true },
|
||||
},
|
||||
},
|
||||
} satisfies Model<"openai-completions">;
|
||||
|
||||
const params = await captureSimpleParams(model, "xhigh");
|
||||
|
||||
expect(params.chat_template_kwargs).toEqual({ preserve_thinking: true, reasoning_effort: "max" });
|
||||
expect(params.reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses Ant Ling compatibility metadata", async () => {
|
||||
const model = getModel("ant-ling", "Ring-2.6-1T")!;
|
||||
let payload: unknown;
|
||||
|
||||
@@ -32,6 +32,7 @@ const compat: Required<OpenAICompletionsCompat> = {
|
||||
thinkingFormat: "openai",
|
||||
openRouterRouting: {},
|
||||
vercelGatewayRouting: {},
|
||||
chatTemplateKwargs: {},
|
||||
zaiToolStream: false,
|
||||
supportsStrictMode: true,
|
||||
cacheControlFormat: "anthropic",
|
||||
|
||||
@@ -49,6 +49,13 @@ describe("isContextOverflow", () => {
|
||||
expect(isContextOverflow(message, 131072)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects OpenAI-compatible parenthesized maximum context length errors", () => {
|
||||
const message = createErrorMessage(
|
||||
"Error: 400 Input length (265330) exceeds model's maximum context length (262144).",
|
||||
);
|
||||
expect(isContextOverflow(message, 262144)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects OpenRouter Poolside maximum allowed input length errors", () => {
|
||||
const message = createErrorMessage(
|
||||
"Provider returned error: Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||
|
||||
@@ -69,6 +69,15 @@ describe("getSupportedThinkingLevels", () => {
|
||||
expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high"]);
|
||||
});
|
||||
|
||||
it("excludes thinking off for Moonshot Kimi K2.7 Code models", () => {
|
||||
const cases = [getModel("moonshotai", "kimi-k2.7-code"), getModel("moonshotai-cn", "kimi-k2.7-code")];
|
||||
|
||||
for (const model of cases) {
|
||||
expect(model).toBeDefined();
|
||||
expect(getSupportedThinkingLevels(model!)).toEqual(["minimal", "low", "medium", "high"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes only high for OpenCode Grok Build", () => {
|
||||
const model = getModel("opencode", "grok-build-0.1");
|
||||
expect(model).toBeDefined();
|
||||
|
||||
Reference in New Issue
Block a user