import { randomUUID } from "node:crypto"; 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 { 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; type GlmExecuteResult = Awaited> & { 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 || ""; } /** * GLM-5.2 effort tiers 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". * * https://docs.z.ai/devpack/latest-model */ function parseGlm52Effort(model: string): { baseModel: string; effort: "high" | "max" } | null { if (model === "glm-5.2-high") return { baseModel: "glm-5.2", effort: "high" }; if (model === "glm-5.2-max") return { baseModel: "glm-5.2", effort: "max" }; 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; 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 { 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 { 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, }, }; } export function translateSseResponse( response: Response, provider: string, model: string, suppressThinkClose: boolean = false ): Response { if (!response.body) return response; const transform = createSSETransformStreamWithLogger( FORMATS.CLAUDE, FORMATS.OPENAI, provider, null, null, model, null, null, null, null, null, false, suppressThinkClose ); 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 | null, _model?: string, transport: GlmTransport = getGlmTransport(credentials.providerSpecificData) ): Record { 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 = parseGlm52Effort(model); const effectiveModel = effortTier ? effortTier.baseModel : model; const transformed = this.transformRequest(effectiveModel, body, stream, credentials); const record = asRecord(transformed); // 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") { 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 { const credentials = input.credentials; const url = buildGlmChatUrl(credentials?.providerSpecificData, transport, this.config.baseUrl); const headers = this.buildHeaders( credentials, input.stream, input.clientHeaders, input.model, transport ); 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 | 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 { 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") { // Resolve whether the `` close marker should be suppressed for // this client. GLM's Anthropic transport does its own Claude→OpenAI // translation (bypassing chatCore's stream), so we must resolve the flag // here from the original client headers (#5245 / #5312). 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, }); const translatedResponse = input.stream && result.response.ok ? translateSseResponse(result.response, this.provider, input.model, suppressThinkClose) : isJsonResponse(result.response) ? await translateAnthropicJsonResponse(result.response) : result.response; return { ...result, response: translatedResponse, url, headers, transformedBody, targetFormat: FORMATS.OPENAI, }; } return { ...result, url, headers, transformedBody, targetFormat: FORMATS.OPENAI, }; } async execute(input: ExecuteInput): Promise { const effortTier = parseGlm52Effort(input.model); // GLM-5.2 effort tiers route directly through Anthropic transport (no fallback). // Zhipu only graduates effort on the Anthropic endpoint via the // effort-2025-11-24 beta header included in GLM_ANTHROPIC_BETA. if (effortTier) { return this.executeTransport(input, "anthropic"); } 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;