mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +03:00
* fix(thinking): recognize adaptive thinking + parse/scrub DSML tool-call markers
Two defects combined to break DeepSeek-V4-Flash turns and raise 502
empty_response on Claude Code autocompact.
Defect 1 — DSML tool-call markers leaked as visible content:
DeepSeek-V4-Flash occasionally emits tool calls in a non-standard DSML
text format using full-width pipes instead of the OpenAI tool_calls JSON.
Two shapes appear in production call logs:
- complete block: <|DSML|:Read><path>...</path></|DSML|:Read>
- stray closers (truncated call): </|DSML|parameter></|DSML|invoke>
</|DSML|tool_calls>, sometimes trailing a system-prompt echo
The openai-compatible path never parsed these, so the markers leaked to
the client as visible content and the turn ended incomplete.
Fix: add open-sse/utils/dsmlToolCalls.ts — parseDsmlToolCalls() converts
complete DSML blocks into OpenAI tool_calls and strips stray closing
markers from content (streaming-safe via a holdback for partial openers).
Wire it into the response translator before extractXmlInvokeBlocks so
DSML and XML invoke tool calls share the same pending queue.
Defect 2 — adaptive thinking silently suppressed:
A prior inline === 'enabled' check on body.thinking.type silently
suppressed adaptive (the intent Claude Code actually sends), so
reasoning was dropped. The model then emitted DSML tool-call markers
as plain text, producing an incomplete stop finish. Fix: use
hasActiveClaudeThinking() (which recognizes enabled AND adaptive) to
set requestedThinking, thread it through stream.ts and translator
state, and gate thinking block emission on state.requestedThinking
so upstream reasoning_content only relays when the client opted in.
Tests: 29/29 (6 dsml-tool-calls, 5 thinking-active-claude-adapter,
3 translator-resp-dsml-integration, 15 translator-resp-openai-to-claude
incl. requestedThinking suppression regression). typecheck:core clean.
* fix(sse): strip echoed system-prompt preamble + preserve large analysis/summary blocks
DeepSeek-V4 and similar models echo the OMNIROUTE_SYSTEM_INSTRUCTION_APPEND
directive (appended to the system tail by claude-to-openai.ts) and whole chunks
of the system prompt (<analysis>/<system-reminder>/<summary> blocks, prose
reproductions of the superpowers skill section) verbatim at the START of their
reply — the 'system message leak' persisting after the request-side fix.
Add two streaming-safe preamble strippers in directivePreambleStripper.ts:
- createDirectivePreambleStripper(directive): drops a leading reproduction of
the exact configured directive across arbitrary SSE chunk boundaries.
- createSystemPreambleStripper(): removes <analysis>/<system-reminder>/
<summary> echo blocks and known prose heads (Phase B) from the very start
of a stream, only while the stream is still a preamble.
Wire both into openai-to-claude.ts content-delta path: chain the exact-directive
stripper then the system-echo stripper before DSML/XML-invoke parsing, so a
leading system echo is dropped before it reaches the client.
Preserve large blocks (>= SYSTEM_ECHO_THRESHOLD=1000 chars) and blocks with no
trailing content — these are the model's real response (e.g. a Claude Code
autocompact summary), not a short system-echo. Stops the autocompact
empty-response regression where a whole-summary <analysis> block was stripped
to empty (3a8515).
Regression: origin's markdown-boundary feature (bufferedPrefix /
splitMarkdownBoundary, commit 1b39873ea) is preserved — preamble strip runs
before the markdown buffer rehydration, and the scrubbed content flows into
the existing DSML/XML-invoke/markdown pipeline unchanged.
TDD: tests/unit/directive-preamble-strip.test.ts (7 cases),
system-preamble-strip.test.ts (12 cases incl. 3a8515 regression),
system-preamble-wiring.test.ts (3 integration cases); group F regression
24/24 green; typecheck:core 0 errors.
* fix(sse): gate thinking block on requestedThinking + synthesize text block for reasoning-only responses
Reasoning-content (thinking) blocks were emitted unconditionally to
Claude-format clients, leaking reasoning to thinking-opt-out clients
(Claude Code sends thinking:{type:"disabled"}) — the operator reported
'reasoning is exposed'. On reasoning-only upstream responses (GLM-5.2
autocompact pattern), the unconditional thinking block also caused either
a 502 'no content block' at flush, or — after a text-block fallback — an
autocompact 'empty response' rejection that looped the session forever.
Streaming translator (openai-to-claude.ts):
- Compute hasReasoning outside the emission gate; accumulate into
state._reasoningAccum always (so fix B can fire).
- Gate only the thinking-block EMISSION on requestedThinking === true.
- FIX B at finish: when no text block was started and requestedThinking
!== true, synthesize a text content block from _reasoningAccum so
autocompact can extract the summary (no 502, compact applies).
- Skip fix B when requestedThinking === true to avoid double-exposure
(thinking block + text block both carrying reasoning).
Non-streaming translator (responseTranslator.ts):
- Thread requestedThinking through translateNonStreamingResponse into
convertOpenAINonStreamingToClaude.
- suppressThinking = requestedThinking === false: drop the thinking block
when content is present (no leak); relay reasoning as a text block when
the response is reasoning-only (no 502). requestedThinking === undefined
keeps the legacy 'always a thinking block' relay.
chatCore.ts: pass hasActiveClaudeThinking(body) to the non-stream
translate call (inline, since the shared const is in the stream branch's
temporal dead zone here).
Tests: 25/25 (5 gate-restore, 1 gate-502-repro, 4 nonstream-leak,
15 resp-openai-to-claude incl. requestedThinking suppression regression).
Group E (22) + F (14) regression-free. typecheck:core clean.
* fix(sse): restore requestToolIdentityMap in the Codex CLI responses-translation path
The needsResponsesTranslation branch (openai-responses -> openai, used
when the client also speaks Responses) silently dropped the
requestToolIdentityMap argument to createSSETransformStreamWithLogger
when the requestedThinking parameter was added, reverting the #7936
tool-identity round-trip fix for that branch. The sibling
needsTranslation branch was updated correctly; restore the same
argument here.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* docs(changelog): add the 3 fragments documented in the PR body
The PR body already writes out the changelog.d/ entries for the DSML
parser (Group F), the directive-preamble stripper (Group E), and the
reasoning-gate thinking-leak fix (Group G), but none of the files
existed in the diff. Add them so the release aggregator picks them up.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(sse): realign GLM's positional call after the new requestedThinking parameter
createSSETransformStreamWithLogger gained a new requestedThinking
parameter inserted before customToolNames. glm.ts's translateSseResponse
still called it with the pre-existing positional argument list, so the
new parameter silently absorbed the old customToolNames slot, and the
GLM_STREAM_BUFFER_BYTES tuning value (#12925) landed on
requestToolIdentityMap instead of streamBufferBytes — a TS2345 (number
is not assignable to Map<...> | null) caught by
check:open-sse-typecheck, and a real loss of GLM's 64KB stream buffer
budget. Insert an explicit `undefined` for requestedThinking to restore
the original alignment.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(sse): make the system-preamble stripper opt-in and flush it at stream end
`createSystemPreambleStripper()` was wired DEFAULT-ON and unconditional in the
openai→claude streaming translator, unlike the exact-directive stripper right
above it, which only runs when the operator configured
OMNIROUTE_SYSTEM_INSTRUCTION_APPEND. Cause: the system-echo stripper recognises
its openers by English-prose heuristics ("# Skill usage", "# Verification
Process", <analysis>/<summary>/<system-reminder>), so leaving it always-on made
it mutate the response payload of EVERY openai→claude stream. A legitimate reply
opening with "# Skill usage: how to write one\n\nHere is the guide." lost that
whole section. It is now gated on OMNIROUTE_STRIP_SYSTEM_PREAMBLE=1, mirroring
the directive stripper's opt-in.
Second cause, same feature: neither stripper was ever flushed. Both buffer while
a construct is still undecided — a directive prefix that never completes, an
<analysis> block that never closes — and nothing released that buffer at the end
of the stream. A reply consisting of an unterminated echo block therefore reached
the client as an EMPTY message: the answer was held in the buffer and discarded
with the stripper. Both strippers now expose flush(), the finish handler calls it
for both, and the released text is emitted as a text block. A construct that WAS
finally classified as an echo is not resurrected (the drop is final).
Tests: tests/unit/system-preamble-gate-and-flush.test.ts pins the default-off
contract, the opted-in behaviour, the flush for both strippers (unit + wiring),
and the no-resurrection guard. system-preamble-wiring.test.ts now opts in
explicitly, since it exercises the stripping path.
* fix(sse): thread the client's thinking intent into the non-streaming path
The streaming and non-streaming translators disagreed on the default meaning of
`requestedThinking`, so the SAME request produced different shapes depending on
`stream`. Cause: chatCore computes the client's intent
(hasActiveClaudeThinking) and threads it into the SSE translator, which relays
reasoning as a thinking block only when it is explicitly `true` — but NO caller
ever passed it to translateNonStreamingResponse(). The non-streaming
OpenAI→Claude conversion therefore only ever saw `undefined`, its legacy
"always relay a thinking block" default, and leaked reasoning to a client that
had opted out with `thinking: {"type":"disabled"}`. The streaming plumbing also
coerced an omitted value into an explicit `false`, hiding the divergence behind
two different spellings of "no intent".
Fix (least destructive of the options): do NOT flip either gate — both encode a
deliberate, regression-tested contract — but give the non-streaming path the
same input the streaming path already has. runNonStreamingProviderLeg owns the
client body (`sourceBody`), so it computes the intent with the very same helper
and passes it down through translateNonStreamingClientResponse. `undefined`
keeps its documented back-compat relay for callers that cannot express intent
(issue-7856 / issue-6623), and stream.ts no longer defaults the parameter to
`false`, so "absent" now means the same thing in both signatures.
No content is lost by the suppression: a reasoning-ONLY response is still
relayed as an ordinary text block (no empty response, no 502) — exactly what the
streaming finish handler does.
Tests: tests/unit/nonstream-requested-thinking-parity.test.ts drives the real
provider leg with thinking disabled / enabled / adaptive.
---------
Co-authored-by: Jihyun Son <jihyun.son@sk.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
625 lines
22 KiB
TypeScript
625 lines
22 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { KeyHealth } from "../services/apiKeyRotator.ts";
|
|
|
|
import { DefaultExecutor } from "./default.ts";
|
|
import {
|
|
applyConfiguredUserAgent,
|
|
mergeAbortSignals,
|
|
mergeUpstreamExtraHeaders,
|
|
type CountTokensInput,
|
|
type ExecuteInput,
|
|
type ProviderCredentials,
|
|
} from "./base.ts";
|
|
import {
|
|
buildGlmBaseHeaders,
|
|
buildGlmChatUrl,
|
|
buildGlmCodingHeaders,
|
|
buildGlmCountTokensUrl,
|
|
GLM_COUNT_TOKENS_TIMEOUT_MS,
|
|
type GlmTransport,
|
|
getGlmTransport,
|
|
} from "../config/glmProvider.ts";
|
|
import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
|
|
import { stripUnsupportedParams } from "../translator/paramSupport.ts";
|
|
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
|
|
import { CLAUDE_CLI_STAINLESS_PACKAGE_VERSION } from "../config/anthropicHeaders.ts";
|
|
import {
|
|
getRuntimeVersion,
|
|
normalizeStainlessArch,
|
|
normalizeStainlessPlatform,
|
|
} from "../config/providerHeaderProfiles.ts";
|
|
import { translateNonStreamingResponse } from "../handlers/responseTranslator.ts";
|
|
import { translateRequest } from "../translator/index.ts";
|
|
import { FORMATS } from "../translator/formats.ts";
|
|
import { createSSETransformStreamWithLogger } from "../utils/stream.ts";
|
|
import { ensureStreamReadiness } from "../utils/streamReadiness.ts";
|
|
import { STREAM_READINESS_TIMEOUT_MS } from "../config/constants.ts";
|
|
import { resolveSuppressThinkClose, THINKING_MARKER_HEADER } from "../utils/thinkCloseMarker.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
type GlmExecuteResult = Awaited<ReturnType<DefaultExecutor["execute"]>> & {
|
|
targetFormat?: string;
|
|
};
|
|
|
|
function asRecord(value: unknown): JsonRecord | null {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
|
|
}
|
|
|
|
function getEffectiveKey(credentials: ProviderCredentials): string {
|
|
const extraKeys = (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
|
|
if (credentials.apiKey && credentials.connectionId && extraKeys.length > 0) {
|
|
return getRotatingApiKey(credentials.connectionId, credentials.apiKey, extraKeys);
|
|
}
|
|
return credentials.apiKey || credentials.accessToken || "";
|
|
}
|
|
|
|
export type GlmEffortLevel = "low" | "high" | "max";
|
|
|
|
type GlmEffortTier = {
|
|
baseModel: string;
|
|
effort: GlmEffortLevel;
|
|
/** Transport where the upstream honors the effort selector for this family. */
|
|
transport: GlmTransport;
|
|
};
|
|
|
|
/**
|
|
* GLM-5.2 effort tiers (glm-5.2-high/-max) route exclusively through the
|
|
* Anthropic transport, where Zhipu maps Claude Code effort selectors (high/max)
|
|
* to reasoning intensity. The base model ID sent upstream is always "glm-5.2".
|
|
*
|
|
* GLM-5.3 replaced tier endpoints with a documented `reasoning_effort` request
|
|
* parameter (low|high|max, default max) on the coding chat/completions endpoint,
|
|
* so its tiers stay on the OpenAI transport and inject `reasoning_effort` +
|
|
* `thinking.type=enabled` (5.3 no longer accepts thinking disabled).
|
|
*
|
|
* https://docs.z.ai/devpack/latest-model
|
|
* https://docs.z.ai/guides/llm/glm-5.3
|
|
*/
|
|
function parseGlmEffortTier(model: string): GlmEffortTier | null {
|
|
switch (model) {
|
|
case "glm-5.2-high":
|
|
return { baseModel: "glm-5.2", effort: "high", transport: "anthropic" };
|
|
case "glm-5.2-max":
|
|
return { baseModel: "glm-5.2", effort: "max", transport: "anthropic" };
|
|
case "glm-5.3-high":
|
|
return { baseModel: "glm-5.3", effort: "high", transport: "openai" };
|
|
case "glm-5.3-low":
|
|
return { baseModel: "glm-5.3", effort: "low", transport: "openai" };
|
|
case "glm-5.3-max":
|
|
return { baseModel: "glm-5.3", effort: "max", transport: "openai" };
|
|
case "glm-5.3-flash-high":
|
|
return { baseModel: "glm-5.3-flash", effort: "high", transport: "openai" };
|
|
case "glm-5.3-flash-low":
|
|
return { baseModel: "glm-5.3-flash", effort: "low", transport: "openai" };
|
|
case "glm-5.3-flash-max":
|
|
return { baseModel: "glm-5.3-flash", effort: "max", transport: "openai" };
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detects GLM models that support deep thinking (5.2+).
|
|
* These models share a single max_tokens budget for reasoning + response
|
|
* (Z.AI does not document a separate thinking budget). When the client
|
|
* doesn't explicitly request max_tokens, we default to the model's full
|
|
* output capacity so reasoning isn't truncated by a low generic default.
|
|
*
|
|
* To add future models (e.g. glm-5.3, glm-5.4), just extend the regex.
|
|
* https://docs.z.ai/guides/overview/concept-param
|
|
*/
|
|
const GLM_THINKING_MODEL_PATTERN = /^glm-5\.(?:[2-9]|\d{2,})/i;
|
|
const GLM_53_OR_HIGHER_PATTERN = /^glm-5\.(?:[3-9]|\d{2,})/i;
|
|
|
|
function isGlmThinkingModel(model: string): boolean {
|
|
return GLM_THINKING_MODEL_PATTERN.test(model);
|
|
}
|
|
|
|
/**
|
|
* Z.AI's official max output for GLM-5.2+ is 131072 tokens (128K).
|
|
* This budget covers BOTH reasoning and the final response.
|
|
* https://z.ai/blog/glm-5.2
|
|
*/
|
|
const GLM_THINKING_DEFAULT_MAX_TOKENS = 131072;
|
|
|
|
function applyGlmRequestDefaults(body: unknown, defaults?: JsonRecord | null): unknown {
|
|
const record = asRecord(body);
|
|
if (!record || !defaults) return body;
|
|
|
|
const next = { ...(applyProviderRequestDefaults(record, defaults) as JsonRecord) };
|
|
const thinkingType = typeof defaults.thinkingType === "string" ? defaults.thinkingType : null;
|
|
|
|
if (thinkingType && next.thinking === undefined) {
|
|
next.thinking = { type: thinkingType };
|
|
} else if (thinkingType && asRecord(next.thinking)?.type === "enabled") {
|
|
next.thinking = { ...asRecord(next.thinking), type: thinkingType };
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
function hasTools(body: unknown): boolean {
|
|
const record = asRecord(body);
|
|
return Array.isArray(record?.tools) && record.tools.length > 0;
|
|
}
|
|
|
|
function isRetryableGlmFallbackStatus(status: number): boolean {
|
|
return status === 404 || status === 408 || status === 409 || status === 429 || status >= 500;
|
|
}
|
|
|
|
function isRetryableGlmFallbackError(error: unknown): boolean {
|
|
if (!error) return false;
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
if (err.name === "AbortError") return false;
|
|
return true;
|
|
}
|
|
|
|
function cloneHeaders(headers: Headers): Headers {
|
|
const next = new Headers();
|
|
headers.forEach((value, key) => next.set(key, value));
|
|
return next;
|
|
}
|
|
|
|
function isJsonResponse(response: Response): boolean {
|
|
return (response.headers.get("content-type") || "").toLowerCase().includes("application/json");
|
|
}
|
|
|
|
async function translateJsonResponse(response: Response): Promise<Response> {
|
|
const parsed = await response.json().catch(() => null);
|
|
const translated = translateNonStreamingResponse(parsed, FORMATS.CLAUDE, FORMATS.OPENAI);
|
|
const headers = cloneHeaders(response.headers);
|
|
headers.set("content-type", "application/json");
|
|
headers.delete("content-length");
|
|
return new Response(JSON.stringify(translated), {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
async function translateAnthropicJsonResponse(response: Response): Promise<Response> {
|
|
const parsed = await response.json().catch(() => null);
|
|
const translated = response.ok
|
|
? translateNonStreamingResponse(parsed, FORMATS.CLAUDE, FORMATS.OPENAI)
|
|
: translateAnthropicJsonError(parsed);
|
|
const headers = cloneHeaders(response.headers);
|
|
headers.set("content-type", "application/json");
|
|
headers.delete("content-length");
|
|
return new Response(JSON.stringify(translated), {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
function translateAnthropicJsonError(parsed: unknown): JsonRecord {
|
|
const root = asRecord(parsed) || {};
|
|
const error = asRecord(root.error) || root;
|
|
const message =
|
|
typeof error.message === "string" && error.message.trim()
|
|
? error.message
|
|
: typeof root.message === "string" && root.message.trim()
|
|
? root.message
|
|
: "GLM Anthropic transport error";
|
|
const type =
|
|
typeof error.type === "string" && error.type.trim()
|
|
? error.type
|
|
: typeof root.type === "string" && root.type.trim()
|
|
? root.type
|
|
: "upstream_error";
|
|
|
|
return {
|
|
error: {
|
|
message,
|
|
type,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 64 KB queue budget for GLM streaming (#12179, wired through in #12925). */
|
|
const GLM_STREAM_BUFFER_BYTES = 65536;
|
|
|
|
export function translateSseResponse(
|
|
response: Response,
|
|
provider: string,
|
|
model: string,
|
|
suppressThinkClose: boolean = false
|
|
): Response {
|
|
if (!response.body) return response;
|
|
// GLM is a high-throughput provider: a 64 KB queue budget keeps provider ->
|
|
// client pacing ahead of the model's emission rate. #12179 asked for this by
|
|
// passing a 16th positional the helper did not take (a TS2554 that never
|
|
// reached the TransformStream); the helper now accepts it as its last
|
|
// parameter, so the request finally takes effect (#12925).
|
|
const transform = createSSETransformStreamWithLogger(
|
|
FORMATS.CLAUDE,
|
|
FORMATS.OPENAI,
|
|
provider,
|
|
null,
|
|
null,
|
|
model,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
false,
|
|
suppressThinkClose,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
GLM_STREAM_BUFFER_BYTES
|
|
);
|
|
const headers = cloneHeaders(response.headers);
|
|
headers.set("content-type", "text/event-stream");
|
|
headers.delete("content-length");
|
|
return new Response(response.body.pipeThrough(transform), {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
export class GlmExecutor extends DefaultExecutor {
|
|
constructor(provider = "glm") {
|
|
super(provider);
|
|
}
|
|
|
|
buildUrl(
|
|
_model: string,
|
|
_stream: boolean,
|
|
_urlIndex = 0,
|
|
credentials: ProviderCredentials | null = null
|
|
) {
|
|
const primaryTransport = getGlmTransport(credentials?.providerSpecificData);
|
|
const transport =
|
|
_urlIndex === 1 ? (primaryTransport === "openai" ? "anthropic" : "openai") : primaryTransport;
|
|
return buildGlmChatUrl(credentials?.providerSpecificData, transport, this.config.baseUrl);
|
|
}
|
|
|
|
buildCountTokensUrl(_model: string, credentials: ProviderCredentials | null = null) {
|
|
return buildGlmCountTokensUrl(credentials?.providerSpecificData, this.config.baseUrl);
|
|
}
|
|
|
|
getCountTokensTimeoutMs() {
|
|
return GLM_COUNT_TOKENS_TIMEOUT_MS;
|
|
}
|
|
|
|
buildHeaders(
|
|
credentials: ProviderCredentials,
|
|
stream = true,
|
|
_clientHeaders?: Record<string, string> | null,
|
|
_model?: string,
|
|
_health?: unknown,
|
|
_body?: unknown
|
|
): Record<string, string> {
|
|
const transport: GlmTransport = getGlmTransport(credentials.providerSpecificData);
|
|
if (transport === "openai") {
|
|
return buildGlmCodingHeaders(getEffectiveKey(credentials), stream);
|
|
}
|
|
|
|
return {
|
|
...buildGlmBaseHeaders(getEffectiveKey(credentials), stream),
|
|
"X-Stainless-Arch": normalizeStainlessArch(),
|
|
"X-Stainless-OS": normalizeStainlessPlatform(),
|
|
"X-Stainless-Runtime-Version": getRuntimeVersion(),
|
|
"X-Stainless-Package-Version": CLAUDE_CLI_STAINLESS_PACKAGE_VERSION,
|
|
"X-Claude-Code-Session-Id": randomUUID(),
|
|
"x-client-request-id": randomUUID(),
|
|
};
|
|
}
|
|
|
|
transformRequest(
|
|
model: string,
|
|
body: unknown,
|
|
stream: boolean,
|
|
credentials: ProviderCredentials
|
|
) {
|
|
const cleanedBody = super.transformRequest(model, body, stream, credentials);
|
|
return applyGlmRequestDefaults(cleanedBody, this.config.requestDefaults as JsonRecord | null);
|
|
}
|
|
|
|
transformForTransport(
|
|
model: string,
|
|
body: unknown,
|
|
stream: boolean,
|
|
credentials: ProviderCredentials,
|
|
transport: GlmTransport
|
|
) {
|
|
const effortTier = parseGlmEffortTier(model);
|
|
const effectiveModel = effortTier ? effortTier.baseModel : model;
|
|
|
|
const transformed = this.transformRequest(effectiveModel, body, stream, credentials);
|
|
const record = asRecord(transformed);
|
|
|
|
// #7364: unlike DefaultExecutor.execute() (default.ts), GlmExecutor.execute()
|
|
// never calls the base execute() loop — it drives its own fetch via
|
|
// executeTransport()/transformForTransport() — so stripUnsupportedParams()
|
|
// (normally applied at default.ts's execute() call site) never ran for GLM
|
|
// requests. Without this call, a STRIP_RULES clamp entry for provider "glm"
|
|
// (e.g. the glm-4.6v max_tokens ceiling) would be silently dead code.
|
|
if (record) stripUnsupportedParams(this.provider, effectiveModel, record);
|
|
|
|
// Ensure upstream receives the base model ID, not the effort-suffixed alias
|
|
if (record && effortTier) {
|
|
record.model = effectiveModel;
|
|
}
|
|
|
|
// GLM-5.2+ models share a single max_tokens budget for reasoning + response.
|
|
// When the client doesn't explicitly set max_tokens, default to the model's
|
|
// full output capacity (131072) so deep reasoning isn't truncated by the
|
|
// generic translator defaults (64000 for Anthropic, 16384 for OpenAI).
|
|
// This acts as the "transparent proxy override" described in Z.AI's own
|
|
// Terminal-Bench evaluation methodology.
|
|
// https://huggingface.co/blog/zai-org/glm-52-blog
|
|
if (record && isGlmThinkingModel(effectiveModel)) {
|
|
const clientBody = asRecord(body);
|
|
const clientMaxTokens = clientBody?.max_tokens ?? clientBody?.max_completion_tokens;
|
|
if (!clientMaxTokens) {
|
|
record.max_tokens = GLM_THINKING_DEFAULT_MAX_TOKENS;
|
|
}
|
|
}
|
|
|
|
if (transport === "openai") {
|
|
// GLM-5.3+ rejects thinking.type "disabled". Ensure thinking is enabled
|
|
// when targeting GLM-5.3 or higher.
|
|
if (record && GLM_53_OR_HIGHER_PATTERN.test(effectiveModel)) {
|
|
const existingThinking = asRecord(record.thinking);
|
|
if (existingThinking?.type === "disabled") {
|
|
record.thinking = { ...existingThinking, type: "enabled" };
|
|
}
|
|
}
|
|
|
|
// GLM-5.3 effort tiers: inject the documented `reasoning_effort` param and
|
|
// force thinking on — 5.3 rejects thinking.type "disabled", and an effort
|
|
// tier without thinking would silently drop the selector upstream.
|
|
if (record && effortTier && effortTier.transport === "openai") {
|
|
const existingThinking = asRecord(record.thinking);
|
|
record.thinking = { ...existingThinking, type: "enabled" };
|
|
record.reasoning_effort = effortTier.effort;
|
|
}
|
|
if (record && stream && hasTools(record) && record.tool_stream === undefined) {
|
|
return { ...record, tool_stream: true };
|
|
}
|
|
return transformed;
|
|
}
|
|
|
|
const translated = translateRequest(
|
|
FORMATS.OPENAI,
|
|
FORMATS.CLAUDE,
|
|
effectiveModel,
|
|
{ ...(record ?? {}), _disableToolPrefix: true },
|
|
stream,
|
|
credentials,
|
|
this.provider,
|
|
null,
|
|
{ preserveCacheControl: false }
|
|
);
|
|
|
|
// Inject effort and thinking for the Anthropic transport.
|
|
// Zhipu's Anthropic endpoint requires thinking.type=enabled to emit
|
|
// thinking_delta blocks in the SSE response. Without it, reasoning is
|
|
// not surfaced and clients see no thinking content.
|
|
// The effort-2025-11-24 beta header (in GLM_ANTHROPIC_BETA) carries
|
|
// the high/max intensity selector.
|
|
if (effortTier) {
|
|
const translatedRecord = asRecord(translated);
|
|
if (translatedRecord) {
|
|
translatedRecord.effort = effortTier.effort;
|
|
// Zhipu's Anthropic endpoint only supports thinking.type
|
|
// "enabled"/"disabled" — not "adaptive". Clients like Claude Code
|
|
// default to "adaptive" for reasoning models, so force "enabled"
|
|
// here while preserving any other fields (e.g. budget_tokens).
|
|
const existingThinking = asRecord(translatedRecord.thinking);
|
|
if (!existingThinking || existingThinking.type !== "enabled") {
|
|
translatedRecord.thinking = {
|
|
...existingThinking,
|
|
type: "enabled",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return translated;
|
|
}
|
|
|
|
private async executeTransport(
|
|
input: ExecuteInput,
|
|
transport: GlmTransport
|
|
): Promise<GlmExecuteResult> {
|
|
const credentials = input.credentials;
|
|
const url = buildGlmChatUrl(credentials?.providerSpecificData, transport, this.config.baseUrl);
|
|
// #10798 moved the transport out of buildHeaders' signature; the Anthropic
|
|
// transport must therefore be visible to buildHeaders through
|
|
// providerSpecificData (primaryTransport / anthropic-shaped baseUrl).
|
|
const headers =
|
|
transport === "anthropic"
|
|
? this.buildHeaders(
|
|
{
|
|
...credentials,
|
|
providerSpecificData: {
|
|
...credentials?.providerSpecificData,
|
|
primaryTransport: "anthropic",
|
|
},
|
|
},
|
|
input.stream,
|
|
input.clientHeaders,
|
|
input.model
|
|
)
|
|
: this.buildHeaders(credentials, input.stream, input.clientHeaders, input.model);
|
|
applyConfiguredUserAgent(headers, credentials.providerSpecificData);
|
|
mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders);
|
|
|
|
const transformedBody = this.transformForTransport(
|
|
input.model,
|
|
input.body,
|
|
input.stream,
|
|
credentials,
|
|
transport
|
|
);
|
|
|
|
const fetchStartTimeoutMs = this.getTimeoutMs();
|
|
const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null;
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
if (timeoutController) {
|
|
timeoutId = setTimeout(() => {
|
|
const timeoutError = new Error(`Fetch timeout after ${fetchStartTimeoutMs}ms on ${url}`);
|
|
timeoutError.name = "TimeoutError";
|
|
timeoutController.abort(timeoutError);
|
|
}, fetchStartTimeoutMs);
|
|
}
|
|
|
|
const timeoutSignal = timeoutController?.signal ?? null;
|
|
const combinedSignal =
|
|
input.signal && timeoutSignal
|
|
? mergeAbortSignals(input.signal, timeoutSignal)
|
|
: input.signal || timeoutSignal;
|
|
|
|
let response: Response;
|
|
try {
|
|
this.assertOutboundUrlAllowed(url); // GHSA-4f49: glm has its own fetch path
|
|
response = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(transformedBody),
|
|
signal: combinedSignal || undefined,
|
|
});
|
|
} finally {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
}
|
|
|
|
if (input.stream && response.ok) {
|
|
const readiness = await ensureStreamReadiness(response, {
|
|
timeoutMs: STREAM_READINESS_TIMEOUT_MS,
|
|
provider: this.provider,
|
|
model: input.model,
|
|
log: input.log,
|
|
});
|
|
response = readiness.response;
|
|
}
|
|
|
|
const result = { response, url, headers, transformedBody };
|
|
|
|
if (transport === "anthropic") {
|
|
return this.finalizeAnthropicTransportResult(input, result);
|
|
}
|
|
|
|
return {
|
|
...result,
|
|
url,
|
|
headers,
|
|
transformedBody,
|
|
targetFormat: FORMATS.OPENAI,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* GLM's Anthropic transport does its own Claude→OpenAI translation
|
|
* (bypassing chatCore's stream), so the `</think>` close-marker
|
|
* suppression flag and the response translation both have to be resolved
|
|
* here from the original client headers (#5245 / #5312). Extracted from
|
|
* `executeTransport` to keep that method's cyclomatic complexity under the
|
|
* project cap.
|
|
*/
|
|
private async finalizeAnthropicTransportResult(
|
|
input: ExecuteInput,
|
|
result: {
|
|
response: Response;
|
|
url: string;
|
|
headers: Record<string, string>;
|
|
transformedBody: unknown;
|
|
}
|
|
): Promise<GlmExecuteResult> {
|
|
const { response: rawResponse, url, headers, transformedBody } = result;
|
|
const clientHeaders = input.clientHeaders ?? {};
|
|
const suppressThinkClose = resolveSuppressThinkClose({
|
|
userAgent: clientHeaders["user-agent"] ?? clientHeaders["User-Agent"] ?? null,
|
|
thinkingMarkerHeader:
|
|
clientHeaders[THINKING_MARKER_HEADER] ??
|
|
clientHeaders["x-omniroute-thinking-marker"] ??
|
|
null,
|
|
clientResponseFormat: input.clientResponseFormat ?? null,
|
|
});
|
|
|
|
const translatedResponse =
|
|
input.stream && rawResponse.ok
|
|
? translateSseResponse(rawResponse, this.provider, input.model, suppressThinkClose)
|
|
: isJsonResponse(rawResponse)
|
|
? await translateAnthropicJsonResponse(rawResponse)
|
|
: rawResponse;
|
|
return {
|
|
response: translatedResponse,
|
|
url,
|
|
headers,
|
|
transformedBody,
|
|
targetFormat: FORMATS.OPENAI,
|
|
};
|
|
}
|
|
|
|
async execute(input: ExecuteInput): Promise<GlmExecuteResult> {
|
|
const effortTier = parseGlmEffortTier(input.model);
|
|
|
|
// Effort tiers route directly through their family's transport (no fallback):
|
|
// GLM-5.2 → Anthropic (Zhipu only graduates effort there, via the
|
|
// effort-2025-11-24 beta header in GLM_ANTHROPIC_BETA); GLM-5.3 → OpenAI
|
|
// coding endpoint (`reasoning_effort` param). See parseGlmEffortTier.
|
|
if (effortTier) {
|
|
return this.executeTransport(input, effortTier.transport);
|
|
}
|
|
|
|
const primaryTransport = getGlmTransport(
|
|
input.credentials.providerSpecificData,
|
|
this.config.baseUrl
|
|
);
|
|
const fallbackTransport: GlmTransport = primaryTransport === "openai" ? "anthropic" : "openai";
|
|
|
|
let primaryResult: GlmExecuteResult | null = null;
|
|
try {
|
|
primaryResult = await this.executeTransport(input, primaryTransport);
|
|
if (!isRetryableGlmFallbackStatus(primaryResult.response.status)) {
|
|
return primaryResult;
|
|
}
|
|
input.log?.debug?.(
|
|
"GLM_FALLBACK",
|
|
`${primaryTransport} returned ${primaryResult.response.status}; trying ${fallbackTransport}`
|
|
);
|
|
} catch (error) {
|
|
if (!isRetryableGlmFallbackError(error)) throw error;
|
|
input.log?.debug?.(
|
|
"GLM_FALLBACK",
|
|
`${primaryTransport} error (${error instanceof Error ? error.message : String(error)}); trying ${fallbackTransport}`
|
|
);
|
|
}
|
|
|
|
try {
|
|
const fallbackResult = await this.executeTransport(input, fallbackTransport);
|
|
if (fallbackResult.response.ok || !primaryResult) {
|
|
return fallbackResult;
|
|
}
|
|
} catch (error) {
|
|
if (!primaryResult) throw error;
|
|
input.log?.debug?.(
|
|
"GLM_FALLBACK",
|
|
`${fallbackTransport} fallback failed (${error instanceof Error ? error.message : String(error)}); returning primary response`
|
|
);
|
|
}
|
|
|
|
return primaryResult;
|
|
}
|
|
|
|
async countTokens(input: CountTokensInput) {
|
|
return super.countTokens({
|
|
...input,
|
|
credentials: {
|
|
...input.credentials,
|
|
providerSpecificData: {
|
|
...(input.credentials.providerSpecificData || {}),
|
|
primaryTransport: "anthropic",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
export default GlmExecutor;
|