@bitkyc08/opencodex 2.7.7 → 2.7.9-preview.20260712

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.ko.md +2 -0
  2. package/README.md +2 -0
  3. package/README.zh-CN.md +2 -0
  4. package/gui/dist/assets/index-BcaDQD3i.js +40 -0
  5. package/gui/dist/assets/index-Cq8maiJf.css +1 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +11 -9
  9. package/src/adapters/cursor/exec-policy.ts +38 -0
  10. package/src/adapters/cursor/live-transport.ts +4 -3
  11. package/src/adapters/cursor/protobuf-request.ts +20 -0
  12. package/src/adapters/cursor/transport.ts +5 -0
  13. package/src/adapters/cursor.ts +2 -2
  14. package/src/bridge.ts +4 -2
  15. package/src/claude/agents-inject.ts +198 -0
  16. package/src/claude/alias.ts +69 -0
  17. package/src/claude/context-windows.ts +189 -0
  18. package/src/claude/desktop-3p.ts +254 -0
  19. package/src/claude/gateway-cache.ts +70 -0
  20. package/src/claude/inbound-debug.ts +114 -0
  21. package/src/claude/inbound.ts +481 -0
  22. package/src/claude/model-info.ts +145 -0
  23. package/src/claude/outbound.ts +487 -0
  24. package/src/cli/claude.ts +157 -0
  25. package/src/cli/help.ts +12 -0
  26. package/src/cli/index.ts +86 -7
  27. package/src/cli/v2.ts +23 -18
  28. package/src/codex/features.ts +288 -16
  29. package/src/lib/crash-guard.ts +11 -1
  30. package/src/lib/debug-settings.ts +14 -2
  31. package/src/lib/token-estimate.ts +27 -1
  32. package/src/providers/registry.ts +1 -1
  33. package/src/responses/parser.ts +28 -0
  34. package/src/server/auth-cors.ts +4 -2
  35. package/src/server/claude-messages.ts +494 -0
  36. package/src/server/index.ts +72 -0
  37. package/src/server/management-api.ts +226 -34
  38. package/src/server/request-log.ts +19 -4
  39. package/src/server/responses.ts +56 -15
  40. package/src/server/system-env.ts +314 -0
  41. package/src/types.ts +108 -0
  42. package/src/usage/log.ts +8 -2
  43. package/src/usage/summary.ts +18 -1
  44. package/src/usage/totals.ts +7 -18
  45. package/gui/dist/assets/index-C0xVu72_.css +0 -1
  46. package/gui/dist/assets/index-DzEDGLZh.js +0 -40
@@ -4,6 +4,8 @@
4
4
  * Usage capture: `ocx debug usage on|off|status|reset|logs [-f]` (or OPENCODEX_USAGE_DEBUG=1).
5
5
  * Injection log: `ocx debug injection on|off|status|reset` (or OCX_INJECTION_DEBUG=1) —
6
6
  * multi-agent guidance-injection console lines, default OFF.
7
+ * Claude inbound capture: `ocx debug claude on|off|status|reset` (or OCX_CLAUDE_DEBUG=1) —
8
+ * allowlist-scalar ring of inbound Anthropic request metadata, default OFF.
7
9
  * `/api/debug` and `ocx debug` override env defaults without restart.
8
10
  */
9
11
 
@@ -11,6 +13,7 @@ export const DEBUG_ENV = {
11
13
  debug: "OCX_DEBUG",
12
14
  usage: "OPENCODEX_USAGE_DEBUG",
13
15
  injection: "OCX_INJECTION_DEBUG",
16
+ claude: "OCX_CLAUDE_DEBUG",
14
17
  } as const;
15
18
 
16
19
  /** Legacy env var that still enables provider debug logging. */
@@ -22,6 +25,7 @@ export interface DebugSettingsView {
22
25
  enabled: boolean;
23
26
  usage: boolean;
24
27
  injection: boolean;
28
+ claude: boolean;
25
29
  runtimeOverride: Partial<Record<DebugFlag, boolean>>;
26
30
  env: Record<DebugFlag, boolean>;
27
31
  }
@@ -57,22 +61,30 @@ export function isInjectionDebugEnabled(): boolean {
57
61
  return envFlag(DEBUG_ENV.injection);
58
62
  }
59
63
 
