Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/async/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
44 changes: 34 additions & 10 deletions src/provider/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
];
22 changes: 8 additions & 14 deletions src/provider/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
8 changes: 8 additions & 0 deletions src/provider/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,4 +29,9 @@ export interface ModelDef {
maxOutputTokens?: number;
/** Whether the model supports reasoning/thinking mode. */
reasoning?: boolean;
reasoningEfforts?: readonly CanonicalReasoningEffort[];
defaultReasoningEffort?: Exclude<CanonicalReasoningEffort, "none">;
reasoningEffortMap?: Partial<Record<ModelReasoningEffort, CanonicalReasoningEffort>>;
forcedReasoning?: boolean;
inputModalities?: readonly ("text" | "image")[];
}
22 changes: 22 additions & 0 deletions src/proxy/body-transformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
13 changes: 12 additions & 1 deletion src/proxy/body-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,6 +33,14 @@ interface TransformContext {
startPlan?: boolean;
}

function applyUpstreamModel(body: Record<string, unknown>): 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.
Expand All @@ -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<string, unknown>);

if (ctx.format === "openai") {
if (ctx.startPlan) {
Expand Down
16 changes: 16 additions & 0 deletions src/proxy/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ async function loadCaptcha(): Promise<CaptchaModule> {
}
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";
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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<AnthropicMessagesResponse>;
Expand Down
18 changes: 18 additions & 0 deletions src/proxy/responses-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> => {
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 });
Expand Down
97 changes: 97 additions & 0 deletions src/proxy/upstream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> => {
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<Response> => {
Expand Down Expand Up @@ -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<Response> => {
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<Response> => {
Expand Down Expand Up @@ -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<Response> => {
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<Response> => {
Expand Down
Loading