/** * Upstream request builder — constructs the forwarded HTTP request. * * **`format` semantics**: This is the *upstream* format — the format used to * talk to the upstream LLM provider, not the client's inbound format. Both * ZCode coding-plan and start-plan routes post an Anthropic-format upstream * (since PR #34, 2026-08-29 — start-plan's old OpenAI gateway is retired); * OpenAI clients are translated before this builder is called. * * @see _reverse/NOTEPAD.md "How Credential is Used for LLM Calls" */ import type { Format } from "../translator/types.js"; import type { ProviderDef } from "../provider/types.js"; import type { Credential } from "../auth/types.js"; import type { ProxyIdentity } from "../config/types.js"; import { credentialString } from "../auth/types.js"; import { buildLlmIdentityHeaders } from "./identity.js"; import { buildZcodeTraceHeaders } from "./trace-headers.js"; import { sessionIdForHeader, shouldUseExactTraceHeaders } from "./session-context.js"; export interface UpstreamClientSession { source?: "none" | "explicit" | "lineage"; action: "off" | "observe" | "enforce"; sessionId?: string; upstreamSessionId?: string; /** Explicit x-zcode-session-type override (wins over sessionId-prefix derivation). */ sessionType?: "main" | "subagent" | "other"; requestId?: string; traceId?: string; queryId?: string; } export type UpstreamHeaderPair = [string, string]; const ANTHROPIC_VERSION = "2023-06-01"; /** * SDK UA suffix the real client appends to its User-Agent on LLM calls * (bundle `Cm` merges `ai-sdk/anthropic/${k0o}` into the UA; k0o = "3.0.81"). * Only the LLM request path carries it — control-plane fetches (endpoint * routing, signing gate, claim/billing) keep the bare `ZCode/…` UA. */ const ANTHROPIC_SDK_UA = "ai-sdk/anthropic/3.0.81"; const STARTPLAN_ANTHROPIC_BASE = "https://zcode.z.ai/api/v1/zcode-plan"; /** * Build the upstream URL based on format + plan + provider. * * The `format` parameter is the *upstream* format — callers in handler.ts * pass the format the upstream will receive, which may differ from the * client's inbound format when the proxy is in compatibility mode. */ export function buildUpstreamURL(format: Format, provider: ProviderDef, plan: "coding-plan" | "start-plan" = "coding-plan"): string { if (plan === "start-plan") { // Anthropic-messages gateway used by the live ZCode desktop client for // start-plan (incl. claimed trial plans like the weekend package). // The legacy OpenAI route `${STARTPLAN_ANTHROPIC_BASE}/chat/completions` // returns "404 page not found" since the server-side rollout of this // endpoint (observed 2026-08-28). return `${STARTPLAN_ANTHROPIC_BASE}/anthropic/v1/messages`; } if (format === "anthropic") { return `${provider.anthropicBaseURL}/v1/messages`; } return `${provider.openaiBaseURL}/chat/completions`; } /** * Build auth + identity + trace headers for the upstream LLM request. * * The `format` parameter is the *upstream* format — selects auth scheme: * - Anthropic upstream, coding-plan → `x-api-key: {cred}` AND * `Authorization: Bearer {cred}` (dual headers, bundle `ebo`) + * `anthropic-version` * - Anthropic upstream, start-plan → `Authorization: Bearer {jwt}` + `anthropic-version` * - OpenAI upstream (any plan) → `Authorization: Bearer {cred|jwt}` * * Identity headers come from `buildLlmIdentityHeaders` (bundle 3.12.3 `g6n` * shape); the User-Agent carries the `ai-sdk/anthropic` SDK suffix (bundle * `Cm`/`k0o`). * * Trace/attribution headers mirror the bundle's `Bdt` * ("createModelRequestAttributionHeaders") when an explicit/enforced trace * context exists. Default enforce mode forwards the inferred conversation * session id; sessions without a resolvable id fall back to a synthesized * one (coding-plan must always carry x-session-id — signing requires it). */ export function buildAuthHeaders( format: Format, cred: Credential, identity: ProxyIdentity, plan: "coding-plan" | "start-plan" = "coding-plan", clientSession?: UpstreamClientSession, ): Record { const credStr = plan === "start-plan" && cred.jwt ? cred.jwt : credentialString(cred); const base: Record = { // LLM requests mirror the bundle's 3.12.3 source-headers builder (`g6n`, // no wrapper) — language/timezone always sent with "unknown" fallback, // X-ZCode-Agent inline 8th before the platform headers, no X-Device-Mid. // Context/control-plane fetches use buildIdentityHeaders (`TV`). ...buildLlmIdentityHeaders(identity), ...buildTraceHeaders(plan, clientSession), }; // Bundle `Cm`/`k0o`: the anthropic SDK merges its identity into the UA — // real LLM requests arrive as `ZCode/{ver} ai-sdk/anthropic/3.0.81`. base["User-Agent"] = `${base["User-Agent"]} ${ANTHROPIC_SDK_UA}`; if (format === "anthropic") { if (plan === "start-plan" && cred.jwt) { base["authorization"] = `Bearer ${cred.jwt}`; } else { // Bundle `ebo` (coding-plan, anthropic): x-api-key from the SDK config // AND `Authorization: Bearer {credential}` from the custom-header merge // — both headers, same value. base["x-api-key"] = credStr; base["authorization"] = `Bearer ${credStr}`; } base["anthropic-version"] = ANTHROPIC_VERSION; } else { base["authorization"] = `Bearer ${credStr}`; } return base; } function buildTraceHeaders(plan: "coding-plan" | "start-plan", clientSession?: UpstreamClientSession): Record { if (shouldUseExactTraceHeaders(plan, clientSession)) { // Degenerate bodies (lineage failed) and requestId-only requests resolve // to an exact context with NO session id — coding-plan would then ship // without x-session-id and signing would skip (client-signing requires // it), violating the "coding-plan 必签" invariant. Synthesize like the // fallback branch; start-plan is never signed and keeps the omission. const sessionId = sessionIdForHeader(clientSession) ?? (plan === "coding-plan" ? crypto.randomUUID() : undefined); return buildZcodeTraceHeaders({ requestId: clientSession?.requestId, traceId: clientSession?.traceId, queryId: clientSession?.queryId, sessionId, ...(clientSession?.sessionType ? { sessionType: clientSession.sessionType } : {}), }); } const headers: Record = { "x-request-id": crypto.randomUUID(), // ZCode 3.9.1 attributes every model request; a forwarded conversation turn is the main-agent loop. "x-zcode-session-type": "main", "x-zcode-trace-id": crypto.randomUUID(), }; if (plan !== "start-plan") { headers["x-query-id"] = crypto.randomUUID(); headers["x-session-id"] = crypto.randomUUID(); } return headers; } function collectPassthroughHeaders(req: Request): Record { const result: Record = {}; for (const [key, value] of req.headers.entries()) { const lower = key.toLowerCase(); // Explicit allowlist: client auth, routing auth, trace and V4 signing // headers are never forwarded. Their trusted values are rebuilt below. if (lower === "anthropic-beta") { result[lower] = value; } } return result; } export function buildUpstreamHeaderPairs( clientReq: Request, format: Format, cred: Credential, identity: ProxyIdentity, plan: "coding-plan" | "start-plan" = "coding-plan", extraHeaders?: Record, clientSession?: UpstreamClientSession, ): UpstreamHeaderPair[] { // Forward the client's Accept-Encoding so the upstream compresses only when // the client can decode it. Hardcoding "gzip" broke clients (e.g. some Tauri // builds) that send `accept-encoding: identity` and cannot auto-decompress. const clientAcceptEncoding = clientReq.headers.get("accept-encoding") ?? "gzip"; return [ ["content-type", "application/json"], ["accept-encoding", clientAcceptEncoding], ...Object.entries(collectPassthroughHeaders(clientReq)), ...Object.entries(buildAuthHeaders(format, cred, identity, plan, clientSession)), ...Object.entries(extraHeaders ?? {}), ]; } export function buildUpstreamRequest( clientReq: Request, format: Format, provider: ProviderDef, cred: Credential, body: string | undefined, identity: ProxyIdentity, plan: "coding-plan" | "start-plan" = "coding-plan", extraHeaders?: Record, clientSession?: UpstreamClientSession, ): Request { const url = buildUpstreamURL(format, provider, plan); const headerPairs = buildUpstreamHeaderPairs(clientReq, format, cred, identity, plan, extraHeaders, clientSession); const init: RequestInit = { method: "POST", headers: Object.fromEntries(headerPairs), }; if (body !== undefined) { init.body = body; } return new Request(url, init); }