diff --git a/Dockerfile b/Dockerfile index 0430290..5d5b2a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,19 +31,23 @@ FROM node:24-alpine AS production WORKDIR /app -# Copy package files -COPY package*.json ./ +# Copy package metadata +COPY --from=builder /app/package.json ./package.json -# Install production dependencies only -RUN npm ci --only=production +# Reuse the dependencies the builder already installed (npm ci installed `pg` +# and the generated Prisma client). This avoids the flaky `npm ci --only=production` +# reinstall that was dropping `pg` from the final image. +COPY --from=builder /app/node_modules ./node_modules # Copy built files from builder COPY --from=builder /app/dist ./dist -# Copy Prisma files and generated client +# Copy Prisma files COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./ -COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma + +# Fail the build loudly if a runtime dependency is missing +RUN node -e "require.resolve('pg'); console.log('dependency check: pg OK')" # Create non-root user RUN addgroup -g 1001 -S nodejs && \ diff --git a/src/index.ts b/src/index.ts index 15eaac3..33b15b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,9 @@ const DEFAULT_CONFIG: ProxyConfig = { ), enabled: process.env.CLAUDE_SUBSCRIPTION_ENABLED !== "false", oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN || "", + refreshUrl: process.env.CLAUDE_OAUTH_REFRESH_URL || "https://console.anthropic.com/v1/oauth/token", + clientId: process.env.CLAUDE_OAUTH_CLIENT_ID || "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + refreshSkewMs: parseInt(process.env.CLAUDE_OAUTH_REFRESH_SKEW_MS || "300000", 10), // refresh when <5min to expiry }, modelFallbackMap: { // Anthropic Claude models (use directly) @@ -239,6 +242,9 @@ export class ClaudeCodeProxy { private providerHealth: ProviderHealth; private requestCounter = 0; private subscriptionCooldownUntil = 0; + // Single-flight guard: concurrent requests await the same in-flight token refresh + // instead of each redeeming the refresh token (which rotates on every use). + private oauthRefreshInFlight: Promise | null = null; constructor(config: Partial = {}) { this.config = this.mergeConfig(config); @@ -333,6 +339,37 @@ export class ClaudeCodeProxy { return `${providerName} ▸ ${modelDisplay} ▸ ${status}`; } + private readClaudeCredentialsFile(retries = 3): { + raw: Record; + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + } | undefined { + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const text = fs.readFileSync(this.config.claudeSubscription.credentialsPath, "utf-8"); + const raw = JSON.parse(text) as Record; + const oauth = (raw?.claudeAiOauth ?? {}) as Record; + return { + raw, + accessToken: oauth.accessToken as string | undefined, + refreshToken: oauth.refreshToken as string | undefined, + expiresAt: oauth.expiresAt as number | undefined, + }; + } catch (err) { + // JSON parse failure = file mid-write race; wait 50ms and retry + if (err instanceof SyntaxError && attempt < retries) { + // Busy-wait briefly without async to keep the read path simple + const until = Date.now() + 50; + while (Date.now() < until) { /* spin */ } + continue; + } + return undefined; + } + } + return undefined; + } + private async readClaudeOAuthToken(retries = 3): Promise { // A static long-lived token (`claude setup-token`) takes precedence over the // credentials file. It has no readable expiry, so treat it as always-valid; @@ -340,25 +377,164 @@ export class ClaudeCodeProxy { const staticToken = this.config.claudeSubscription.oauthToken; if (staticToken) return staticToken; - for (let attempt = 0; attempt <= retries; attempt++) { + const creds = this.readClaudeCredentialsFile(retries); + if (!creds) return undefined; + + const { accessToken, refreshToken, expiresAt } = creds; + const skewMs = this.config.claudeSubscription.refreshSkewMs ?? 300000; + + // Proactively refresh when the token is expired or within the skew window, + // provided we have a refresh token to redeem. + if (expiresAt && Date.now() > expiresAt - skewMs) { + if (refreshToken) { + const expired = Date.now() > expiresAt; + this.logger.warn( + `Claude subscription OAuth token ${expired ? "has expired" : "near expiry"} — refreshing` + ); + const refreshed = await this.refreshClaudeOAuthToken(refreshToken); + if (refreshed) return refreshed; + // Refresh failed: if still within validity, the current token may work; else give up. + if (expired) return undefined; + return accessToken; + } + if (Date.now() > expiresAt) { + this.logger.warn("Claude subscription OAuth token has expired (no refresh token available)"); + return undefined; + } + } + + return accessToken; + } + + /** + * Force an OAuth refresh regardless of the cached token's expiry. Used at the + * 401 boundary, where the access token may have been revoked server-side while + * still appearing valid locally. A static token cannot be refreshed. + */ + private async forceRefreshClaudeOAuthToken(): Promise { + if (this.config.claudeSubscription.oauthToken) return undefined; + const creds = this.readClaudeCredentialsFile(); + if (!creds?.refreshToken) { + this.logger.warn("Claude subscription 401 — no refresh token available to recover"); + return undefined; + } + return this.refreshClaudeOAuthToken(creds.refreshToken); + } + + /** + * Redeem the OAuth refresh token for a fresh access token and persist the + * rotated credentials back to the credentials file. Anthropic rotates the + * refresh token on every use, so all returned fields must be written back or + * the next refresh will fail. A single-flight mutex ensures concurrent callers + * share one refresh rather than racing (and invalidating each other's tokens). + */ + private async refreshClaudeOAuthToken(refreshToken: string): Promise { + if (this.oauthRefreshInFlight) return this.oauthRefreshInFlight; + + this.oauthRefreshInFlight = (async () => { + const refreshUrl = this.config.claudeSubscription.refreshUrl + || "https://console.anthropic.com/v1/oauth/token"; + const clientId = this.config.claudeSubscription.clientId + || "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; + try { - const raw = fs.readFileSync(this.config.claudeSubscription.credentialsPath, "utf-8"); - const creds = JSON.parse(raw); - const token = creds?.claudeAiOauth?.accessToken as string | undefined; - const expiresAt = creds?.claudeAiOauth?.expiresAt as number | undefined; - if (expiresAt && Date.now() > expiresAt) { - this.logger.warn("Claude subscription OAuth token has expired"); + const body = JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + }); + const res = await this.httpRequest( + refreshUrl, + { + method: "POST", + headers: { + "content-type": "application/json", + "accept": "application/json", + "content-length": Buffer.byteLength(body).toString(), + }, + body, + }, + 15000, // 15s — keep refresh snappy so it doesn't stall a request + 0 // no internal retries; a failed refresh falls through to next provider + ); + + if (res.status < 200 || res.status >= 300) { + this.logger.error( + `Claude OAuth refresh failed: HTTP ${res.status} ${res.body.toString().slice(0, 200)}` + ); return undefined; } - return token; - } catch (err) { - // JSON parse failure = file mid-write race; wait 50ms and retry - if (err instanceof SyntaxError && attempt < retries) { - await new Promise(r => setTimeout(r, 50)); - continue; + + const data = JSON.parse(res.body.toString()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + if (!data.access_token) { + this.logger.error("Claude OAuth refresh response missing access_token"); + return undefined; } + + const newExpiresAt = Date.now() + (data.expires_in ? data.expires_in * 1000 : 8 * 3600 * 1000); + this.persistClaudeCredentials({ + accessToken: data.access_token, + // Anthropic rotates the refresh token; fall back to the old one if absent. + refreshToken: data.refresh_token || refreshToken, + expiresAt: newExpiresAt, + }); + + this.logger.ok( + `Claude OAuth token refreshed (expires ${new Date(newExpiresAt).toISOString()})` + ); + return data.access_token; + } catch (err) { + this.logger.error( + `Claude OAuth refresh error: ${err instanceof Error ? err.message : "Unknown"}` + ); return undefined; } + })(); + + try { + return await this.oauthRefreshInFlight; + } finally { + this.oauthRefreshInFlight = null; + } + } + + /** + * Merge rotated OAuth fields into the credentials file and write it back + * atomically (temp file + rename) with 0600 perms, preserving any other keys + * already present (e.g. mcpOAuth). + */ + private persistClaudeCredentials(next: { + accessToken: string; + refreshToken: string; + expiresAt: number; + }): void { + const credPath = this.config.claudeSubscription.credentialsPath; + try { + let raw: Record = {}; + const existing = this.readClaudeCredentialsFile(); + if (existing) raw = existing.raw; + + const prevOauth = (raw.claudeAiOauth ?? {}) as Record; + raw.claudeAiOauth = { + ...prevOauth, + accessToken: next.accessToken, + refreshToken: next.refreshToken, + expiresAt: next.expiresAt, + }; + + const tmpPath = `${credPath}.tmp-${process.pid}`; + fs.writeFileSync(tmpPath, JSON.stringify(raw), { mode: 0o600 }); + fs.renameSync(tmpPath, credPath); + } catch (err) { + // A write failure is non-fatal: the refreshed token is still returned and + // used for this request; it just won't be cached for the next one. + this.logger.warn( + `Could not persist refreshed Claude credentials to ${credPath}: ${err instanceof Error ? err.message : "Unknown"}` + ); } } @@ -393,8 +569,8 @@ export class ClaudeCodeProxy { let subRes = await tryRequest(oauthToken); if (subRes.status === 401) { - this.logger.warn("← Claude subscription 401 — re-reading credentials and retrying"); - const freshToken = await this.readClaudeOAuthToken(); + this.logger.warn("← Claude subscription 401 — refreshing credentials and retrying"); + const freshToken = await this.forceRefreshClaudeOAuthToken(); if (freshToken && freshToken !== oauthToken) subRes = await tryRequest(freshToken); } @@ -557,12 +733,17 @@ export class ClaudeCodeProxy { } const openRouterModels: Record = { + // Opus → Opus (never silently downgrade the model class to Sonnet) + "claude-opus-4-8": "~anthropic/claude-opus-latest", + "claude-opus-4-7": "~anthropic/claude-opus-latest", + "claude-opus-4-6": "~anthropic/claude-opus-latest", + "claude-opus": "~anthropic/claude-opus-latest", + // Sonnet → Sonnet "claude-sonnet-4-6": "~anthropic/claude-sonnet-latest", - "claude-opus-4-6": "~anthropic/claude-sonnet-latest", - "claude-haiku-4-5-20251001": "~anthropic/claude-haiku-latest", "claude-sonnet-4-5": "~anthropic/claude-sonnet-latest", - "claude-opus": "~anthropic/claude-sonnet-latest", "claude-sonnet": "~anthropic/claude-sonnet-latest", + // Haiku → Haiku + "claude-haiku-4-5-20251001": "~anthropic/claude-haiku-latest", "claude-haiku": "~anthropic/claude-haiku-latest", "openrouter-free": this.config.modelFallbackMap["openrouter-free"] || @@ -1112,7 +1293,17 @@ export class ClaudeCodeProxy { if (!Array.isArray(messages)) return messages; let removedThinkingBlocks = 0; - const shouldStripMessageThinking = provider === "subscription"; + // Subscription replays full conversation history to Anthropic. Anthropic validates + // `thinking` blocks by their cryptographic `signature`; blocks produced by a different + // provider (GLM/Z.AI fallback) have no valid signature and 400 on replay. But blocks + // Anthropic itself produced ARE signed, and preserving them keeps reasoning continuity + // across turns (interleaved/adaptive thinking) — exactly what direct Claude does. + // unsigned (default): drop only thinking blocks without a signature (contaminated) + // all: drop every thinking/redacted_thinking block (legacy behavior) + // none: keep everything + const stripMode = provider === "subscription" + ? (process.env.STRIP_SUBSCRIPTION_THINKING || "unsigned") + : "none"; const cleanedMessages = messages .map((message) => { @@ -1121,16 +1312,30 @@ export class ClaudeCodeProxy { const cleanedMessage = { ...(message as Record) }; const content = cleanedMessage.content; - if (shouldStripMessageThinking && Array.isArray(content)) { + if (stripMode !== "none" && Array.isArray(content)) { const filteredContent = content.filter((block) => { if (!block || typeof block !== "object" || Array.isArray(block)) return true; - const type = (block as Record).type; - if (type === "thinking" || type === "redacted_thinking") { - removedThinkingBlocks++; - return false; + const rec = block as Record; + const type = rec.type; + + if (stripMode === "all") { + if (type === "thinking" || type === "redacted_thinking") { + removedThinkingBlocks++; + return false; + } + return true; } + // stripMode === "unsigned": keep redacted_thinking (carries `data`) and any + // signed thinking block; drop only thinking blocks missing a signature. + if (type === "thinking") { + const sig = rec.signature; + if (typeof sig !== "string" || sig.length === 0) { + removedThinkingBlocks++; + return false; + } + } return true; }); @@ -1146,27 +1351,126 @@ export class ClaudeCodeProxy { .filter(Boolean); if (removedThinkingBlocks > 0) { - this.logger.debug(` stripped ${removedThinkingBlocks} historical thinking block(s) for ${provider}`); + this.logger.debug( + ` stripped ${removedThinkingBlocks} ${stripMode === "all" ? "" : "unsigned "}thinking block(s) for ${provider} (mode=${stripMode})` + ); + } + + // Anthropic strictly validates tool-use block IDs against fixed patterns + // (server_tool_use → ^srvtoolu_…, tool_use → ^toolu_…). A fallback provider + // (e.g. GLM via Z.AI) can leave behind IDs in its own format; replaying that + // history to Anthropic/subscription 400s. Rewrite non-conforming IDs to a + // conforming shape, remapping paired tool_use_id references in the same pass. + if (provider === "anthropic" || provider === "subscription") { + return this.sanitizeToolUseIds(cleanedMessages as unknown[], provider); } return cleanedMessages; } + /** + * Rewrite tool-use block IDs that don't match Anthropic's required patterns, + * keeping each rewritten ID consistent with the tool_result/tool_use_id blocks + * that reference it. Returns the messages array (with shallow copies of any + * block actually changed); the input is left untouched. + */ + private sanitizeToolUseIds(messages: unknown[], provider: string): unknown[] { + const SERVER_RE = /^srvtoolu_[a-zA-Z0-9_]+$/; + const CLIENT_RE = /^toolu_[a-zA-Z0-9_]+$/; + const idMap = new Map(); + const usedTargets = new Set(); + + const conform = (id: string, prefix: string): string => { + let candidate = `${prefix}${id.replace(/[^a-zA-Z0-9_]/g, "_")}`; + // Guard against two distinct source IDs collapsing to the same target. + while (usedTargets.has(candidate)) candidate += "_"; + usedTargets.add(candidate); + return candidate; + }; + + // Pass 1: discover non-conforming server_tool_use / tool_use IDs. + for (const message of messages) { + const content = (message as Record | null)?.content; + if (!Array.isArray(content)) continue; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const { type, id } = block as Record; + if (typeof id !== "string") continue; + if (type === "server_tool_use" && !SERVER_RE.test(id)) { + if (!idMap.has(id)) idMap.set(id, conform(id, "srvtoolu_")); + } else if (type === "tool_use" && !CLIENT_RE.test(id)) { + if (!idMap.has(id)) idMap.set(id, conform(id, "toolu_")); + } + } + } + + if (idMap.size === 0) return messages; + + // Pass 2: apply the remap to both the defining IDs and any references. + let rewrites = 0; + const result = messages.map((message) => { + const content = (message as Record | null)?.content; + if (!Array.isArray(content)) return message; + + let blockChanged = false; + const newContent = content.map((block) => { + if (!block || typeof block !== "object") return block; + const rec = block as Record; + const newId = typeof rec.id === "string" ? idMap.get(rec.id) : undefined; + const newRef = typeof rec.tool_use_id === "string" ? idMap.get(rec.tool_use_id) : undefined; + if (!newId && !newRef) return block; + + blockChanged = true; + rewrites++; + const copy = { ...rec }; + if (newId) copy.id = newId; + if (newRef) copy.tool_use_id = newRef; + return copy; + }); + + if (!blockChanged) return message; + return { ...(message as Record), content: newContent }; + }); + + this.logger.debug( + ` sanitized ${idMap.size} non-conforming tool-use ID(s), ${rewrites} block reference(s) for ${provider}` + ); + return result; + } + private cleanBody( bodyStr: string, provider: "anthropic" | "zai" | "openrouter" | "subscription" = "anthropic" ): string { try { const body = JSON.parse(bodyStr); - const allowedFields = [ - "model", "messages", "max_tokens", "stop_sequences", "stream", - "system", "temperature", "top_p", "top_k", "metadata", "tools", "tool_choice", - "thinking" - ]; - const cleaned: Record = {}; - for (const field of allowedFields) { - if (body[field] !== undefined) cleaned[field] = body[field]; + // Anthropic-compatible providers (subscription + direct Anthropic) accept the + // client's native request shape. Pass top-level fields through untouched so + // coding-critical settings the client sends — output_config (effort, task_budget, + // format), context_management, and any future fields — reach the upstream exactly + // as Claude Code intended. We still remap the model and sanitize/clean messages. + const isAnthropicCompatible = provider === "anthropic" || provider === "subscription"; + + let cleaned: Record; + if (isAnthropicCompatible) { + cleaned = { ...body }; + } else { + // Z.AI / OpenRouter need a narrow, translated shape — keep the strict allow-list. + const allowedFields = [ + "model", "messages", "max_tokens", "stop_sequences", "stream", + "system", "temperature", "top_p", "top_k", "metadata", "tools", "tool_choice", + "thinking" + ]; + cleaned = {}; + for (const field of allowedFields) { + if (body[field] !== undefined) cleaned[field] = body[field]; + } + + const stripped = Object.keys(body).filter(k => !allowedFields.includes(k)); + if (stripped.length > 0) { + this.logger.debug(` stripped fields: ${stripped.join(", ")}`); + } } if (cleaned.messages !== undefined) { @@ -1183,12 +1487,6 @@ export class ClaudeCodeProxy { } } - // Log stripped fields - const stripped = Object.keys(body).filter(k => !allowedFields.includes(k)); - if (stripped.length > 0) { - this.logger.debug(` stripped fields: ${stripped.join(", ")}`); - } - return JSON.stringify(cleaned); } catch { return bodyStr; @@ -1697,9 +1995,9 @@ export class ClaudeCodeProxy { let subRes = await trySubscriptionStream(oauthToken); if (subRes.statusCode === 401) { - this.logger.warn("← Claude subscription 401 — re-reading credentials and retrying"); + this.logger.warn("← Claude subscription 401 — refreshing credentials and retrying"); await this.readIncomingBody(subRes); - const freshToken = await this.readClaudeOAuthToken(); + const freshToken = await this.forceRefreshClaudeOAuthToken(); if (freshToken && freshToken !== oauthToken) { subRes = await trySubscriptionStream(freshToken); } else { diff --git a/src/types.ts b/src/types.ts index c8b2d14..ef90523 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,9 @@ export interface ProxyConfig { credentialsPath: string; enabled: boolean; oauthToken?: string; // static long-lived token from `claude setup-token` + refreshUrl?: string; // OAuth token endpoint used to redeem the refresh token + clientId?: string; // Claude Code public OAuth client_id + refreshSkewMs?: number; // refresh proactively when within this window of expiry }; modelFallbackMap: Record; fallbackOnCodes: number[]; diff --git a/tests/proxy.test.js b/tests/proxy.test.js index 69aaa09..0e50c3d 100644 --- a/tests/proxy.test.js +++ b/tests/proxy.test.js @@ -1,6 +1,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import http from "node:http"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { ClaudeCodeProxy } from "../dist/index.js"; import { ProviderState } from "../dist/provider-health.js"; @@ -304,7 +307,7 @@ describe("Claude Code Proxy fallback chain", () => { assert.equal(capturedHeaders.host, "api.anthropic.com"); }); - it("strips historical thinking blocks before Claude subscription retry", async () => { + it("drops unsigned thinking blocks but keeps signed ones for Claude subscription", async () => { const proxy = createProxy(); let capturedBody = ""; @@ -324,8 +327,17 @@ describe("Claude Code Proxy fallback chain", () => { { role: "assistant", content: [ - { type: "thinking", thinking: "private chain", signature: "bad-signature" }, - { type: "text", text: "visible answer" }, + // Unsigned (contaminated, e.g. from a GLM fallback) → dropped + { type: "thinking", thinking: "contaminated chain" }, + { type: "text", text: "first answer" }, + ], + }, + { + role: "assistant", + content: [ + // Signed (Anthropic-produced) → preserved for reasoning continuity + { type: "thinking", thinking: "valid chain", signature: "real-sig-abc" }, + { type: "text", text: "second answer" }, ], }, ], @@ -339,7 +351,13 @@ describe("Claude Code Proxy fallback chain", () => { assert.equal(response.status, 200); assert.deepEqual(parsed.thinking, { type: "enabled", budget_tokens: 1024 }); - assert.deepEqual(parsed.messages[1].content, [{ type: "text", text: "visible answer" }]); + // Unsigned thinking removed from the first assistant turn + assert.deepEqual(parsed.messages[1].content, [{ type: "text", text: "first answer" }]); + // Signed thinking preserved in the second assistant turn + assert.deepEqual(parsed.messages[2].content, [ + { type: "thinking", thinking: "valid chain", signature: "real-sig-abc" }, + { type: "text", text: "second answer" }, + ]); }); it("does not send streaming provider errors before trying fallback", async () => { @@ -637,3 +655,442 @@ describe("Claude Code Proxy provider request normalization", () => { }); }); }); + +describe("Claude subscription OAuth refresh", () => { + function writeCreds(filePath, { accessToken, refreshToken, expiresAt }) { + fs.writeFileSync( + filePath, + JSON.stringify({ + claudeAiOauth: { accessToken, refreshToken, expiresAt }, + mcpOAuth: { keep: "me" }, // unrelated key that must be preserved + }), + ); + } + + function tmpCredsPath() { + return path.join(os.tmpdir(), `cc-proxy-test-creds-${process.pid}-${Math.random().toString(36).slice(2)}.json`); + } + + it("refreshes a near-expiry token and persists rotated credentials", async () => { + const credPath = tmpCredsPath(); + writeCreds(credPath, { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, // within the 5min skew window → should refresh + }); + + const proxy = createProxy({ + claudeSubscription: { enabled: true, credentialsPath: credPath }, + }); + + let refreshCalls = 0; + let capturedBody; + proxy.httpRequest = async (url, options) => { + refreshCalls++; + assert.match(url, /oauth\/token$/); + capturedBody = JSON.parse(options.body); + return jsonResponse(200, { + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 28800, + }); + }; + + const token = await proxy.readClaudeOAuthToken(); + + assert.equal(token, "new-access"); + assert.equal(refreshCalls, 1); + assert.equal(capturedBody.grant_type, "refresh_token"); + assert.equal(capturedBody.refresh_token, "old-refresh"); + + // Rotated tokens written back; unrelated keys preserved. + const persisted = JSON.parse(fs.readFileSync(credPath, "utf-8")); + assert.equal(persisted.claudeAiOauth.accessToken, "new-access"); + assert.equal(persisted.claudeAiOauth.refreshToken, "new-refresh"); + assert.equal(persisted.mcpOAuth.keep, "me"); + + fs.unlinkSync(credPath); + proxy.providerHealth.destroy(); + }); + + it("does not refresh a token that is comfortably valid", async () => { + const credPath = tmpCredsPath(); + writeCreds(credPath, { + accessToken: "still-good", + refreshToken: "some-refresh", + expiresAt: Date.now() + 3_600_000, // 1h out → outside skew, no refresh + }); + + const proxy = createProxy({ + claudeSubscription: { enabled: true, credentialsPath: credPath }, + }); + + let refreshCalls = 0; + proxy.httpRequest = async () => { + refreshCalls++; + return jsonResponse(200, { access_token: "unexpected" }); + }; + + const token = await proxy.readClaudeOAuthToken(); + + assert.equal(token, "still-good"); + assert.equal(refreshCalls, 0); + + fs.unlinkSync(credPath); + proxy.providerHealth.destroy(); + }); + + it("collapses concurrent refreshes into a single token redemption", async () => { + const credPath = tmpCredsPath(); + writeCreds(credPath, { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() - 1000, // already expired + }); + + const proxy = createProxy({ + claudeSubscription: { enabled: true, credentialsPath: credPath }, + }); + + let refreshCalls = 0; + proxy.httpRequest = async () => { + refreshCalls++; + await new Promise((r) => setTimeout(r, 25)); // hold the refresh open + return jsonResponse(200, { + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 28800, + }); + }; + + const [a, b, c] = await Promise.all([ + proxy.readClaudeOAuthToken(), + proxy.readClaudeOAuthToken(), + proxy.readClaudeOAuthToken(), + ]); + + assert.equal(a, "new-access"); + assert.equal(b, "new-access"); + assert.equal(c, "new-access"); + assert.equal(refreshCalls, 1); // single-flight: one redemption for all three + + fs.unlinkSync(credPath); + proxy.providerHealth.destroy(); + }); + + it("returns undefined when an expired token cannot be refreshed", async () => { + const credPath = tmpCredsPath(); + writeCreds(credPath, { + accessToken: "old-access", + refreshToken: "bad-refresh", + expiresAt: Date.now() - 1000, // expired + }); + + const proxy = createProxy({ + claudeSubscription: { enabled: true, credentialsPath: credPath }, + }); + + proxy.httpRequest = async () => jsonResponse(400, { error: "invalid_grant" }); + + const token = await proxy.readClaudeOAuthToken(); + assert.equal(token, undefined); + + fs.unlinkSync(credPath); + proxy.providerHealth.destroy(); + }); + + it("forceRefresh redeems the refresh token regardless of local expiry (401 recovery)", async () => { + const credPath = tmpCredsPath(); + writeCreds(credPath, { + accessToken: "revoked-but-unexpired", + refreshToken: "old-refresh", + expiresAt: Date.now() + 3_600_000, // looks valid locally, but server revoked it + }); + + const proxy = createProxy({ + claudeSubscription: { enabled: true, credentialsPath: credPath }, + }); + + let refreshCalls = 0; + proxy.httpRequest = async () => { + refreshCalls++; + return jsonResponse(200, { + access_token: "recovered-access", + refresh_token: "new-refresh", + expires_in: 28800, + }); + }; + + const token = await proxy.forceRefreshClaudeOAuthToken(); + assert.equal(token, "recovered-access"); + assert.equal(refreshCalls, 1); + + fs.unlinkSync(credPath); + proxy.providerHealth.destroy(); + }); + + it("prefers a static token and never refreshes", async () => { + const proxy = createProxy({ + claudeSubscription: { enabled: true, oauthToken: "static-long-lived", credentialsPath: "nonexistent.json" }, + }); + + let refreshCalls = 0; + proxy.httpRequest = async () => { + refreshCalls++; + return jsonResponse(200, { access_token: "nope" }); + }; + + assert.equal(await proxy.readClaudeOAuthToken(), "static-long-lived"); + assert.equal(await proxy.forceRefreshClaudeOAuthToken(), undefined); + assert.equal(refreshCalls, 0); + + proxy.providerHealth.destroy(); + }); +}); + +describe("Tool-use ID sanitization (cross-provider contamination)", () => { + function cleanedMessages(proxy, messages, provider) { + const body = JSON.stringify({ + model: "claude-opus-4-8", + max_tokens: 64, + messages, + }); + return JSON.parse(proxy.cleanBody(body, provider)).messages; + } + + it("rewrites a non-conforming server_tool_use id and its tool_result reference for subscription", () => { + const proxy = createProxy(); + const messages = [ + { role: "user", content: [{ type: "text", text: "search the web" }] }, + { + role: "assistant", + content: [ + { type: "server_tool_use", id: "call_abc123", name: "web_search", input: { query: "x" } }, + ], + }, + { + role: "user", + content: [ + { type: "web_search_tool_result", tool_use_id: "call_abc123", content: [] }, + ], + }, + ]; + + const out = cleanedMessages(proxy, messages, "subscription"); + const newId = out[1].content[0].id; + + assert.match(newId, /^srvtoolu_[a-zA-Z0-9_]+$/); + // The reference must be remapped to the SAME new id, or Anthropic 400s on pairing. + assert.equal(out[2].content[0].tool_use_id, newId); + + proxy.providerHealth.destroy(); + }); + + it("rewrites a non-conforming tool_use id (client tool) and its result reference", () => { + const proxy = createProxy(); + const messages = [ + { + role: "assistant", + content: [{ type: "tool_use", id: "glm-77", name: "do_thing", input: {} }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "glm-77", content: "ok" }], + }, + ]; + + const out = cleanedMessages(proxy, messages, "anthropic"); + const newId = out[0].content[0].id; + + assert.match(newId, /^toolu_[a-zA-Z0-9_]+$/); + assert.equal(out[1].content[0].tool_use_id, newId); + + proxy.providerHealth.destroy(); + }); + + it("leaves already-conforming ids untouched", () => { + const proxy = createProxy(); + const messages = [ + { + role: "assistant", + content: [ + { type: "server_tool_use", id: "srvtoolu_keepme", name: "web_search", input: {} }, + { type: "tool_use", id: "toolu_keepme2", name: "do", input: {} }, + ], + }, + { + role: "user", + content: [ + { type: "web_search_tool_result", tool_use_id: "srvtoolu_keepme", content: [] }, + { type: "tool_result", tool_use_id: "toolu_keepme2", content: "ok" }, + ], + }, + ]; + + const out = cleanedMessages(proxy, messages, "subscription"); + assert.equal(out[0].content[0].id, "srvtoolu_keepme"); + assert.equal(out[0].content[1].id, "toolu_keepme2"); + assert.equal(out[1].content[0].tool_use_id, "srvtoolu_keepme"); + assert.equal(out[1].content[1].tool_use_id, "toolu_keepme2"); + + proxy.providerHealth.destroy(); + }); + + it("does NOT sanitize ids for non-Anthropic providers (zai passes through)", () => { + const proxy = createProxy(); + const messages = [ + { + role: "assistant", + content: [{ type: "server_tool_use", id: "call_abc123", name: "web_search", input: {} }], + }, + ]; + + const out = cleanedMessages(proxy, messages, "zai"); + assert.equal(out[0].content[0].id, "call_abc123"); + + proxy.providerHealth.destroy(); + }); + + it("keeps two distinct non-conforming ids distinct after rewrite", () => { + const proxy = createProxy(); + const messages = [ + { + role: "assistant", + content: [ + { type: "tool_use", id: "a/b", name: "t", input: {} }, + { type: "tool_use", id: "a_b", name: "t", input: {} }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "a/b", content: "1" }, + { type: "tool_result", tool_use_id: "a_b", content: "2" }, + ], + }, + ]; + + const out = cleanedMessages(proxy, messages, "subscription"); + const id1 = out[0].content[0].id; + const id2 = out[0].content[1].id; + + assert.notEqual(id1, id2); // "a/b" and "a_b" must not collapse to one id + assert.equal(out[1].content[0].tool_use_id, id1); + assert.equal(out[1].content[1].tool_use_id, id2); + + proxy.providerHealth.destroy(); + }); +}); + +describe("cleanBody field passthrough (preserve coding-critical settings)", () => { + function clean(proxy, body, provider) { + return JSON.parse(proxy.cleanBody(JSON.stringify(body), provider)); + } + + const codingBody = { + model: "claude-opus-4-8", + max_tokens: 64000, + messages: [{ role: "user", content: "refactor this" }], + output_config: { effort: "xhigh", task_budget: { type: "tokens", total: 128000 } }, + context_management: { edits: [{ type: "compact_20260112" }] }, + }; + + for (const provider of ["subscription", "anthropic"]) { + it(`preserves output_config and context_management for ${provider}`, () => { + const proxy = createProxy(); + const out = clean(proxy, codingBody, provider); + + assert.deepEqual(out.output_config, { + effort: "xhigh", + task_budget: { type: "tokens", total: 128000 }, + }); + assert.deepEqual(out.context_management, { edits: [{ type: "compact_20260112" }] }); + assert.equal(out.model, "claude-opus-4-8"); + + proxy.providerHealth.destroy(); + }); + } + + for (const provider of ["zai", "openrouter"]) { + it(`still strips unknown top-level fields for ${provider}`, () => { + const proxy = createProxy(); + const out = clean(proxy, codingBody, provider); + + // Z.AI / OpenRouter need the narrow translated shape. + assert.equal(out.output_config, undefined); + assert.equal(out.context_management, undefined); + // Allowed fields survive. + assert.equal(out.max_tokens, 64000); + + proxy.providerHealth.destroy(); + }); + } +}); + +describe("OpenRouter model mapping (no Opus→Sonnet downgrade)", () => { + it("maps Opus models to an Opus OpenRouter target", () => { + const proxy = createProxy(); + for (const m of ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-opus"]) { + assert.equal(proxy.mapModel(m, "openrouter"), "~anthropic/claude-opus-latest", `model ${m}`); + } + proxy.providerHealth.destroy(); + }); + + it("keeps Sonnet mapped to Sonnet", () => { + const proxy = createProxy(); + assert.equal(proxy.mapModel("claude-sonnet-4-6", "openrouter"), "~anthropic/claude-sonnet-latest"); + proxy.providerHealth.destroy(); + }); +}); + +describe("STRIP_SUBSCRIPTION_THINKING modes", () => { + function subscriptionMessages(proxy, messages) { + const body = JSON.stringify({ model: "claude-opus-4-8", max_tokens: 64, messages }); + return JSON.parse(proxy.cleanBody(body, "subscription")).messages; + } + + const mixed = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "unsigned", }, + { type: "thinking", thinking: "signed", signature: "sig" }, + { type: "redacted_thinking", data: "abc" }, + { type: "text", text: "answer" }, + ], + }, + ]; + + it("default (unsigned) drops unsigned thinking, keeps signed + redacted", () => { + delete process.env.STRIP_SUBSCRIPTION_THINKING; + const proxy = createProxy(); + const out = subscriptionMessages(proxy, mixed); + const types = out[0].content.map((b) => b.type); + assert.deepEqual(types, ["thinking", "redacted_thinking", "text"]); + assert.equal(out[0].content[0].signature, "sig"); + proxy.providerHealth.destroy(); + }); + + it("mode=all strips every thinking and redacted_thinking block", () => { + process.env.STRIP_SUBSCRIPTION_THINKING = "all"; + const proxy = createProxy(); + const out = subscriptionMessages(proxy, mixed); + assert.deepEqual(out[0].content.map((b) => b.type), ["text"]); + delete process.env.STRIP_SUBSCRIPTION_THINKING; + proxy.providerHealth.destroy(); + }); + + it("mode=none keeps all blocks", () => { + process.env.STRIP_SUBSCRIPTION_THINKING = "none"; + const proxy = createProxy(); + const out = subscriptionMessages(proxy, mixed); + assert.deepEqual(out[0].content.map((b) => b.type), [ + "thinking", + "thinking", + "redacted_thinking", + "text", + ]); + delete process.env.STRIP_SUBSCRIPTION_THINKING; + proxy.providerHealth.destroy(); + }); +});