diff --git a/src/async/handler.ts b/src/async/handler.ts index 009c44c..ceae78e 100644 --- a/src/async/handler.ts +++ b/src/async/handler.ts @@ -26,6 +26,7 @@ import { credentialString } from "../auth/types.js"; import { errorResponse } from "../proxy/handler.js"; import { transformRequestBody } from "../proxy/body-transformer.js"; import { translateRequestOpenAIToAnthropic, translateResponseAnthropicToOpenAI } from "../translator/openai-to-anthropic.js"; +import { normalizeAnthropicReasoning } from "../translator/reasoning-effort.js"; import { anthropicSseToOpenaiSseWithKeepalive } from "./openai-stream-adapter.js"; import type { AnthropicMessagesRequest, OpenAIChatRequest, AnthropicMessagesResponse } from "../translator/types.js"; import { createOffPeakClient, type OffPeakClient } from "./client.js"; @@ -237,11 +238,11 @@ export async function handleAsyncMessages(req: Request, opts: AsyncHandlerOption // Anthropic spec: omitted `stream` defaults to non-streaming (false). // Validated: parsedBody is a plain object with messages[]. Remaining fields // (max_tokens, tools, etc.) are forwarded as-is — upstream rejects invalid shapes. - const upstreamBody = { + const upstreamBody = normalizeAnthropicReasoning({ ...parsedBody, model: resolveModel({ model: modelStr }, opts.config), stream: true, - } as AnthropicMessagesRequest; + } as AnthropicMessagesRequest); const upstreamBodyText = transformRequestBody(JSON.stringify(upstreamBody), { format: "anthropic", userId: cred.cred.userId }) ?? JSON.stringify(upstreamBody); // Now we're safe to take a ticket diff --git a/src/provider/models.ts b/src/provider/models.ts index 682051f..2912f1f 100644 --- a/src/provider/models.ts +++ b/src/provider/models.ts @@ -10,16 +10,40 @@ */ import type { ModelDef } from "./types.js"; +const TEXT = ["text"] as const; +const VISION = ["text", "image"] as const; + /** All models available on the GLM coding plan, pinned with verified specs. */ export const MODELS: ModelDef[] = [ - { id: "glm-4.5-air", name: "GLM 4.5 Air", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true }, - { id: "glm-4.6", name: "GLM 4.6", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true }, - { id: "glm-4.6v", name: "GLM 4.6V", contextWindow: 200_000, maxOutputTokens: 128_000 }, - { id: "glm-4.7", name: "GLM 4.7", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true }, - { id: "glm-5", name: "GLM 5", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true }, - { id: "glm-5-turbo", name: "GLM 5 Turbo", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true }, - { id: "glm-5v-turbo", name: "GLM 5V Turbo", contextWindow: 200_000, maxOutputTokens: 128_000 }, - { id: "glm-5.1", name: "GLM 5.1", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true }, - { id: "glm-5.2", name: "GLM 5.2", contextWindow: 1_000_000, maxOutputTokens: 128_000, reasoning: true }, - { id: "glm-5.3", name: "GLM 5.3", contextWindow: 1_000_000, maxOutputTokens: 128_000, reasoning: true }, + { id: "glm-4.5-air", name: "GLM 4.5 Air", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true, inputModalities: TEXT }, + { id: "glm-4.6", name: "GLM 4.6", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true, inputModalities: TEXT }, + { id: "glm-4.6v", name: "GLM 4.6V", contextWindow: 200_000, maxOutputTokens: 128_000, inputModalities: VISION }, + { id: "glm-4.7", name: "GLM 4.7", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true, forcedReasoning: true, inputModalities: TEXT }, + { id: "glm-5", name: "GLM 5", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true, inputModalities: TEXT }, + { id: "glm-5-turbo", name: "GLM 5 Turbo", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true, inputModalities: TEXT }, + { id: "glm-5v-turbo", name: "GLM 5V Turbo", contextWindow: 200_000, maxOutputTokens: 128_000, inputModalities: VISION }, + { id: "glm-5.1", name: "GLM 5.1", contextWindow: 200_000, maxOutputTokens: 128_000, reasoning: true, inputModalities: TEXT }, + { + id: "glm-5.2", + name: "GLM 5.2", + contextWindow: 200_000, + maxOutputTokens: 131_072, + reasoning: true, + reasoningEfforts: ["none", "high", "max"], + defaultReasoningEffort: "max", + reasoningEffortMap: { none: "none", minimal: "none", light: "high", low: "high", medium: "high", high: "high", xhigh: "max", max: "max", ultra: "max" }, + inputModalities: TEXT, + }, + { + id: "glm-5.3", + name: "GLM 5.3", + contextWindow: 200_000, + maxOutputTokens: 131_072, + reasoning: true, + reasoningEfforts: ["low", "high", "max"], + defaultReasoningEffort: "max", + reasoningEffortMap: { none: "low", minimal: "low", light: "low", low: "low", medium: "high", high: "high", xhigh: "max", max: "max", ultra: "max" }, + forcedReasoning: true, + inputModalities: TEXT, + }, ]; diff --git a/src/provider/providers.test.ts b/src/provider/providers.test.ts index ba52403..46f4270 100644 --- a/src/provider/providers.test.ts +++ b/src/provider/providers.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect } from "bun:test"; import { getProvider, ZAI_PROVIDER, BIGMODEL_PROVIDER } from "./providers.js"; import { MODELS } from "./models.js"; +import { catalogContextWindow, CONTEXT_WINDOW_1M, reasoningModel } from "../translator/reasoning-effort.js"; describe("providers", () => { it("getProvider returns Z.AI definition", () => { @@ -51,24 +52,17 @@ describe("models", () => { expect(typeof m.id).toBe("string"); expect(m.id.length).toBeGreaterThan(0); expect(m.contextWindow).toBeGreaterThan(0); - expect(m.maxOutputTokens).toBe(128_000); - } - }); - - it("all models except glm-5.2/glm-5.3 have 200k context", () => { - for (const m of MODELS) { - if (m.id === "glm-5.2" || m.id === "glm-5.3") continue; expect(m.contextWindow).toBe(200_000); + expect(m.maxOutputTokens).toBe(m.id === "glm-5.2" || m.id === "glm-5.3" ? 131_072 : 128_000); } }); - it("glm-5.2 and glm-5.3 have 1M context", () => { - const glm52 = MODELS.find((m) => m.id === "glm-5.2"); - expect(glm52).toBeDefined(); - expect(glm52!.contextWindow).toBe(1_000_000); - const glm53 = MODELS.find((m) => m.id === "glm-5.3"); - expect(glm53).toBeDefined(); - expect(glm53!.contextWindow).toBe(1_000_000); + it("glm-5.2 and glm-5.3 default to 200k; [1m] aliases are listing-only 1M", () => { + expect(reasoningModel("glm-5.2")!.contextWindow).toBe(200_000); + expect(reasoningModel("glm-5.3")!.contextWindow).toBe(200_000); + expect(catalogContextWindow("glm-5.3", reasoningModel("glm-5.3")!)).toBe(200_000); + expect(catalogContextWindow("glm-5.3[1m]", reasoningModel("glm-5.3[1m]")!)).toBe(CONTEXT_WINDOW_1M); + expect(catalogContextWindow("glm-5.2[1m]", reasoningModel("glm-5.2[1m]")!)).toBe(CONTEXT_WINDOW_1M); }); it("includes key GLM models", () => { diff --git a/src/provider/types.ts b/src/provider/types.ts index 3eea821..aa3f3df 100644 --- a/src/provider/types.ts +++ b/src/provider/types.ts @@ -18,6 +18,9 @@ export interface ProviderDef { bizHost: string; } +export type ModelReasoningEffort = "none" | "minimal" | "light" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; +export type CanonicalReasoningEffort = "none" | "low" | "high" | "max"; + /** Model definition derived from the catalog. */ export interface ModelDef { id: string; @@ -26,4 +29,9 @@ export interface ModelDef { maxOutputTokens?: number; /** Whether the model supports reasoning/thinking mode. */ reasoning?: boolean; + reasoningEfforts?: readonly CanonicalReasoningEffort[]; + defaultReasoningEffort?: Exclude; + reasoningEffortMap?: Partial>; + forcedReasoning?: boolean; + inputModalities?: readonly ("text" | "image")[]; } diff --git a/src/proxy/body-transformer.test.ts b/src/proxy/body-transformer.test.ts index acecf3e..2e3e1a5 100644 --- a/src/proxy/body-transformer.test.ts +++ b/src/proxy/body-transformer.test.ts @@ -345,3 +345,25 @@ describe("transformRequestBody — metadata.user_id (Anthropic)", () => { expect(parsed.metadata).toBeUndefined(); }); }); + +describe("transformRequestBody — catalog [1m] aliases", () => { + it("strips glm-5.3[1m] to glm-5.3 for OpenAI upstream bodies", () => { + const body = JSON.stringify({ model: "glm-5.3[1m]", messages: [], stream: false }); + const parsed = JSON.parse(transformRequestBody(body, { format: "openai" }) as string); + expect(parsed.model).toBe("glm-5.3"); + }); + + it("strips glm-5.2[1m] to glm-5.2 for Anthropic upstream bodies", () => { + const body = JSON.stringify({ + model: "glm-5.2[1m]", + messages: [{ role: "user", content: "hi" }], + }); + const parsed = JSON.parse(transformRequestBody(body, { format: "anthropic" }) as string); + expect(parsed.model).toBe("glm-5.2"); + }); + + it("leaves base model ids unchanged", () => { + const body = JSON.stringify({ model: "glm-5.3", messages: [], stream: false }); + expect(transformRequestBody(body, { format: "openai" })).toBe(body); + }); +}); diff --git a/src/proxy/body-transformer.ts b/src/proxy/body-transformer.ts index 6cfd249..d7645ce 100644 --- a/src/proxy/body-transformer.ts +++ b/src/proxy/body-transformer.ts @@ -16,10 +16,13 @@ * is safe and matches ZCode's `applyCacheControl: true` default. * 4. Anthropic format + `ctx.userId` set → inject `metadata: { user_id }`. * Mirrors `user_id: B.metadata.userId` at bundle offset ~4760586. + * 5. Catalog aliases like `glm-5.3[1m]` → strip to the upstream modelCode. + * Listing keeps the alias; the provider rejects `[1m]` as modelCode (1214). * * @see _reverse/NOTEPAD.md "How Credential is Used for LLM Calls" */ import type { Format } from "../translator/types.js"; +import { upstreamModelId } from "../translator/reasoning-effort.js"; import { buildStartPlanSystem } from "./system-prompt.js"; interface TransformContext { @@ -30,6 +33,14 @@ interface TransformContext { startPlan?: boolean; } +function applyUpstreamModel(body: Record): boolean { + if (typeof body.model !== "string") return false; + const upstream = upstreamModelId(body.model); + if (upstream === body.model) return false; + body.model = upstream; + return true; +} + /** * Apply body transformations. Returns the original `body` string when nothing * changed OR when parsing failed; otherwise returns the re-serialized body. @@ -45,7 +56,7 @@ export function transformRequestBody(body: string | undefined, ctx: TransformCon } if (typeof parsed !== "object" || parsed === null) return body; - let modified = false; + let modified = applyUpstreamModel(parsed as Record); if (ctx.format === "openai") { if (ctx.startPlan) { diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index 2850c69..917608b 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -37,6 +37,7 @@ async function loadCaptcha(): Promise { } import { translateRequestOpenAIToAnthropic, translateResponseAnthropicToOpenAI } from "../translator/openai-to-anthropic.js"; import { translateRequestAnthropicToOpenAI, translateResponseOpenAIToAnthropic } from "../translator/anthropic-to-openai.js"; +import { normalizeAnthropicReasoning } from "../translator/reasoning-effort.js"; import { anthropicSseToOpenaiSse, openaiSseToAnthropicSse } from "../translator/sse-translator.js"; import type { OpenAIChatRequest, OpenAIChatResponse, AnthropicMessagesRequest, AnthropicMessagesResponse } from "../translator/types.js"; import { dumpPhase, dumpHeaders, dumpBody, dumpEnabled } from "./dump.js"; @@ -147,6 +148,10 @@ export async function proxyRequest( if (translated instanceof Response) return translated; upstreamBody = translated; if (debug) debugLine(reqId, `translated Anthropic→OpenAI (bytes=${upstreamBody?.length ?? 0})`); + } else if (format === "anthropic") { + const normalized = normalizeNativeAnthropicBody(body); + if (normalized instanceof Response) return normalized; + upstreamBody = normalized; } const transformedBody = transformRequestBody(upstreamBody, { format: upstreamFormat, userId: startPlan ? undefined : cred.userId, startPlan }); @@ -672,6 +677,17 @@ function translateAnthropicBody(body: string | undefined): Response | string | u } } +function normalizeNativeAnthropicBody(body: string | undefined): Response | string | undefined { + if (body === undefined || body.length === 0) return body; + try { + const parsed = JSON.parse(body) as AnthropicMessagesRequest; + const normalized = normalizeAnthropicReasoning(parsed); + return normalized === parsed ? body : JSON.stringify(normalized); + } catch (err) { + return errorResponse(400, "translation_failed", `Anthropic request body is not valid JSON: ${(err as Error).message}`); + } +} + function isAnthropicMessagesResponse(value: unknown): value is AnthropicMessagesResponse { if (typeof value !== "object" || value === null) return false; const candidate = value as Partial; diff --git a/src/proxy/responses-handler.test.ts b/src/proxy/responses-handler.test.ts index b685576..e893f67 100644 --- a/src/proxy/responses-handler.test.ts +++ b/src/proxy/responses-handler.test.ts @@ -63,6 +63,24 @@ function makeReq(body: unknown): Request { } describe("handleResponses", () => { + it("forwards Responses effort to the coding-plan Anthropic upstream", async () => { + let upstreamBody: any; + const fetchImpl = (async (request: Request): Promise => { + upstreamBody = await request.json(); + return new Response(anthropicMsg("ok"), { status: 200, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch; + + const resp = await handleResponses(makeReq({ + model: "glm-5.3", + input: "hello", + reasoning: { effort: "xhigh" }, + }), { config: CONFIG, auth, fetchImpl }); + + expect(resp.status).toBe(200); + expect(upstreamBody.thinking).toEqual({ type: "enabled" }); + expect(upstreamBody.output_config).toEqual({ effort: "max" }); + }); + it("returns a ResponsesResponse with message output for a basic text request", async () => { const fetchImpl = chatUpstream(anthropicMsg("hi back")); const resp = await handleResponses(makeReq({ model: "glm-5.2", input: "hello" }), { config: CONFIG, auth, fetchImpl }); diff --git a/src/proxy/upstream.test.ts b/src/proxy/upstream.test.ts index db90bc5..7d516c6 100644 --- a/src/proxy/upstream.test.ts +++ b/src/proxy/upstream.test.ts @@ -1644,6 +1644,37 @@ describe("proxyRequest — thinking endpoint matrix", () => { .filter((event): event is { event: string; data: any } => event !== null); } + it("coding-plan OpenAI endpoint forwards GLM-5.3 effort to Anthropic upstream", async () => { + let upstreamBody: any; + const fetchMock = mock(async (req: Request): Promise => { + upstreamBody = await req.json(); + return new Response(anthropicThinkingResponse(), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + const req = new Request("http://localhost:8080/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "glm-5.3", + messages: [{ role: "user", content: "think then answer" }], + reasoning_effort: "high", + }), + }); + + const resp = await proxyRequest(req, "openai", { + config: testConfig, + auth: codingPlanAuth(), + fetchImpl: fetchMock as any, + }); + + expect(resp.status).toBe(200); + expect(upstreamBody.thinking).toEqual({ type: "enabled" }); + expect(upstreamBody.output_config).toEqual({ effort: "high" }); + expect(upstreamBody.reasoning_effort).toBeUndefined(); + }); + it("coding-plan Anthropic upstream non-streaming surfaces thinking as OpenAI reasoning_content", async () => { let upstreamUrl = ""; const fetchMock = mock(async (req: Request): Promise => { @@ -1690,6 +1721,37 @@ describe("proxyRequest — thinking endpoint matrix", () => { expect(text).toContain('"content":"Final answer."'); }); + it("coding-plan Anthropic client normalizes GLM-5.3 disabled thinking to low", async () => { + let upstreamBody: any; + const fetchMock = mock(async (req: Request): Promise => { + upstreamBody = await req.json(); + return new Response(anthropicThinkingResponse(), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + const req = new Request("http://localhost:8080/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "glm-5.3", + max_tokens: 1024, + messages: [{ role: "user", content: "think then answer" }], + thinking: { type: "disabled" }, + }), + }); + + const resp = await proxyRequest(req, "anthropic", { + config: testConfig, + auth: codingPlanAuth(), + fetchImpl: fetchMock as any, + }); + + expect(resp.status).toBe(200); + expect(upstreamBody.thinking).toEqual({ type: "enabled" }); + expect(upstreamBody.output_config).toEqual({ effort: "low" }); + }); + it("coding-plan Anthropic client non-streaming passes thinking blocks through natively", async () => { let upstreamUrl = ""; const fetchMock = mock(async (req: Request): Promise => { @@ -1781,6 +1843,41 @@ describe("proxyRequest — thinking endpoint matrix", () => { }); }); + it("start-plan Anthropic endpoint maps Claude effort to OpenAI upstream", async () => { + await withDisabledCaptcha(async () => { + let upstreamBody: any; + const fetchMock = mock(async (req: Request): Promise => { + upstreamBody = await req.json(); + return new Response(openAIThinkingResponse(), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + const req = new Request("http://localhost:8080/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "glm-5.3", + max_tokens: 1024, + messages: [{ role: "user", content: "think then answer" }], + thinking: { type: "enabled" }, + output_config: { effort: "medium" }, + }), + }); + + const resp = await proxyRequest(req, "anthropic", { + config: { ...testConfig, plan: "start-plan" }, + auth: startPlanAuth(), + fetchImpl: fetchMock as any, + }); + + expect(resp.status).toBe(200); + expect(upstreamBody.reasoning_effort).toBe("high"); + expect(upstreamBody.thinking).toEqual({ type: "enabled" }); + expect(upstreamBody.output_config).toBeUndefined(); + }); + }); + it("start-plan Anthropic endpoint non-streaming translates OpenAI upstream reasoning_content into thinking blocks", async () => { await withDisabledCaptcha(async () => { const fetchMock = mock(async (req: Request): Promise => { diff --git a/src/server/routes-async.test.ts b/src/server/routes-async.test.ts index 3d506ac..4bab4e6 100644 --- a/src/server/routes-async.test.ts +++ b/src/server/routes-async.test.ts @@ -63,6 +63,7 @@ interface MockServerOpts { * Bridge detects post-commit → terminal error (no retry, no duplicate lifecycle). */ midStreamExpiredPostCommit?: boolean; llmStatusOverride?: number; + upstreamBodies?: unknown[]; takeCount: { value: number }; settleCount: { value: number }; } @@ -111,6 +112,7 @@ function makeMockFetch(opts: MockServerOpts): typeof fetch { if (u.endsWith("/api/v1/off-peak/anthropic/v1/messages") && method === "POST") { state.llmCounter++; const reqBody = JSON.parse((init?.body as string) ?? "{}"); + opts.upstreamBodies?.push(reqBody); if (opts.llmStatusOverride && opts.llmStatusOverride !== 200) { return new Response(JSON.stringify({ type: "error", error: { type: "api_error", message: "forced upstream error" } }), { @@ -363,7 +365,62 @@ describe("/async/v1/messages (Anthropic)", () => { }); }); +describe("/async/v1/messages reasoning", () => { + it("normalizes GLM-5.3 disabled thinking to low", async () => { + const config = makeConfig(); + const auth = makeOauthAuth(); + const upstreamBodies: unknown[] = []; + const counters = { takeCount: { value: 0 }, settleCount: { value: 0 } }; + const handler = createFetchHandler({ config, auth, fetchImpl: makeMockFetch({ ...counters, upstreamBodies }) }); + + const resp = await handler(new Request("http://localhost/async/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "glm-5.3", + messages: [{ role: "user", content: "hi" }], + max_tokens: 100, + thinking: { type: "disabled" }, + }), + })); + + expect(resp.status).toBe(200); + await resp.text(); + expect(upstreamBodies[0]).toMatchObject({ + model: "glm-5.3", + thinking: { type: "enabled" }, + output_config: { effort: "low" }, + }); + }); +}); + describe("/async/v1/chat/completions (OpenAI)", () => { + it("forwards GLM-5.3 effort to the Anthropic upstream", async () => { + const config = makeConfig(); + const auth = makeOauthAuth(); + const upstreamBodies: unknown[] = []; + const counters = { takeCount: { value: 0 }, settleCount: { value: 0 } }; + const handler = createFetchHandler({ config, auth, fetchImpl: makeMockFetch({ ...counters, upstreamBodies }) }); + + const resp = await handler(new Request("http://localhost/async/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "glm-5.3", + messages: [{ role: "user", content: "hi" }], + reasoning_effort: "low", + }), + })); + + expect(resp.status).toBe(200); + await resp.text(); + expect(upstreamBodies[0]).toMatchObject({ + model: "glm-5.3", + thinking: { type: "enabled" }, + output_config: { effort: "low" }, + }); + }); + it("B4: happy path stream — keepalives preserved + OpenAI chunks emitted", async () => { const config = makeConfig({ async: { enabled: true, origin: "https://zcode.z.ai", pollIntervalMs: 30, keepAliveIntervalMs: 5, maxWaitMs: 0, maxRetries: 3, settleTimeoutMs: 100, controlTimeoutMs: 1000, defaultModel: "" } }); const auth = makeOauthAuth(); diff --git a/src/server/routes-openai.ts b/src/server/routes-openai.ts index cb8ed8b..1b6419b 100644 --- a/src/server/routes-openai.ts +++ b/src/server/routes-openai.ts @@ -3,7 +3,9 @@ * @see .omo/plans/zcode-proxy.md Task 7 */ import { proxyRequest, type ProxyHandlerOptions } from "../proxy/handler.js"; -import { MODELS } from "../provider/models.js"; +import type { ModelDef } from "../provider/types.js"; +import { catalogContextWindow, reasoningModel } from "../translator/reasoning-effort.js"; +import type { ProxyConfig } from "../config/types.js"; import type { OpenAIModelList } from "../translator/types.js"; /** Handle POST /v1/chat/completions — forward OpenAI-compatible chat requests upstream. */ @@ -14,17 +16,142 @@ export async function handleChatCompletions( return proxyRequest(req, "openai", opts); } -/** Handle GET /v1/models — return the model list in OpenAI format. */ -export function handleListModels(): Response { +/** Handle GET /v1/models with protocol-specific capability discovery. */ +export function handleListModels(req: Request, config: ProxyConfig): Response { + const models = config.models + .map((id) => ({ id, definition: reasoningModel(id) })) + .filter((model): model is { id: string; definition: ModelDef } => model.definition !== undefined); + const url = new URL(req.url); + + if (url.searchParams.has("client_version")) { + return jsonResponse({ models: models.map(({ id, definition }) => codexModel(id, definition)) }); + } + if (req.headers.has("anthropic-version")) { + return anthropicModelsResponse(url, models); + } + const list: OpenAIModelList = { object: "list", - data: MODELS.map((m) => ({ - id: m.id, - object: "model" as const, - owned_by: "zcode-proxy", - })), + data: config.models.map((id) => ({ id, object: "model" as const, owned_by: "zcode-proxy" })), + }; + return jsonResponse(list); +} + +function codexModel(id: string, model: ModelDef): Record { + const efforts = model.reasoningEfforts?.filter((effort) => effort !== "none") ?? []; + return { + slug: id, + display_name: model.name, + description: model.name, + default_reasoning_level: model.defaultReasoningEffort ?? "max", + supported_reasoning_levels: efforts.map((effort) => ({ effort, description: `${effort} reasoning` })), + shell_type: "shell_command", + visibility: "list", + supported_in_api: true, + priority: 0, + base_instructions: "", + supports_reasoning_summaries: model.reasoning === true, + default_reasoning_summary: "none", + support_verbosity: false, + apply_patch_tool_type: "freeform", + truncation_policy: { mode: "bytes", limit: 10_000 }, + context_window: catalogContextWindow(id, model), + max_context_window: catalogContextWindow(id, model), + effective_context_window_percent: 95, + supports_parallel_tool_calls: true, + experimental_supported_tools: [], + input_modalities: model.inputModalities ?? ["text"], }; - return new Response(JSON.stringify(list), { +} + +function anthropicModel(id: string, model: ModelDef): Record { + const acceptedEfforts = new Set(Object.keys(model.reasoningEffortMap ?? {})); + const supported = (effort: "low" | "medium" | "high" | "max") => ({ + supported: acceptedEfforts.has(effort), + }); + return { + id, + type: "model", + display_name: model.name, + created_at: "1970-01-01T00:00:00Z", + max_input_tokens: catalogContextWindow(id, model), + max_tokens: model.maxOutputTokens ?? catalogContextWindow(id, model), + capabilities: { + batch: { supported: false }, + citations: { supported: false }, + code_execution: { supported: false }, + context_management: { + supported: false, + clear_thinking_20251015: { supported: false }, + clear_tool_uses_20250919: { supported: false }, + compact_20260112: { supported: false }, + }, + effort: { + supported: acceptedEfforts.size > 0, + low: supported("low"), + medium: supported("medium"), + high: supported("high"), + max: supported("max"), + }, + image_input: { supported: model.inputModalities?.includes("image") === true }, + pdf_input: { supported: false }, + structured_outputs: { supported: false }, + thinking: { + supported: model.reasoning === true, + types: { + enabled: { supported: model.reasoning === true }, + adaptive: { supported: false }, + }, + }, + }, + }; +} + +function anthropicModelsResponse( + url: URL, + models: Array<{ id: string; definition: ModelDef }>, +): Response { + const limitValue = url.searchParams.get("limit"); + const limit = limitValue === null ? 20 : Number(limitValue); + const after = url.searchParams.get("after_id"); + const before = url.searchParams.get("before_id"); + if (!Number.isInteger(limit) || limit < 1 || limit > 1_000 || (after && before)) { + return jsonError(400, "invalid pagination parameters"); + } + + let start = 0; + let end = models.length; + if (after) { + const index = models.findIndex((model) => model.id === after); + if (index < 0) return jsonError(400, `unknown after_id: ${after}`); + start = index + 1; + } + if (before) { + const index = models.findIndex((model) => model.id === before); + if (index < 0) return jsonError(400, `unknown before_id: ${before}`); + end = index; + } + + const remaining = models.slice(start, end); + const page = before ? remaining.slice(-limit) : remaining.slice(0, limit); + const data = page.map(({ id, definition }) => anthropicModel(id, definition)); + return jsonResponse({ + data, + first_id: page[0]?.id ?? null, + last_id: page.at(-1)?.id ?? null, + has_more: remaining.length > page.length, + }); +} + +function jsonError(status: number, message: string): Response { + return new Response(JSON.stringify({ type: "error", error: { type: "invalid_request_error", message } }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, }); diff --git a/src/server/server.test.ts b/src/server/server.test.ts index f034b82..97aaef8 100644 --- a/src/server/server.test.ts +++ b/src/server/server.test.ts @@ -71,17 +71,102 @@ function mockUpstream(): typeof fetch { } describe("server routing", () => { - it("GET /v1/models returns model list", async () => { - const config = makeConfig({ auth: { mode: "apikey", apiKey: "test" } }); + it("GET /v1/models returns the configured OpenAI model list", async () => { + const config = makeConfig({ auth: { mode: "apikey", apiKey: "test" }, models: ["glm-5.3"] }); const auth = new AuthManager({ mode: "apikey", provider: "zai", apiKey: "test" }); const handler = createFetchHandler({ config, auth, fetchImpl: mockUpstream() }); const resp = await handler(new Request("http://localhost/v1/models", { method: "GET" })); expect(resp.status).toBe(200); const body = await resp.json(); - expect(body.object).toBe("list"); - expect(body.data.length).toBeGreaterThan(0); - expect(body.data[0].object).toBe("model"); + expect(body).toEqual({ + object: "list", + data: [{ id: "glm-5.3", object: "model", owned_by: "zcode-proxy" }], + }); + }); + + it("GET /v1/models?client_version returns Codex reasoning metadata", async () => { + const config = makeConfig({ auth: { mode: "apikey", apiKey: "test" }, models: ["glm-5.2", "glm-5.3[1m]", "vendor-custom"] }); + const auth = new AuthManager({ mode: "apikey", provider: "zai", apiKey: "test" }); + const handler = createFetchHandler({ config, auth, fetchImpl: mockUpstream() }); + + const resp = await handler(new Request("http://localhost/v1/models?client_version=1.2.3", { method: "GET" })); + const body = await resp.json(); + + expect(body.models.map((model: any) => model.slug)).toEqual(["glm-5.2", "glm-5.3[1m]"]); + expect(body.models[0].supported_reasoning_levels.map((level: any) => level.effort)).toEqual(["high", "max"]); + expect(body.models[1].supported_reasoning_levels.map((level: any) => level.effort)).toEqual(["low", "high", "max"]); + expect(body.models[1].default_reasoning_level).toBe("max"); + expect(body.models[0].context_window).toBe(200_000); + expect(body.models[1].context_window).toBe(1_048_576); + }); + + it("GET /v1/models with anthropic-version returns Anthropic capabilities", async () => { + const config = makeConfig({ auth: { mode: "apikey", apiKey: "test" }, models: ["glm-5.2", "glm-5.3"] }); + const auth = new AuthManager({ mode: "apikey", provider: "zai", apiKey: "test" }); + const handler = createFetchHandler({ config, auth, fetchImpl: mockUpstream() }); + + const resp = await handler(new Request("http://localhost/v1/models", { + method: "GET", + headers: { "anthropic-version": "2023-06-01" }, + })); + const body = await resp.json(); + + expect(body.data[0]).toMatchObject({ + id: "glm-5.2", + type: "model", + display_name: "GLM 5.2", + max_input_tokens: 200_000, + max_tokens: 131_072, + capabilities: { + effort: { + supported: true, + low: { supported: true }, + medium: { supported: true }, + high: { supported: true }, + max: { supported: true }, + }, + thinking: { + supported: true, + types: { enabled: { supported: true }, adaptive: { supported: false } }, + }, + }, + }); + expect(body.data[1].capabilities.effort.low.supported).toBe(true); + expect(body.first_id).toBe("glm-5.2"); + expect(body.last_id).toBe("glm-5.3"); + expect(body.has_more).toBe(false); + }); + + it("paginates Anthropic models", async () => { + const config = makeConfig({ auth: { mode: "apikey", apiKey: "test" }, models: ["glm-5.1", "glm-5.2", "glm-5.3"] }); + const auth = new AuthManager({ mode: "apikey", provider: "zai", apiKey: "test" }); + const handler = createFetchHandler({ config, auth, fetchImpl: mockUpstream() }); + + const first = await handler(new Request("http://localhost/v1/models?limit=1", { + headers: { "anthropic-version": "2023-06-01" }, + })); + const firstBody = await first.json(); + expect(firstBody.data.map((model: any) => model.id)).toEqual(["glm-5.1"]); + expect(firstBody.has_more).toBe(true); + + const next = await handler(new Request("http://localhost/v1/models?limit=1&after_id=glm-5.1", { + headers: { "anthropic-version": "2023-06-01" }, + })); + const nextBody = await next.json(); + expect(nextBody.data.map((model: any) => model.id)).toEqual(["glm-5.2"]); + + const previous = await handler(new Request("http://localhost/v1/models?limit=1&before_id=glm-5.3", { + headers: { "anthropic-version": "2023-06-01" }, + })); + const previousBody = await previous.json(); + expect(previousBody.data.map((model: any) => model.id)).toEqual(["glm-5.2"]); + expect(previousBody.has_more).toBe(true); + + const invalid = await handler(new Request("http://localhost/v1/models?limit=0", { + headers: { "anthropic-version": "2023-06-01" }, + })); + expect(invalid.status).toBe(400); }); it("POST /v1/chat/completions forwards to upstream", async () => { @@ -255,7 +340,7 @@ describe("web UI", () => { describe("route handler exports", () => { it("handleListModels returns model list", () => { - const resp = handleListModels(); + const resp = handleListModels(new Request("http://localhost/v1/models"), makeConfig()); expect(resp.status).toBe(200); }); diff --git a/src/server/server.ts b/src/server/server.ts index dd06048..ee6e25a 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -91,7 +91,7 @@ export function createFetchHandler(opts: ServerOptions): (req: Request) => Promi return handleResponsesRoute(req, responsesOpts); } if (path === "/v1/models" && method === "GET") { - return handleListModels(); + return handleListModels(req, config); } if (path === "/v1/messages" && method === "POST") { diff --git a/src/server/webui.txt b/src/server/webui.txt index c83ce5c..7b0858b 100644 --- a/src/server/webui.txt +++ b/src/server/webui.txt @@ -438,12 +438,7 @@ (function () { "use strict"; var STORE = "zcode_webui_state_v1"; - // Known model catalog (used as a fallback when /v1/models is unreachable, - // e.g. before the proxy key has been entered). - var KNOWN_MODELS = [ - "glm-4.5-air", "glm-4.6", "glm-4.6v", "glm-4.7", "glm-5", - "glm-5-turbo", "glm-5v-turbo", "glm-5.1", "glm-5.2" - ]; + var modelCatalog = {}; var DEFAULT_SETTINGS = { apiKey: "", @@ -719,7 +714,7 @@ // ========================================================= function populateModels(list) { modelSel.innerHTML = ""; - (list.length ? list : KNOWN_MODELS).forEach(function (id) { + (list.length ? list : [state.settings.model || DEFAULT_SETTINGS.model]).forEach(function (id) { var o = document.createElement("option"); o.value = id; o.textContent = id; modelSel.appendChild(o); }); @@ -732,25 +727,31 @@ function fetchModels() { var headers = {}; if (state.settings.apiKey) headers["authorization"] = "Bearer " + state.settings.apiKey; - fetch("/v1/models", { method: "GET", headers: headers }) - .then(function (r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }) - .then(function (j) { - var ids = (j.data || []).map(function (m) { return m.id; }); - populateModels(ids); setStatus("ok"); - }) - .catch(function () { - populateModels(KNOWN_MODELS); - setStatus(state.settings.apiKey ? "err" : "idle"); - }); + Promise.all([ + fetch("/v1/models", { method: "GET", headers: headers }), + fetch("/v1/models?client_version=webui", { method: "GET", headers: headers }) + ]).then(function (responses) { + if (!responses[0].ok || !responses[1].ok) throw new Error("model discovery failed"); + return Promise.all([responses[0].json(), responses[1].json()]); + }).then(function (payloads) { + modelCatalog = {}; + (payloads[1].models || []).forEach(function (model) { modelCatalog[model.slug] = model; }); + var ids = (payloads[0].data || []).map(function (model) { return model.id; }); + populateModels(ids); setStatus("ok"); + }).catch(function () { + modelCatalog = {}; + populateModels([]); + setStatus(state.settings.apiKey ? "err" : "idle"); + }); } function setStatus(s) { statusDot.className = "status-dot" + (s === "ok" ? " ok" : s === "err" ? " err" : ""); statusDot.title = s === "ok" ? "Connected" : s === "err" ? "Request failed (check key / proxy)" : "Set proxy API key in settings"; } - function isVisionModel(id) { return /v/i.test(id || ""); } + function isVisionModel(id) { + var model = modelCatalog[id]; + return !!model && (model.input_modalities || []).indexOf("image") >= 0; + } function syncAttachVisibility() { attachBtn.hidden = !isVisionModel(modelSel.value); } // ========================================================= @@ -806,8 +807,8 @@ return body; } function isReasoningModel(id) { - // reasoning_effort is only honored by GLM-5.2 and above per BigModel docs. - return /glm-5\.[2-9]|glm-[6-9]/i.test(id || ""); + var model = modelCatalog[id]; + return !!model && (model.supported_reasoning_levels || []).length > 0; } // ========================================================= // MCP — client-managed (browser-direct, optional CORS proxy). @@ -1312,7 +1313,7 @@ if (!confirm("Delete ALL conversations and reset settings? This cannot be undone.")) return; localStorage.removeItem(STORE); state = loadState(); saveState(); applyTheme(); renderSidebar(); renderMessages(); - syncSettingsForm(); populateModels(KNOWN_MODELS); toast("Reset complete."); + syncSettingsForm(); populateModels([]); toast("Reset complete."); }; $("btn-theme").onclick = function () { var cur = document.documentElement.getAttribute("data-theme"); @@ -1341,20 +1342,19 @@ } saveState(); syncAttachVisibility(); populateEffort(); }; - // GLM-5.2 only has two distinct reasoning-effort levels (low/medium map - // to high, xhigh maps to max), so expose just high and max. Other models - // get a disabled placeholder (reasoning_effort is GLM-5.2+ only). function populateEffort() { - var model = modelSel.value; + var model = modelCatalog[modelSel.value]; + var levels = model ? (model.supported_reasoning_levels || []).map(function (level) { return level.effort; }) : []; effortSel.innerHTML = ""; - if (isReasoningModel(model)) { - ["max", "high"].forEach(function (v) { + if (levels.length) { + levels.forEach(function (v) { var o = document.createElement("option"); o.value = v; o.textContent = v; effortSel.appendChild(o); }); var cur = state.settings.reasoningEffort; - effortSel.value = (cur === "high") ? "high" : "max"; + effortSel.value = levels.indexOf(cur) >= 0 ? cur : (model.default_reasoning_level || levels[0]); + state.settings.reasoningEffort = effortSel.value; effortSel.disabled = !state.settings.thinkingEnabled; } else { var o = document.createElement("option"); @@ -1407,7 +1407,7 @@ // boot // ========================================================= applyTheme(); - populateModels(KNOWN_MODELS); + populateModels([]); renderSidebar(); renderMessages(); populateEffort(); syncThinkButton(); fetchModels(); diff --git a/src/translator/anthropic-to-openai.ts b/src/translator/anthropic-to-openai.ts index add04e7..3af7f85 100644 --- a/src/translator/anthropic-to-openai.ts +++ b/src/translator/anthropic-to-openai.ts @@ -2,6 +2,7 @@ * Anthropic → OpenAI request translator and OpenAI → Anthropic response translator. * @see .omo/plans/zcode-proxy.md Task 11 */ +import { normalizeAnthropicReasoning } from "./reasoning-effort.js"; import type { OpenAIChatRequest, OpenAIChatResponse, @@ -18,6 +19,7 @@ import type { /** Translate an Anthropic messages request into an OpenAI chat request. */ export function translateRequestAnthropicToOpenAI(req: AnthropicMessagesRequest): OpenAIChatRequest { + req = normalizeAnthropicReasoning(req); const messages: OpenAIMessage[] = []; if (req.system) { @@ -44,9 +46,8 @@ export function translateRequestAnthropicToOpenAI(req: AnthropicMessagesRequest) result.stop = req.stop_sequences.length === 1 ? req.stop_sequences[0] : req.stop_sequences; } - if (req.thinking) { - result.thinking = req.thinking; - } + if (req.output_config?.effort) result.reasoning_effort = req.output_config.effort; + if (req.thinking) result.thinking = req.thinking; if (req.tools?.length) { result.tools = req.tools.map((t) => ({ diff --git a/src/translator/openai-to-anthropic.test.ts b/src/translator/openai-to-anthropic.test.ts index 8e089d3..52e6b3c 100644 --- a/src/translator/openai-to-anthropic.test.ts +++ b/src/translator/openai-to-anthropic.test.ts @@ -2,7 +2,7 @@ * Tests for OpenAI ↔ Anthropic translators. * @see .omo/plans/zcode-proxy.md Task 11 */ -import { describe, it, expect } from "bun:test"; +import { describe, it, expect, spyOn } from "bun:test"; import { translateRequestOpenAIToAnthropic, translateResponseAnthropicToOpenAI, @@ -414,6 +414,78 @@ describe("translateRequestOpenAIToAnthropic", () => { expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 1024 }); }); + + it.each([ + ["none", "low"], + ["minimal", "low"], + ["light", "low"], + ["low", "low"], + ["medium", "high"], + ["high", "high"], + ["xhigh", "max"], + ["max", "max"], + ["ultra", "max"], + ] as const)("maps GLM-5.3 Coding Plan effort %s to %s", (input, expected) => { + const result = translateRequestOpenAIToAnthropic({ + model: "glm-5.3", + messages: [{ role: "user", content: "Think" }], + reasoning_effort: input, + }); + + expect(result.thinking).toEqual({ type: "enabled" }); + expect(result.output_config).toEqual({ effort: expected }); + }); + + it.each([ + ["none", "disabled", undefined], + ["minimal", "disabled", undefined], + ["light", "enabled", "high"], + ["low", "enabled", "high"], + ["medium", "enabled", "high"], + ["high", "enabled", "high"], + ["xhigh", "enabled", "max"], + ["max", "enabled", "max"], + ["ultra", "enabled", "max"], + ] as const)("maps GLM-5.2 Coding Plan effort %s", (input, thinking, effort) => { + const result = translateRequestOpenAIToAnthropic({ + model: "glm-5.2", + messages: [{ role: "user", content: "Think" }], + reasoning_effort: input, + }); + + expect(result.thinking).toEqual({ type: thinking }); + expect(result.output_config?.effort).toBe(effort); + }); + + it.each([false, "disabled", "none", "off"] as const)( + "converts GLM-5.3 thinking toggle %s to low effort", + (type) => { + const result = translateRequestOpenAIToAnthropic({ + model: "glm-5.3", + messages: [{ role: "user", content: "Think" }], + thinking: { type }, + }); + + expect(result.thinking).toEqual({ type: "enabled" }); + expect(result.output_config).toEqual({ effort: "low" }); + }, + ); + + it.each(["unexpected", "constructor", "toString", "__proto__"])( + "falls back unknown effort %s to the model default", + (effort) => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const result = translateRequestOpenAIToAnthropic({ + model: "glm-5.3", + messages: [{ role: "user", content: "Think" }], + reasoning_effort: effort as any, + }); + + expect(result.output_config).toEqual({ effort: "max" }); + expect(warn).toHaveBeenCalledWith(`[reasoning] unknown effort ${JSON.stringify(effort)} for glm-5.3; using max`); + warn.mockRestore(); + }, + ); }); describe("translateResponseAnthropicToOpenAI", () => { @@ -557,6 +629,39 @@ describe("translateRequestAnthropicToOpenAI", () => { expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 2048 }); }); + it.each([ + ["glm-5.3", "low", "low"], + ["glm-5.3", "medium", "high"], + ["glm-5.3", "high", "high"], + ["glm-5.3", "max", "max"], + ["glm-5.2", "low", "high"], + ["glm-5.2", "medium", "high"], + ["glm-5.2", "high", "high"], + ["glm-5.2", "max", "max"], + ] as const)("maps Anthropic %s effort %s to OpenAI %s", (model, input, expected) => { + const result = translateRequestAnthropicToOpenAI({ + model, + messages: [{ role: "user", content: "Think" }], + max_tokens: 100, + thinking: { type: "enabled" }, + output_config: { effort: input }, + }); + + expect(result.reasoning_effort).toBe(expected); + }); + + it("maps a disabled GLM-5.3 Anthropic request to enabled low effort", () => { + const result = translateRequestAnthropicToOpenAI({ + model: "glm-5.3", + messages: [{ role: "user", content: "Think" }], + max_tokens: 100, + thinking: { type: "disabled" }, + }); + + expect(result.thinking).toEqual({ type: "enabled" }); + expect(result.reasoning_effort).toBe("low"); + }); + it("preserves assistant thinking blocks as OpenAI reasoning_content", () => { const req: AnthropicMessagesRequest = { model: "glm-5.2", diff --git a/src/translator/openai-to-anthropic.ts b/src/translator/openai-to-anthropic.ts index ebb359e..a0a5ba7 100644 --- a/src/translator/openai-to-anthropic.ts +++ b/src/translator/openai-to-anthropic.ts @@ -14,7 +14,7 @@ import type { AnthropicToolDefinition, AnthropicThinkingConfig, } from "./types.js"; -import { MODELS } from "../provider/models.js"; +import { isForcedReasoning, isThinkingDisabled, normalizeReasoningEffort, reasoningModel } from "./reasoning-effort.js"; /** Default max_tokens if the OpenAI request doesn't specify one. */ const DEFAULT_MAX_TOKENS = 4096; @@ -41,8 +41,9 @@ export function translateRequestOpenAIToAnthropic(req: OpenAIChatRequest): Anthr if (req.top_p !== undefined) result.top_p = req.top_p; if (req.stream !== undefined) result.stream = req.stream; if (req.stop) result.stop_sequences = Array.isArray(req.stop) ? req.stop : [req.stop]; - const thinking = translateThinking(req); - if (thinking) result.thinking = thinking; + const reasoning = translateReasoning(req); + if (reasoning.thinking) result.thinking = reasoning.thinking; + if (reasoning.effort) result.output_config = { effort: reasoning.effort }; if (req.tools?.length && req.tool_choice !== "none") { result.tools = req.tools.map(translateToolOpenAIToAnthropic); } @@ -54,30 +55,40 @@ export function translateRequestOpenAIToAnthropic(req: OpenAIChatRequest): Anthr return result; } -function translateThinking(req: OpenAIChatRequest): AnthropicThinkingConfig | undefined { +function translateReasoning(req: OpenAIChatRequest): { + thinking?: AnthropicThinkingConfig; + effort?: "low" | "high" | "max"; +} { + const effort = normalizeReasoningEffort(req.model, req.reasoning_effort); + if (effort) { + if (effort === "none") return { thinking: { type: "disabled" } }; + return { thinking: { type: "enabled" }, effort }; + } + const explicit = req.thinking; if (explicit && typeof explicit === "object") { - if (explicit.type === "disabled") return { type: "disabled" }; + if (isThinkingDisabled(explicit.type)) { + return isForcedReasoning(req.model) + ? { thinking: { type: "enabled" }, effort: "low" } + : { thinking: { type: "disabled" } }; + } if (explicit.type === "enabled" || explicit.type === "adaptive") { const budget = explicit.budget_tokens ?? explicit.budgetTokens; return { - type: explicit.type, - ...(typeof budget === "number" && Number.isFinite(budget) && budget > 0 - ? { budget_tokens: Math.floor(budget) } - : {}), - ...(explicit.type === "adaptive" && typeof explicit.display === "boolean" - ? { display: explicit.display } - : {}), + thinking: { + type: explicit.type, + ...(typeof budget === "number" && Number.isFinite(budget) && budget > 0 + ? { budget_tokens: Math.floor(budget) } + : {}), + ...(explicit.type === "adaptive" && typeof explicit.display === "boolean" + ? { display: explicit.display } + : {}), + }, }; } } - if (req.reasoning_effort === "none") return { type: "disabled" }; - if (isReasoningModel(req.model)) return { type: "enabled" }; - return undefined; -} -function isReasoningModel(model: string): boolean { - return MODELS.some((m) => m.id === model && m.reasoning === true); + return reasoningModel(req.model)?.reasoning === true ? { thinking: { type: "enabled" } } : {}; } function translateToolChoice( diff --git a/src/translator/reasoning-effort.ts b/src/translator/reasoning-effort.ts new file mode 100644 index 0000000..75943e2 --- /dev/null +++ b/src/translator/reasoning-effort.ts @@ -0,0 +1,63 @@ +import { MODELS } from "../provider/models.js"; +import type { CanonicalReasoningEffort, ModelDef, ModelReasoningEffort } from "../provider/types.js"; +import type { AnthropicMessagesRequest } from "./types.js"; + +/** Catalog aliases like `glm-5.3[1m]` are listing-only; upstream modelCode is the base id. */ +export function upstreamModelId(model: string): string { + return model.replace(/\[1m\]$/, ""); +} + +/** Official Coding Plan 1M window is opt-in via a trailing `[1m]` alias. */ +export const CONTEXT_WINDOW_1M = 1_048_576; + +export function catalogContextWindow(modelId: string, definition: ModelDef): number { + return /\[1m\]$/.test(modelId) ? CONTEXT_WINDOW_1M : definition.contextWindow; +} + +export function reasoningModel(model: string): ModelDef | undefined { + return MODELS.find((entry) => entry.id === upstreamModelId(model)); +} + +export function normalizeReasoningEffort( + model: string, + effort: ModelReasoningEffort | undefined, +): CanonicalReasoningEffort | undefined { + const definition = reasoningModel(model); + if (!definition?.reasoningEffortMap || !effort) return undefined; + if (Object.hasOwn(definition.reasoningEffortMap, effort)) { + return definition.reasoningEffortMap[effort]; + } + console.warn(`[reasoning] unknown effort ${JSON.stringify(effort)} for ${definition.id}; using ${definition.defaultReasoningEffort}`); + return definition.defaultReasoningEffort; +} + +export function isForcedReasoning(model: string): boolean { + return reasoningModel(model)?.forcedReasoning === true; +} + +export function isThinkingDisabled(type: unknown): boolean { + return type === false || type === "disabled" || type === "none" || type === "off"; +} + +export function normalizeAnthropicReasoning(req: AnthropicMessagesRequest): AnthropicMessagesRequest { + const effort = normalizeReasoningEffort(req.model, req.output_config?.effort); + if (effort === "none") { + const outputConfig = { ...req.output_config }; + delete outputConfig.effort; + return { + ...req, + thinking: { type: "disabled" }, + ...(Object.keys(outputConfig).length > 0 ? { output_config: outputConfig } : { output_config: undefined }), + }; + } + if (effort) { + return { ...req, thinking: { type: "enabled" }, output_config: { ...req.output_config, effort } }; + } + if (isThinkingDisabled(req.thinking?.type)) { + if (isForcedReasoning(req.model)) { + return { ...req, thinking: { type: "enabled" }, output_config: { ...req.output_config, effort: "low" } }; + } + return req.thinking?.type === "disabled" ? req : { ...req, thinking: { type: "disabled" } }; + } + return req; +} diff --git a/src/translator/responses-to-chat.test.ts b/src/translator/responses-to-chat.test.ts index b848a53..8534839 100644 --- a/src/translator/responses-to-chat.test.ts +++ b/src/translator/responses-to-chat.test.ts @@ -173,8 +173,11 @@ describe("responsesToChatCompletions", () => { expect(assistant.reasoning_content).toBe("thinking..."); }); - it("forwards reasoning.effort to reasoning_effort on the chat request", () => { - const r = responsesToChatCompletions(baseReq({ reasoning: { effort: "high" } })); - expect(r.chatRequest.reasoning_effort).toBe("high"); - }); + it.each(["none", "minimal", "light", "low", "medium", "high", "xhigh", "max", "ultra"] as const)( + "forwards reasoning.effort=%s to reasoning_effort", + (effort) => { + const r = responsesToChatCompletions(baseReq({ reasoning: { effort } })); + expect(r.chatRequest.reasoning_effort).toBe(effort); + }, + ); }); diff --git a/src/translator/responses-types.ts b/src/translator/responses-types.ts index b37d393..242cf6b 100644 --- a/src/translator/responses-types.ts +++ b/src/translator/responses-types.ts @@ -1,3 +1,5 @@ +import type { ReasoningEffort } from "./types.js"; + /** * Type definitions for the OpenAI Responses API (`/v1/responses`). * @@ -20,7 +22,7 @@ /** Reasoning configuration on a Responses request. */ export interface ResponsesReasoning { - effort?: "minimal" | "low" | "medium" | "high"; + effort?: ReasoningEffort; summary?: "auto" | "concise" | "detailed" | "none"; } diff --git a/src/translator/types.ts b/src/translator/types.ts index d727752..ed0eb69 100644 --- a/src/translator/types.ts +++ b/src/translator/types.ts @@ -80,6 +80,9 @@ export interface OpenAIToolDefinition { }; } +export type ReasoningEffort = "none" | "minimal" | "light" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; +export type AnthropicEffort = "low" | "medium" | "high" | "max"; + /** POST /v1/chat/completions request body. */ export interface OpenAIChatRequest { model: string; @@ -99,8 +102,11 @@ export interface OpenAIChatRequest { parallel_tool_calls?: boolean; response_format?: { type: "text" | "json_object" }; seed?: number; - reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; - thinking?: AnthropicThinkingConfig & { budgetTokens?: number }; + reasoning_effort?: ReasoningEffort; + thinking?: Omit & { + type: AnthropicThinkingConfig["type"] | "none" | "off" | boolean; + budgetTokens?: number; + }; } /** Non-streaming response. */ @@ -193,6 +199,7 @@ export interface AnthropicMessagesRequest { tools?: AnthropicToolDefinition[]; tool_choice?: { type: "auto" | "any" | "tool"; name?: string }; thinking?: AnthropicThinkingConfig; + output_config?: { effort?: AnthropicEffort; [key: string]: unknown }; } /** Anthropic thinking/reasoning control. */