64
+ /** Claude inbound request capture (default OFF; GUI toggle / API / CLI). */
65
+ export function isClaudeDebugEnabled(): boolean {
66
+ if (runtimeOverride.claude !== undefined) return runtimeOverride.claude;
67
+ return envFlag(DEBUG_ENV.claude);
68
+ }
69
+
60
70
  export function getDebugSettings(): DebugSettingsView {
61
71
  return {
62
72
  enabled: isDebugEnabled(),
63
73
  usage: isUsageDebugEnabled(),
64
74
  injection: isInjectionDebugEnabled(),
75
+ claude: isClaudeDebugEnabled(),
65
76
  runtimeOverride: { ...runtimeOverride },
66
77
  env: {
67
78
  debug: envFlag(DEBUG_ENV.debug) || legacyDebugEnvEnabled(),
68
79
  usage: envFlag(DEBUG_ENV.usage),
69
80
  injection: envFlag(DEBUG_ENV.injection),
81
+ claude: envFlag(DEBUG_ENV.claude),
70
82
  },
71
83
  };
72
84
  }
73
85
 
74
86
  export function setDebugSettings(partial: Partial<Record<DebugFlag, boolean>>): DebugSettingsView {
75
- for (const key of ["debug", "usage", "injection"] as const) {
87
+ for (const key of ["debug", "usage", "injection", "claude"] as const) {
76
88
  if (partial[key] !== undefined) runtimeOverride[key] = partial[key];
77
89
  }
78
90
  return getDebugSettings();
@@ -84,7 +96,7 @@ export function clearDebugSetting(flag: DebugFlag): DebugSettingsView {
84
96
  }
85
97
 
86
98
  export function clearDebugSettings(): DebugSettingsView {
87
- for (const key of ["debug", "usage", "injection"] as const) {
99
+ for (const key of ["debug", "usage", "injection", "claude"] as const) {
88
100
  delete runtimeOverride[key];
89
101
  }
90
102
  return getDebugSettings();
@@ -31,6 +31,30 @@ export function charsPerToken(modelId?: string): number {
31
31
  return DEFAULT_CHARS_PER_TOKEN;
32
32
  }
33
33
 
34
+ /**
35
+ * CJK-aware ratio (devlog 260712 B3, audit R2#7): Korean/Chinese/Japanese text packs
36
+ * roughly one token per 1.5-3 chars, so a CJK-heavy blob estimated at English ratios
37
+ * badly undercounts. When >30% of chars are CJK, clamp DOWN to 2.5 chars/token —
38
+ * `min(model ratio, 2.5)` so per-model ratios (Claude 3.5, Kiro family) never rise.
39
+ */
40
+ const CJK_CHARS_PER_TOKEN = 2.5;
41
+ const CJK_RATIO_THRESHOLD = 0.3;
42
+ // Hangul syllables/jamo, CJK unified ideographs (+ext A), hiragana/katakana.
43
+ const CJK_RE = /[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u30FF]/;
44
+
45
+ function cjkRatio(text: string): number {
46
+ if (text.length === 0) return 0;
47
+ // Sample long blobs for O(1) cost: every char up to 2k, then a stride.
48
+ const stride = text.length > 2048 ? Math.ceil(text.length / 2048) : 1;
49
+ let cjk = 0;
50
+ let sampled = 0;
51
+ for (let i = 0; i < text.length; i += stride) {
52
+ sampled++;
53
+ if (CJK_RE.test(text[i]!)) cjk++;
54
+ }
55
+ return sampled === 0 ? 0 : cjk / sampled;
56
+ }
57
+
34
58
  /**
35
59
  * Estimate the token count of a text blob. Pure and deterministic.
36
60
  * Returns 0 for empty/whitespace-free-empty input; otherwise ceil(length / ratio), min 1.
@@ -39,5 +63,7 @@ export function estimateTokens(text: string, modelId?: string): number {
39
63
  if (!text) return 0;
40
64
  const len = text.length;
41
65
  if (len === 0) return 0;
42
- return Math.max(1, Math.ceil(len / charsPerToken(modelId)));
66
+ let ratio = charsPerToken(modelId);
67
+ if (cjkRatio(text) > CJK_RATIO_THRESHOLD) ratio = Math.min(ratio, CJK_CHARS_PER_TOKEN);
68
+ return Math.max(1, Math.ceil(len / ratio));
43
69
  }
@@ -199,7 +199,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
199
199
  authKind: "oauth",
200
200
  featured: false,
201
201
  dashboardPreset: true,
202
- note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ocx login cursor'; native read/write/delete/shell/fetch execution stays disabled unless you set \"unsafeAllowNativeLocalExec\": true on providers.cursor in ~/.opencodex/config.json (dashboard: Providers → Cursor → Edit JSON) for a trusted local experiment.",
202
+ note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ocx login cursor'; native read/write/delete/shell/fetch execution stays disabled unless you set \"nativeLocalExec\": \"on\" (always) or \"codex-sandbox\" (only for requests declaring the Codex danger-full-access sandbox; the declaration is caller-controlled prose the proxy cannot verify, and the auth-free loopback bind admits any process on this host, including other local users — enable only where every data-plane client is trusted) — legacy \"unsafeAllowNativeLocalExec\": true still means \"on\" — on providers.cursor in ~/.opencodex/config.json (dashboard: Providers → Cursor → Edit JSON) for a trusted local experiment.",
203
203
  models: cursorModelIds(CURSOR_STATIC_MODELS),
204
204
  liveModels: true,
205
205
  defaultModel: "auto",
@@ -278,6 +278,34 @@ export function parseRequest(body: unknown): OcxParsedRequest {
278
278
  continue;
279
279
  }
280
280
 
281
+ if (effectiveType === "agent_message") {
282
+ const agentMessage = item as {
283
+ author?: string;
284
+ recipient?: string;
285
+ content?: unknown;
286
+ };
287
+
288
+ const content = inputContentParts(
289
+ agentMessage.content as unknown[] | string | undefined,
290
+ );
291
+
292
+ const hasContent =
293
+ typeof content === "string"
294
+ ? content.trim().length > 0
295
+ : content.length > 0;
296
+
297
+ // An agent_message is external input delivered to the parent agent.
298
+ // Preserve it as a user-role turn so signed Anthropic thinking blocks
299
+ // on either side are never merged into one modified assistant response.
300
+ messages.push({
301
+ role: "user",
302
+ content: hasContent ? content : "(sub-agent message received)",
303
+ timestamp: now,
304
+ });
305
+
306
+ continue;
307
+ }
308
+
281
309
  if (effectiveType === "message") {
282
310
  const msg = item as { role?: string; content?: unknown };
283
311
  switch (msg.role) {
@@ -73,7 +73,7 @@ export function corsHeaders(req?: Request, config?: OcxConfig): Record<string, s
73
73
  return {
74
74
  "Access-Control-Allow-Origin": allowOrigin,
75
75
  "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
76
- "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key",
76
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key, Anthropic-Version, Anthropic-Beta",
77
77
  "Vary": "Origin",
78
78
  };
79
79
  }
@@ -140,7 +140,9 @@ export function isProxyAdmissionSecret(token: string, config: OcxConfig): boolea
140
140
  export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
141
141
  if (!isApiAuthRequired(config)) return true;
142
142
  const actual = req.headers.get("x-opencodex-api-key")?.trim()
143
- || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
143
+ || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim()
144
+ // Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key.
145
+ || req.headers.get("x-api-key")?.trim();
144
146
  if (!actual) return false;
145
147
  return isProxyAdmissionSecret(actual, config);
146
148
  }
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Anthropic Messages inbound (/v1/messages + /v1/messages/count_tokens) for Claude Code.
3
+ *
4
+ * Translate-and-replay (devlog/260711_claude_inbound/010): the Anthropic request is
5
+ * converted to a /v1/responses body and replayed through handleResponses on an
6
+ * internal Request, so routing/OAuth/account-pool/failover/sidecars are inherited
7
+ * unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape.
8
+ */
9
+ import { FORWARD_HEADERS } from "../adapters/openai-responses";
10
+ import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound";
11
+ import { stripOneMillionMarker } from "../claude/context-windows";
12
+ import { captureClaudeInbound } from "../claude/inbound-debug";
13
+ import {
14
+ anthropicErrorBody,
15
+ anthropicErrorResponse,
16
+ collectAnthropicMessage,
17
+ responsesJsonToAnthropicMessage,
18
+ responsesSseToAnthropicSse,
19
+ } from "../claude/outbound";
20
+ import { estimateTokens } from "../lib/token-estimate";
21
+ import { routeModel } from "../router";
22
+ import type { OcxConfig } from "../types";
23
+ import { readJsonRequestBody } from "./request-decompress";
24
+ import { addFinalRequestLog, httpStatusForTerminalStatus, type RequestLogContext, type RequestLogEntry } from "./request-log";
25
+ import { responseWithDeferredRequestLog } from "./relay";
26
+ import { handleResponses } from "./responses";
27
+
28
+ type Rec = Record<string, unknown>;
29
+
30
+ function isRec(v: unknown): v is Rec {
31
+ return !!v && typeof v === "object" && !Array.isArray(v);
32
+ }
33
+
34
+ function claudeInboundDisabled(config: OcxConfig): Response | null {
35
+ if (config.claudeCode?.enabled === false) {
36
+ return anthropicErrorResponse(403, "Claude inbound is disabled (GUI: Claude ON toggle / config.claudeCode.enabled)", "permission_error");
37
+ }
38
+ return null;
39
+ }
40
+
41
+ async function readAnthropicBody(req: Request): Promise<unknown> {
42
+ try {
43
+ return await readJsonRequestBody(req);
44
+ } catch (err) {
45
+ throw new AnthropicRequestError(err instanceof Error && err.message ? err.message : "Invalid JSON body");
46
+ }
47
+ }
48
+
49
+ // ── Native Anthropic passthrough (subscription OAuth pierce) ──────────────────────
50
+ // When Claude Code runs with ONLY ANTHROPIC_BASE_URL set (subscription mode — the
51
+ // connectors warning stays off), it sends its OWN claude.ai OAuth Bearer to us.
52
+ // Requests for genuine claude/anthropic models that no alias/modelMap claims are
53
+ // forwarded VERBATIM to api.anthropic.com with the caller's credential and all
54
+ // end-to-end headers, so betas/thinking signatures/billing identity stay native.
55
+ // (Evidence: teamclaude --no-mitm + Vercel gateway docs, devlog 003/060.)
56
+
57
+ const PASSTHROUGH_STRIP_HEADERS = new Set([
58
+ "connection", "keep-alive", "transfer-encoding", "upgrade", "te", "trailer",
59
+ "proxy-authenticate", "proxy-authorization", "host", "content-length",
60
+ "accept-encoding", "x-opencodex-api-key", "origin",
61
+ ]);
62
+
63
+ function hasAnthropicNativeCredential(req: Request): boolean {
64
+ const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "";
65
+ const apiKey = req.headers.get("x-api-key")?.trim() ?? "";
66
+ return bearer.startsWith("sk-ant-") || apiKey.startsWith("sk-ant-");
67
+ }
68
+
69
+ function wantsNativePassthrough(req: Request, config: OcxConfig, model: unknown): model is string {
70
+ if (config.claudeCode?.nativePassthrough === false) return false;
71
+ if (typeof model !== "string" || !/^(claude|anthropic)/i.test(model)) return false;
72
+ if (!hasAnthropicNativeCredential(req)) return false;
73
+ // An alias or modelMap hit means the user asked for a ROUTED model: translate instead.
74
+ return resolveInboundModel(model, config.claudeCode) === model;
75
+ }
76
+
77
+ /** Format a 32-hex cache key as a uuid-shaped session id (version/variant nibbles forced). */
78
+ function uuidFromHex(hex32: string): string {
79
+ const h = (hex32 + "0".repeat(32)).slice(0, 32);
80
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
81
+ }
82
+
83
+ function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: number; outputTokens: number; cachedInputTokens?: number; cacheReadInputTokens?: number; cacheCreationInputTokens?: number } | undefined {
84
+ if (!usage) return undefined;
85
+ const num = (v: unknown) => typeof v === "number" ? v : 0;
86
+ const hasCache = usage.cache_read_input_tokens !== undefined || usage.cache_creation_input_tokens !== undefined;
87
+ const read = num(usage.cache_read_input_tokens);
88
+ const write = num(usage.cache_creation_input_tokens);
89
+ // Anthropic input_tokens excludes cache read/write; normalize to the canonical
90
+ // inclusive convention (types.ts OcxUsage / devlog 070). cached = READS only.
91
+ return {
92
+ inputTokens: num(usage.input_tokens) + read + write,
93
+ outputTokens: num(usage.output_tokens),
94
+ ...(hasCache ? {
95
+ cachedInputTokens: read,
96
+ cacheReadInputTokens: read,
97
+ cacheCreationInputTokens: write,
98
+ } : {}),
99
+ };
100
+ }
101
+
102
+ /** Tap an Anthropic-vocabulary SSE stream for the request log (usage + terminal). */
103
+ function tapAnthropicSseForLog(
104
+ upstream: ReadableStream<Uint8Array>,
105
+ logCtx: RequestLogContext,
106
+ finalize: (status: number, meta: { closeReason: "terminal" | "client_cancel" }) => void,
107
+ ): ReadableStream<Uint8Array> {
108
+ const decoder = new TextDecoder();
109
+ let buffer = "";
110
+ let usageAcc: Rec = {};
111
+ const inspect = (chunk: Uint8Array) => {
112
+ buffer += decoder.decode(chunk, { stream: true });
113
+ let sep: number;
114
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
115
+ const frame = buffer.slice(0, sep);
116
+ buffer = buffer.slice(sep + 2);
117
+ const dataLine = frame.split("\n").filter(l => l.startsWith("data: ")).map(l => l.slice(6)).join("");
118
+ if (!dataLine) continue;
119
+ let data: unknown;
120
+ try { data = JSON.parse(dataLine); } catch { continue; }
121
+ if (!isRec(data)) continue;
122
+ if (data.type === "message_start" && isRec(data.message) && isRec(data.message.usage)) {
123
+ usageAcc = { ...usageAcc, ...data.message.usage };
124
+ } else if (data.type === "message_delta" && isRec(data.usage)) {
125
+ usageAcc = { ...usageAcc, ...data.usage };
126
+ }
127
+ }
128
+ };
129
+ const reader = upstream.getReader();
130
+ return new ReadableStream<Uint8Array>({
131
+ async pull(controller) {
132
+ try {
133
+ const { done, value } = await reader.read();
134
+ if (done) {
135
+ logCtx.usage = anthropicUsageToOcx(Object.keys(usageAcc).length > 0 ? usageAcc : undefined);
136
+ finalize(200, { closeReason: "terminal" });
137
+ controller.close();
138
+ return;
139
+ }
140
+ inspect(value);
141
+ controller.enqueue(value);
142
+ } catch (err) {
143
+ finalize(200, { closeReason: "terminal" });
144
+ try { controller.error(err); } catch { /* torn down */ }
145
+ }
146
+ },
147
+ cancel(reason) {
148
+ finalize(499, { closeReason: "client_cancel" });
149
+ reader.cancel(reason).catch(() => {});
150
+ },
151
+ });
152
+ }
153
+
154
+ async function anthropicNativePassthrough(
155
+ req: Request,
156
+ config: OcxConfig,
157
+ logCtx: RequestLogContext,
158
+ logIds: { requestId: string; start: number } | undefined,
159
+ body: Rec,
160
+ pathname: string,
161
+ ): Promise<Response> {
162
+ const model = typeof body.model === "string" ? body.model : "unknown";
163
+ logCtx.model = model;
164
+ logCtx.provider = "anthropic-native";
165
+ logCtx.requestedModel = model;
166
+ let logged = false;
167
+ const finalize = (status: number, meta: { closeReason: "terminal" | "client_cancel" | "non_stream" }) => {
168
+ if (!logIds || logged) return;
169
+ logged = true;
170
+ addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
171
+ };
172
+
173
+ const base = (config.claudeCode?.anthropicBaseUrl ?? "https://api.anthropic.com").replace(/\/$/, "");
174
+ const search = new URL(req.url).search;
175
+ const headers = new Headers();
176
+ req.headers.forEach((value, name) => {
177
+ if (!PASSTHROUGH_STRIP_HEADERS.has(name.toLowerCase())) headers.set(name, value);
178
+ });
179
+ headers.set("content-type", "application/json");
180
+
181
+ let upstream: Response;
182
+ try {
183
+ upstream = await fetch(`${base}${pathname}${search}`, {
184
+ method: "POST",
185
+ headers,
186
+ body: JSON.stringify(body),
187
+ signal: req.signal,
188
+ });
189
+ } catch (err) {
190
+ finalize(502, { closeReason: "non_stream" });
191
+ return anthropicErrorResponse(502, `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error");
192
+ }
193
+
194
+ const contentType = upstream.headers.get("content-type") ?? "application/json";
195
+ if (upstream.ok && contentType.includes("text/event-stream") && upstream.body) {
196
+ return new Response(tapAnthropicSseForLog(upstream.body, logCtx, finalize), {
197
+ status: upstream.status,
198
+ headers: {
199
+ "Content-Type": contentType,
200
+ "Cache-Control": "no-cache",
201
+ "Connection": "keep-alive",
202
+ },
203
+ });
204
+ }
205
+ // Non-stream (count_tokens, errors, stream:false): relay verbatim, log on the spot.
206
+ const text = await upstream.text();
207
+ if (upstream.ok) {
208
+ try {
209
+ const parsed = JSON.parse(text) as { usage?: Rec };
210
+ if (isRec(parsed?.usage)) logCtx.usage = anthropicUsageToOcx(parsed.usage);
211
+ } catch { /* count_tokens etc. */ }
212
+ }
213
+ finalize(upstream.status, { closeReason: "non_stream" });
214
+ const retryAfter = upstream.headers.get("retry-after");
215
+ return new Response(text, {
216
+ status: upstream.status,
217
+ headers: { "Content-Type": contentType, ...(retryAfter ? { "Retry-After": retryAfter } : {}) },
218
+ });
219
+ }
220
+
221
+ export async function handleClaudeMessages(
222
+ req: Request,
223
+ config: OcxConfig,
224
+ logCtx: RequestLogContext,
225
+ logIds?: { requestId: string; start: number },
226
+ ): Promise<Response> {
227
+ logCtx.surface = "claude";
228
+ const disabled = claudeInboundDisabled(config);
229
+ if (disabled) {
230
+ if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 403, { closeReason: "non_stream" });
231
+ return disabled;
232
+ }
233
+
234
+ let anthropicBody: unknown;
235
+ let internalBody: Rec;
236
+ let cacheKeySource: ClaudeCacheKeySource = null;
237
+ try {
238
+ anthropicBody = await readAnthropicBody(req);
239
+ // Defensive [1m] strip (devlog 138): clients normally remove the context-variant
240
+ // marker themselves; the 1M signal we act on is the anthropic-beta header.
241
+ // Case-insensitive — the CLI matches /\[1m\]/i (audit 021 #7).
242
+ if (isRec(anthropicBody) && typeof anthropicBody.model === "string") {
243
+ anthropicBody.model = stripOneMillionMarker(anthropicBody.model);
244
+ }
245
+ // ocx-route override (devlog 072): injected agent bodies pin their model via a
246
+ // system-prompt directive because 2.1.207 ignores custom ids in agent
247
+ // frontmatter. Must run BEFORE the native-passthrough branch — the CLI sends
248
+ // these subagent turns under a fallback claude model id.
249
+ if (isRec(anthropicBody)) {
250
+ const routeOverride = extractOcxRouteDirective(anthropicBody);
251
+ if (routeOverride && typeof anthropicBody.model === "string") {
252
+ anthropicBody.model = stripOneMillionMarker(routeOverride);
253
+ }
254
+ }
255
+ // Debug capture (opt-in allowlist scalars) BEFORE the passthrough branch so
256
+ // native, routed, and disabled-alias paths are all observable (devlog 130 B1).
257
+ captureClaudeInbound(
258
+ "messages",
259
+ anthropicBody,
260
+ isRec(anthropicBody) && typeof anthropicBody.model === "string"
261
+ ? resolveInboundModel(anthropicBody.model, config.claudeCode)
262
+ : undefined,
263
+ req.headers.get("anthropic-beta") ?? undefined,
264
+ );
265
+ if (isRec(anthropicBody) && wantsNativePassthrough(req, config, anthropicBody.model)) {
266
+ return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages");
267
+ }
268
+ const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode);
269
+ internalBody = translation.body;
270
+ cacheKeySource = translation.cacheKeySource;
271
+ } catch (err) {
272
+ const status = err instanceof AnthropicRequestError ? 400 : 500;
273
+ if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" });
274
+ return anthropicErrorResponse(status, err instanceof Error ? err.message : String(err));
275
+ }
276
+
277
+ const requestedModel = (anthropicBody as Rec).model as string;
278
+ const stream = internalBody.stream === true;
279
+ // Routed adapters only support streamed turns; always stream internally and fold
280
+ // the translated Anthropic SSE into a message JSON for non-streaming clients.
281
+ internalBody.stream = true;
282
+
283
+ // Native ChatGPT passthrough (openai-responses forward) accepts only Codex-shaped
284
+ // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens",
285
+ // verified live 2026-07-11). Strip them for that route; routed providers keep them.
286
+ let nativeRoute = false;
287
+ try {
288
+ const route = routeModel(config, internalBody.model as string);
289
+ if (route.provider.adapter === "openai-responses") {
290
+ nativeRoute = true;
291
+ delete internalBody.max_output_tokens;
292
+ delete internalBody.temperature;
293
+ delete internalBody.top_p;
294
+ delete internalBody.stop;
295
+ delete internalBody.user;
296
+ }
297
+ // Estimated-usage adapters (cursor/kiro) report no per-turn input tokens; stash a
298
+ // request-side estimate so the log's in:0 rows get a floor. NEVER set this for
299
+ // accurate-usage adapters — the request-log merge is max(reported, estimate) and
300
+ // would overwrite real usage (audit 133 R1#7).
301
+ if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") {
302
+ const raw = anthropicBody as Rec;
303
+ const parts: string[] = [];
304
+ if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : JSON.stringify(raw.system));
305
+ if (raw.messages !== undefined) parts.push(JSON.stringify(raw.messages));
306
+ if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools));
307
+ logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel));
308
+ }
309
+ // Effort safety valve (devlog 136 B6, audit 139 R2#2): opus-shaped aliases make
310
+ // every routed model look like a reasoning model to Claude clients, so a forced
311
+ // effort (CLAUDE_CODE_ALWAYS_ENABLE_EFFORT) would leak reasoning params to routes
312
+ // that affirmatively expose NO effort control. Strip only on a definitive [] from
313
+ // supportedLadderFor; unknown (undefined) passes through untouched.
314
+ if (internalBody.reasoning !== undefined) {
315
+ const { supportedLadderFor } = await import("./effort-policy");
316
+ const ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId });
317
+ if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning;
318
+ }
319
+ } catch { /* unknown model: let handleResponses shape the 404 */ }
320
+
321
+ const headers = new Headers({ "content-type": "application/json" });
322
+ for (const name of FORWARD_HEADERS) {
323
+ // The caller's bearer is the proxy admission token (ocx claude placeholder), never a
324
+ // ChatGPT credential — forwarding it upstream turns into {"detail":"Unauthorized"}.
325
+ if (name === "authorization") continue;
326
+ const value = req.headers.get(name);
327
+ if (value) headers.set(name, value);
328
+ }
329
+ if (nativeRoute) {
330
+ // No forwarded ChatGPT auth exists on this surface. Attach the main codex login
331
+ // (read-only auth.json token); account-pool rotation still overrides downstream.
332
+ const { getMainAccountToken } = await import("../codex/main-account");
333
+ const token = getMainAccountToken();
334
+ if (token) {
335
+ headers.set("authorization", `Bearer ${token.accessToken}`);
336
+ headers.set("chatgpt-account-id", token.chatgptAccountId);
337
+ }
338
+ // ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex
339
+ // clients always send their session uuid; devlog 090 follow-up: body-level
340
+ // prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends
341
+ // the header, so synthesize a stable per-session uuid from the same cache key —
342
+ // but ONLY for a real per-session key (metadata.user_id). The system-hash fallback
343
+ // key is shared across Desktop conversations, and a shared session_id's backend
344
+ // semantics are unproven (audit 133 R2#3): body prompt_cache_key only there.
345
+ if (cacheKeySource === "metadata" && !headers.has("session_id") && typeof internalBody.prompt_cache_key === "string") {
346
+ headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key));
347
+ }
348
+ }
349
+ const internalReq = new Request("http://localhost/v1/responses", {
350
+ method: "POST",
351
+ headers,
352
+ body: JSON.stringify(internalBody),
353
+ });
354
+
355
+ // Request-log wiring mirrors the /v1/responses route: native passthrough finalizes
356
+ // via the terminal callbacks; routed streams get the Responses-vocabulary log tap
357
+ // BEFORE translation (the translated Anthropic stream has no response.completed
358
+ // frame, so tapping it records a bogus 502 with no usage/cache detail).
359
+ let nativeLogged = false;
360
+ const finalizeNativeLog = (status: number, meta: { terminalStatus?: RequestLogEntry["terminalStatus"]; closeReason: "terminal" | "client_cancel" }) => {
361
+ if (!logIds || nativeLogged) return;
362
+ nativeLogged = true;
363
+ addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
364
+ };
365
+ const upstream = await handleResponses(internalReq, config, logCtx, {
366
+ abortSignal: req.signal,
367
+ onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
368
+ onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
369
+ });
370
+ const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream;
371
+
372
+ if (!response.ok) {
373
+ // Re-shape the OpenAI-style error envelope into the Anthropic one, preserving status.
374
+ let message = `upstream error (${response.status})`;
375
+ try {
376
+ const text = await response.text();
377
+ try {
378
+ const parsed = JSON.parse(text) as { error?: { message?: string; type?: string } | string; message?: string };
379
+ const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.message : undefined;
380
+ const flat = typeof parsed?.error === "string" ? parsed.error : parsed?.message;
381
+ message = nested || flat || (text ? `upstream error (${response.status}): ${text.slice(0, 400)}` : message);
382
+ } catch {
383
+ if (text) message = `upstream error (${response.status}): ${text.slice(0, 400)}`;
384
+ }
385
+ } catch { /* keep fallback message */ }
386
+ const retryAfter = response.headers.get("retry-after");
387
+ const out = new Response(JSON.stringify(anthropicErrorBody(response.status, message)), {
388
+ status: response.status,
389
+ headers: { "Content-Type": "application/json", ...(retryAfter ? { "Retry-After": retryAfter } : {}) },
390
+ });
391
+ return out;
392
+ }
393
+
394
+ const contentType = response.headers.get("content-type") ?? "";
395
+ if (contentType.includes("text/event-stream") && response.body) {
396
+ const anthropicSse = responsesSseToAnthropicSse(response.body, requestedModel);
397
+ if (stream) {
398
+ return new Response(anthropicSse, {
399
+ status: 200,
400
+ headers: {
401
+ "Content-Type": "text/event-stream; charset=utf-8",
402
+ "Cache-Control": "no-cache",
403
+ "Connection": "keep-alive",
404
+ },
405
+ });
406
+ }
407
+ const message = await collectAnthropicMessage(anthropicSse, requestedModel);
408
+ const isError = (message as Rec).type === "error";
409
+ return new Response(JSON.stringify(message), {
410
+ status: isError ? 502 : 200,
411
+ headers: { "Content-Type": "application/json" },
412
+ });
413
+ }
414
+
415
+ // Defensive: some passthrough paths may answer JSON despite stream:true.
416
+ let json: unknown;
417
+ try {
418
+ json = await response.json();
419
+ } catch {
420
+ return anthropicErrorResponse(502, "internal replay returned a non-JSON response", "api_error");
421
+ }
422
+ const status = (json as Rec)?.status;
423
+ if (status === "failed") {
424
+ const error = (json as { error?: { message?: string } }).error;
425
+ return anthropicErrorResponse(502, error?.message ?? "upstream request failed", "api_error");
426
+ }
427
+ const message = responsesJsonToAnthropicMessage(json, requestedModel);
428
+ if (!stream) {
429
+ return new Response(JSON.stringify(message), { status: 200, headers: { "Content-Type": "application/json" } });
430
+ }
431
+ // Streaming client + JSON upstream: synthesize a minimal valid Anthropic stream.
432
+ const encoder = new TextEncoder();
433
+ const frames: string[] = [];
434
+ const emit = (name: string, data: Rec) => frames.push(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`);
435
+ emit("message_start", { type: "message_start", message: { ...message, content: [], stop_reason: null, usage: { input_tokens: 0, output_tokens: 0 } } });
436
+ const blocks = Array.isArray((message as Rec).content) ? (message as Rec).content as Rec[] : [];
437
+ blocks.forEach((block, index) => {
438
+ emit("content_block_start", { type: "content_block_start", index, content_block: block });
439
+ emit("content_block_stop", { type: "content_block_stop", index });
440
+ });
441
+ emit("message_delta", { type: "message_delta", delta: { stop_reason: (message as Rec).stop_reason ?? "end_turn", stop_sequence: null }, usage: (message as Rec).usage ?? {} });
442
+ emit("message_stop", { type: "message_stop" });
443
+ return new Response(encoder.encode(frames.join("")), {
444
+ status: 200,
445
+ headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" },
446
+ });
447
+ }
448
+
449
+ /** Documented approximation: serialize system+messages+tools, run the char estimator. */
450
+ export async function handleClaudeCountTokens(req: Request, config: OcxConfig): Promise<Response> {
451
+ const disabled = claudeInboundDisabled(config);
452
+ if (disabled) return disabled;
453
+
454
+ let body: unknown;
455
+ try {
456
+ body = await readAnthropicBody(req);
457
+ } catch (err) {
458
+ if (err instanceof AnthropicRequestError) return anthropicErrorResponse(400, err.message);
459
+ return anthropicErrorResponse(500, err instanceof Error ? err.message : String(err));
460
+ }
461
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
462
+ return anthropicErrorResponse(400, "request body must be a JSON object");
463
+ }
464
+ const raw = body as Rec;
465
+ if (typeof raw.model !== "string" || raw.model.length === 0) {
466
+ return anthropicErrorResponse(400, "model is required");
467
+ }
468
+ let model = raw.model;
469
+ // Case-insensitive [1m] strip (audit 021 #7 — the CLI matches /\[1m\]/i).
470
+ const stripped = stripOneMillionMarker(model);
471
+ if (stripped !== model) {
472
+ model = stripped;
473
+ raw.model = model;
474
+ }
475
+ // ocx-route override (devlog 072): keep count_tokens consistent with messages.
476
+ const countRoute = extractOcxRouteDirective(raw);
477
+ if (countRoute) {
478
+ model = stripOneMillionMarker(countRoute);
479
+ raw.model = model;
480
+ }
481
+ captureClaudeInbound("count_tokens", raw, resolveInboundModel(model, config.claudeCode), req.headers.get("anthropic-beta") ?? undefined);
482
+ if (wantsNativePassthrough(req, config, model)) {
483
+ return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens");
484
+ }
485
+ const parts: string[] = [];
486
+ if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : JSON.stringify(raw.system));
487
+ if (raw.messages !== undefined) parts.push(JSON.stringify(raw.messages));
488
+ if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools));
489
+ const inputTokens = Math.max(1, estimateTokens(parts.join("\n"), model));
490
+ return new Response(JSON.stringify({ input_tokens: inputTokens }), {
491
+ status: 200,
492
+ headers: { "Content-Type": "application/json" },
493
+ });
494
+ }