diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index cd2e12c6183..85dbe21c569 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,11 +1,12 @@ import { hasShrinkableOpenAIChatImages, normalizeOpenAIChatImages } from "./openai-chat-images"; +import { protectGlmSummaryBudget, resolveMaxTokens } from "./openai-chat/summary-budget"; import { chatParallelToolCallsWireValue } from "./openai-chat/parallel-tool-calls"; import { applyExplicitChatReasoningWirePolicy } from "./openai-chat/reasoning-wire"; import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types"; import { modelInList } from "../types"; import { createInlineThinkContentSplitter, splitInlineThinkContent } from "./inline-think-tags"; -import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; +import { mapReasoningEffort } from "../reasoning-effort"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; import { isDebugEnabled } from "../lib/debug-settings"; @@ -51,12 +52,6 @@ export { stripBracketedModelSuffix } from "./openai-chat/wire"; export { buildOpenAIChatPassthroughRequest } from "./openai-chat/passthrough"; export { formatOpenAIChatErrorBody } from "./openai-chat/errors"; -function resolveMaxTokens(provider: OcxProviderConfig, parsed: OcxParsedRequest): number | undefined { - return parsed.options.maxOutputTokens - ?? modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId) - ?? provider.defaultMaxOutputTokens; -} - function thinkingBudgetForEffort(parsed: OcxParsedRequest, reasoningEffort: string, maxOutputTokens?: number): number | undefined { if (parsed.options.reasoning === "minimal") return 0; const maxBudget = maxOutputTokens ?? 32768; @@ -150,12 +145,13 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd body.stop = parsed.options.stopSequences; } const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId); - const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); + const requestedEffort = protectGlmSummaryBudget(body) ? "low" : parsed.options.reasoning; + const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, requestedEffort); const explicitReasoning = applyExplicitChatReasoningWirePolicy({ provider, modelId: parsed.modelId, hasTools: !!tools, - requestedEffort: parsed.options.reasoning, + requestedEffort, wireEffort: reasoningEffort, reasoningDisabled, body, diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index cedc86f8c46..9350ada953e 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -1,3 +1,4 @@ +import { protectGlmSummaryBudget } from "./summary-budget"; import { openAIChatTransport, stripBracketedModelSuffix } from "./wire"; import type { AdapterRequest } from "../base"; import { frameAgentRouterMessages } from "../agentrouter"; @@ -72,6 +73,7 @@ export function buildOpenAIChatPassthroughRequest( for (const field of CHAT_PASSTHROUGH_FIELDS) { if (rawBody[field] !== undefined) body[field] = rawBody[field]; } + if (protectGlmSummaryBudget(body)) body.reasoning_effort = "low"; const rawEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; const reasoningDisabled = modelInList(provider.noReasoningModels, modelId) || rawEfforts?.length === 0; if (reasoningDisabled) { diff --git a/src/adapters/openai-chat/summary-budget.ts b/src/adapters/openai-chat/summary-budget.ts new file mode 100644 index 00000000000..82036541be6 --- /dev/null +++ b/src/adapters/openai-chat/summary-budget.ts @@ -0,0 +1,46 @@ +import { modelRecordValue } from "../../reasoning-effort"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../types"; + +export function resolveMaxTokens(provider: OcxProviderConfig, parsed: OcxParsedRequest): number | undefined { + return parsed.options.maxOutputTokens + ?? modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId) + ?? provider.defaultMaxOutputTokens; +} + +function textContent(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (!Array.isArray(value)) return undefined; + const text: string[] = []; + for (const part of value) { + if (!part || part.type !== "text" || typeof part.text !== "string") return undefined; + text.push(part.text); + } + return text.join("\n"); +} + +/** Aside's emergency checkpoint is a standalone summary, not an ordinary short answer. + * Runs at the physical Chat destination, after all combo effort overrides. + */ +export function protectGlmSummaryBudget(body: Record): boolean { + if (typeof body.model !== "string" + || !/^(?:(?:zai|z-ai|zai-org)\/)?glm-5\.3-flash$/i.test(body.model)) return false; + if (body.tools !== undefined && (!Array.isArray(body.tools) || body.tools.length > 0)) return false; + const cap = body.max_completion_tokens ?? body.max_tokens; + if (typeof cap !== "number" || !Number.isInteger(cap) || cap < 1 || cap > 1024) return false; + const messages = body.messages; + if (!Array.isArray(messages) || messages.length !== 2) return false; + const [system, user] = messages; + if (!system || !user || !["system", "developer"].includes(system.role) || user.role !== "user" + || system.tool_calls || user.tool_calls || system.function_call || user.function_call) return false; + const instruction = textContent(system.content); + const transcript = textContent(user.content); + if (instruction === undefined || transcript === undefined) return false; + const summaryInstruction = /\bcontext[-\s]+summari[sz](?:ation|er|ing)\b/i.test(instruction); + const checkpointTranscript = /\b(?:summari[sz]e|summary|checkpoint)\b/i.test(instruction) + && /[\s\S]*<\/conversation>/i.test(transcript); + if (!summaryInstruction && !checkpointTranscript) return false; + // Update both if supplied: gateways differ on which cap takes precedence. + if (body.max_tokens !== undefined) body.max_tokens = 4096; + if (body.max_completion_tokens !== undefined) body.max_completion_tokens = 4096; + return true; +} diff --git a/tests/adapters/openai/openai-chat-glm-summary.test.ts b/tests/adapters/openai/openai-chat-glm-summary.test.ts new file mode 100644 index 00000000000..6a93df3b7d5 --- /dev/null +++ b/tests/adapters/openai/openai-chat-glm-summary.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { chatCompletionsToResponsesBody } from "../../../src/chat/inbound"; +import { concreteComboRequestBody } from "../../../src/combos/request"; +import { parseRequest } from "../../../src/responses/parser"; +import type { OcxProviderConfig } from "../../../src/types"; + +const model = "glm-5.3-flash"; +const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://api.z.ai/api/coding/paas/v4", + reasoningEfforts: ["low", "medium", "high", "max"], +}; +const messages = [ + { role: "system", content: "You are a context-summarization assistant. Produce a checkpoint." }, + { role: "user", content: "User: implement the feature." }, +]; +function bodies(overrides: Record = {}, config = provider) { + const raw = { model, messages, max_tokens: 512, reasoning_effort: "max", ...overrides }; + const parsed = parseRequest(chatCompletionsToResponsesBody(raw)); + return [ + JSON.parse(createOpenAIChatAdapter(config).buildRequest(parsed).body), + JSON.parse(buildOpenAIChatPassthroughRequest(config, raw, String(raw.model), false).body), + ]; +} + +describe("GLM tiny standalone summary compatibility", () => { + test.each([1, 512, 819, 1024])("raises cap %i and lowers effort on both Chat paths", cap => { + for (const body of bodies({ max_tokens: cap })) { + expect(body.max_tokens).toBe(4096); + expect(body.reasoning_effort).toBe("low"); + expect(body.messages).toEqual(messages); + } + }); + test("final adapter wins after successive combo force overrides", () => { + let raw = chatCompletionsToResponsesBody({ model, messages, max_tokens: 819, reasoning_effort: "high" }); + for (const target of [{ provider: "proxy", model: "inner" }, { provider: "zai", model }]) { + raw = concreteComboRequestBody(raw, target, "max", provider.reasoningEfforts, "strict", "force"); + } + const parsed = parseRequest(raw); + parsed.modelId = model; + expect(parsed.options.reasoning).toBe("max"); + const body = JSON.parse(createOpenAIChatAdapter(provider).buildRequest(parsed).body); + expect(body.max_tokens).toBe(4096); + expect(body.reasoning_effort).toBe("low"); + expect(parsed.options.maxOutputTokens).toBe(819); + expect(parsed.options.reasoning).toBe("max"); + }); + test.each([0, -1, 1025, 4096, undefined])("preserves cap outside the mitigation: %s", cap => { + for (const body of bodies({ max_tokens: cap })) { + expect(body.max_tokens).toBe(cap); + expect(body.reasoning_effort).toBe("max"); + } + }); + test("does not change other models, ordinary prompts, tools, or ongoing conversations", () => { + for (const overrides of [ + { model: "glm-5.3" }, { model: "glm-5.3-flashx" }, { model: "gpt-5" }, + { messages: [{ role: "system", content: "Be helpful." }, { role: "user", content: "Summarize this article." }] }, + { messages: [...messages, { role: "assistant", content: "Previous checkpoint" }] }, + { tools: [{ type: "function", function: { name: "read", parameters: { type: "object", properties: {} } } }] }, + ]) for (const body of bodies(overrides)) { + expect(body.max_tokens).toBe(512); + expect(body.reasoning_effort).toBe("max"); + } + }); +});