@bitkyc08/opencodex 2.7.8 → 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 (45) 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/server/auth-cors.ts +4 -2
  34. package/src/server/claude-messages.ts +494 -0
  35. package/src/server/index.ts +72 -0
  36. package/src/server/management-api.ts +226 -34
  37. package/src/server/request-log.ts +19 -4
  38. package/src/server/responses.ts +13 -1
  39. package/src/server/system-env.ts +314 -0
  40. package/src/types.ts +108 -0
  41. package/src/usage/log.ts +8 -2
  42. package/src/usage/summary.ts +18 -1
  43. package/src/usage/totals.ts +7 -18
  44. package/gui/dist/assets/index-Bp8dDrs5.js +0 -40
  45. package/gui/dist/assets/index-C0xVu72_.css +0 -1
@@ -0,0 +1,481 @@
1
+ /**
2
+ * Claude Code inbound: Anthropic Messages API request -> internal /v1/responses body.
3
+ *
4
+ * Design (devlog/260711_claude_inbound/010, 003_evidence.md):
5
+ * - translate-and-replay: the produced body MUST pass the real responsesRequestSchema
6
+ * parse so routing/OAuth/pool/failover are inherited unchanged.
7
+ * - thinking/redacted_thinking blocks on replay are DROPPED (v1 policy) — routed
8
+ * providers carry reasoning in Responses items/ocxr1 envelopes instead.
9
+ * - thinking.budget_tokens is NEVER forwarded raw; it maps to an effort tier.
10
+ * - top_k is accepted and silently dropped (no Responses equivalent, CCR parity).
11
+ */
12
+ import type { OcxClaudeCodeConfig } from "../types";
13
+ import { resolveAlias } from "./alias";
14
+ import { stripOneMillionMarker } from "./context-windows";
15
+ import { resolveDesktop3pAlias } from "./desktop-3p";
16
+ import { createHash } from "node:crypto";
17
+
18
+ export class AnthropicRequestError extends Error {}
19
+
20
+ type Rec = Record<string, unknown>;
21
+
22
+ function isRec(v: unknown): v is Rec {
23
+ return !!v && typeof v === "object" && !Array.isArray(v);
24
+ }
25
+
26
+ /** Alias first, then modelMap: exact id, then date-suffix-stripped (`-\d{8}$`), else passthrough. */
27
+ export function resolveInboundModel(model: string, cc?: OcxClaudeCodeConfig): string {
28
+ // Defensive: Desktop/CLI strip the [1m] context-variant marker client-side, but a
29
+ // leaking build must not break alias decode (devlog 138 — the 1M signal is the
30
+ // anthropic-beta header, never the id). Case-insensitive: the CLI matches /\[1m\]/i.
31
+ model = stripOneMillionMarker(model);
32
+ const aliased = resolveAlias(model);
33
+ if (aliased) return aliased;
34
+ // Desktop 3P aliases: claude-opus-4-{code} → provider/model route key
35
+ const desktop3p = resolveDesktop3pAlias(model);
36
+ if (desktop3p) {
37
+ // Native pseudo-provider returns bare slug; routed returns provider/model
38
+ const sep = desktop3p.indexOf("/");
39
+ if (sep > 0 && desktop3p.slice(0, sep) === "native") return desktop3p.slice(sep + 1);
40
+ return desktop3p;
41
+ }
42
+ const map = cc?.modelMap ?? {};
43
+ const exact = map[model];
44
+ if (typeof exact === "string" && exact.length > 0) return exact;
45
+ const stripped = model.replace(/-\d{8}$/, "");
46
+ const dateless = map[stripped];
47
+ if (typeof dateless === "string" && dateless.length > 0) return dateless;
48
+ return model;
49
+ }
50
+
51
+ /** budget_tokens ladder -> Responses reasoning effort (003: real API min is 1024; never forward raw). */
52
+ export function effortForThinkingBudget(budget: number): string {
53
+ if (budget <= 4096) return "low";
54
+ if (budget <= 16384) return "medium";
55
+ return "high";
56
+ }
57
+
58
+ /**
59
+ * Adaptive-thinking wire (devlog 080): Claude Code /effort sends
60
+ * `thinking:{type:"adaptive"}` + `output_config:{effort:"..."}` (verified by local
61
+ * capture of claude 2.1.207 and CLIProxyAPI#1540). Forward the level verbatim when it
62
+ * is a known Responses effort; unknown strings are dropped so downstream defaults win.
63
+ */
64
+ const OUTPUT_CONFIG_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
65
+ export function effortFromOutputConfig(outputConfig: unknown): string | undefined {
66
+ if (!isRec(outputConfig)) return undefined;
67
+ const effort = outputConfig.effort;
68
+ return typeof effort === "string" && OUTPUT_CONFIG_EFFORTS.has(effort) ? effort : undefined;
69
+ }
70
+
71
+ function systemToInstructions(system: unknown): string | undefined {
72
+ if (typeof system === "string") return system.length > 0 ? system : undefined;
73
+ if (Array.isArray(system)) {
74
+ const parts: string[] = [];
75
+ for (const block of system) {
76
+ if (isRec(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text);
77
+ }
78
+ return parts.length > 0 ? parts.join("\n\n") : undefined;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ function imageBlockToInputImage(block: Rec): Rec | null {
84
+ const source = block.source;
85
+ if (!isRec(source)) return null;
86
+ if (source.type === "base64" && typeof source.data === "string") {
87
+ const media = typeof source.media_type === "string" ? source.media_type : "image/png";
88
+ return { type: "input_image", image_url: `data:${media};base64,${source.data}` };
89
+ }
90
+ if (source.type === "url" && typeof source.url === "string") {
91
+ return { type: "input_image", image_url: source.url };
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function toolResultOutput(block: Rec): string | Rec[] {
97
+ const isError = block.is_error === true;
98
+ const content = block.content;
99
+ if (typeof content === "string") return isError ? `[tool error] ${content}` : content;
100
+ if (Array.isArray(content)) {
101
+ const out: Rec[] = [];
102
+ for (const item of content) {
103
+ if (!isRec(item)) continue;
104
+ if (item.type === "text" && typeof item.text === "string") {
105
+ out.push({ type: "input_text", text: item.text });
106
+ } else if (item.type === "image") {
107
+ const img = imageBlockToInputImage(item);
108
+ if (img) out.push(img);
109
+ }
110
+ }
111
+ if (isError) out.unshift({ type: "input_text", text: "[tool error]" });
112
+ if (out.length === 0) return isError ? "[tool error]" : "";
113
+ return out;
114
+ }
115
+ return isError ? "[tool error]" : "";
116
+ }
117
+
118
+ function pushUserMessage(input: Rec[], blocks: Rec[]): void {
119
+ if (blocks.length === 0) return;
120
+ input.push({ type: "message", role: "user", content: blocks });
121
+ }
122
+
123
+ /**
124
+ * Bundled-skill elision for routed models (devlog 060). Claude Code loads a skill
125
+ * by calling the `Skill` tool; the ~136k-token document bundle then rides the
126
+ * paired tool_result on EVERY subsequent turn. Third-party models are not trained
127
+ * on these Anthropic bundles, so for blocked skills we substitute the result body
128
+ * with a short stub — the function_call_output item itself stays (pairing intact).
129
+ * Native Anthropic passthrough never reaches this translation.
130
+ */
131
+ export const DEFAULT_BLOCKED_SKILLS = ["claude-api"];
132
+
133
+ /**
134
+ * ocx-route directive (devlog 072): injected agent-definition bodies carry
135
+ * `<!-- ocx-route: <model> -->` because Claude Code 2.1.207 ignores custom
136
+ * gateway ids in agent frontmatter (live-proven fallback to sonnet). The body
137
+ * rides the subagent's system prompt, so the proxy re-routes here. Only the
138
+ * FIRST directive wins; the scan is bounded to the system field.
139
+ */
140
+ const OCX_ROUTE_RE = /<!--\s*ocx-route:\s*([^\s]+)\s*-->/;
141
+
142
+ export function extractOcxRouteDirective(body: unknown): string | null {
143
+ if (!isRec(body)) return null;
144
+ const system = body.system;
145
+ let text: string | undefined;
146
+ if (typeof system === "string") text = system;
147
+ else if (Array.isArray(system)) {
148
+ text = system
149
+ .filter((b): b is Rec => isRec(b) && b.type === "text" && typeof b.text === "string")
150
+ .map(b => b.text as string)
151
+ .join("\n");
152
+ }
153
+ if (!text) return null;
154
+ const match = OCX_ROUTE_RE.exec(text);
155
+ return match ? match[1]! : null;
156
+ }
157
+
158
+ /** Injected-skill payloads below this size are never stubbed (not worth it). */
159
+ const SKILL_ELISION_MIN_CHARS = 10_000;
160
+ const SKILL_TEXT_MARKER = "Base directory for this skill: ";
161
+
162
+ interface SkillElisionContext {
163
+ /** Skill-tool call ids whose input names a blocked skill (result-body carrier). */
164
+ callIds: ReadonlySet<string>;
165
+ /** Lowercased blocked skill names (text-block carrier). */
166
+ names: readonly string[];
167
+ }
168
+
169
+ const NO_ELISION: SkillElisionContext = { callIds: new Set(), names: [] };
170
+
171
+ /**
172
+ * Claude Code 2.1.207 (live capture, devlog 060 follow-up): the Skill tool_result is
173
+ * a tiny "Launching skill: <name>" note; the actual ~570k-char document bundle rides
174
+ * as a SEPARATE text block in the same user message, whose first line is
175
+ * `Base directory for this skill: <dir>/<skill-name>`. Stub that block when the
176
+ * directory basename matches a blocked skill.
177
+ */
178
+ function maybeElideSkillText(text: string, names: readonly string[]): string {
179
+ if (names.length === 0 || text.length < SKILL_ELISION_MIN_CHARS) return text;
180
+ if (!text.startsWith(SKILL_TEXT_MARKER)) return text;
181
+ const firstLineEnd = text.indexOf("\n");
182
+ const dir = text.slice(SKILL_TEXT_MARKER.length, firstLineEnd === -1 ? text.length : firstLineEnd).trim();
183
+ const base = dir.split("/").filter(Boolean).pop()?.toLowerCase() ?? "";
184
+ if (!names.includes(base)) return text;
185
+ return `[opencodex] '${base}' skill document bundle (${text.length} chars) elided for routed models `
186
+ + "(claudeCode.blockedSkills). The skill is loaded; answer from general knowledge instead of citing the bundle.";
187
+ }
188
+
189
+ function skillElisionStub(callId: string): string {
190
+ return "[opencodex] Skill document bundle elided for routed models (claudeCode.blockedSkills). "
191
+ + `The skill loaded, but its reference documents were removed to save context (call ${callId}). `
192
+ + "Answer from general knowledge instead of citing the bundle.";
193
+ }
194
+
195
+ /** Collect Skill-tool call ids whose input names a blocked skill. */
196
+ function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly string[]): Set<string> {
197
+ const ids = new Set<string>();
198
+ if (blocked.length === 0) return ids;
199
+ const needles = blocked.map(name => name.toLowerCase()).filter(name => name.length > 0);
200
+ if (needles.length === 0) return ids;
201
+ for (const msg of messages) {
202
+ if (!isRec(msg) || msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
203
+ for (const block of msg.content) {
204
+ if (!isRec(block) || block.type !== "tool_use" || block.name !== "Skill") continue;
205
+ if (typeof block.id !== "string" || block.id.length === 0) continue;
206
+ const inputJson = JSON.stringify(block.input ?? {}).toLowerCase();
207
+ if (needles.some(name => inputJson.includes(name))) ids.add(block.id);
208
+ }
209
+ }
210
+ return ids;
211
+ }
212
+
213
+ /**
214
+ * Claude Code (observed 2026-07-11, real CLI smoke) sends `role:"system"` entries in
215
+ * `messages` despite the published API having no system role. Map them to Responses
216
+ * instructions text: the native ChatGPT backend rejects system message items in
217
+ * `input` ("System messages are not allowed", verified live), so folding into
218
+ * `instructions` is the only shape that works on every route.
219
+ */
220
+ function systemMessageText(content: unknown): string {
221
+ if (typeof content === "string") return content;
222
+ if (!Array.isArray(content)) return "";
223
+ const parts: string[] = [];
224
+ for (const raw of content) {
225
+ if (isRec(raw) && raw.type === "text" && typeof raw.text === "string") parts.push(raw.text);
226
+ }
227
+ return parts.join("\n\n");
228
+ }
229
+
230
+ function userMessageToItems(content: unknown, input: Rec[], elide: SkillElisionContext = NO_ELISION): void {
231
+ if (typeof content === "string") {
232
+ if (content.length > 0) pushUserMessage(input, [{ type: "input_text", text: content }]);
233
+ return;
234
+ }
235
+ if (!Array.isArray(content)) return;
236
+ // Preserve block order: tool_result blocks become standalone function_call_output
237
+ // items; contiguous text/image runs become one user message.
238
+ let pending: Rec[] = [];
239
+ for (const raw of content) {
240
+ if (!isRec(raw)) continue;
241
+ switch (raw.type) {
242
+ case "text":
243
+ if (typeof raw.text === "string") pending.push({ type: "input_text", text: maybeElideSkillText(raw.text, elide.names) });
244
+ break;
245
+ case "image": {
246
+ const img = imageBlockToInputImage(raw);
247
+ if (img) pending.push(img);
248
+ break;
249
+ }
250
+ case "tool_result": {
251
+ pushUserMessage(input, pending);
252
+ pending = [];
253
+ if (typeof raw.tool_use_id !== "string" || raw.tool_use_id.length === 0) {
254
+ throw new AnthropicRequestError("tool_result requires tool_use_id");
255
+ }
256
+ input.push({
257
+ type: "function_call_output",
258
+ call_id: raw.tool_use_id,
259
+ // Blocked-skill bundles are stubbed out for routed models (devlog 060).
260
+ output: elide.callIds.has(raw.tool_use_id) ? skillElisionStub(raw.tool_use_id) : toolResultOutput(raw),
261
+ });
262
+ break;
263
+ }
264
+ case "document":
265
+ // No Responses equivalent for raw document blocks; surface the title so the
266
+ // model at least sees the attachment happened.
267
+ pending.push({ type: "input_text", text: `[document${typeof raw.title === "string" ? `: ${raw.title}` : ""}]` });
268
+ break;
269
+ default:
270
+ break; // thinking/redacted_thinking never appear in user messages; ignore unknowns
271
+ }
272
+ }
273
+ pushUserMessage(input, pending);
274
+ }
275
+
276
+ function assistantMessageToItems(content: unknown, input: Rec[]): void {
277
+ if (typeof content === "string") {
278
+ if (content.length > 0) input.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: content }] });
279
+ return;
280
+ }
281
+ if (!Array.isArray(content)) return;
282
+ let pendingText: Rec[] = [];
283
+ const flush = () => {
284
+ if (pendingText.length > 0) input.push({ type: "message", role: "assistant", content: pendingText });
285
+ pendingText = [];
286
+ };
287
+ for (const raw of content) {
288
+ if (!isRec(raw)) continue;
289
+ switch (raw.type) {
290
+ case "text":
291
+ if (typeof raw.text === "string") pendingText.push({ type: "output_text", text: raw.text });
292
+ break;
293
+ case "tool_use": {
294
+ flush();
295
+ if (typeof raw.id !== "string" || raw.id.length === 0 || typeof raw.name !== "string" || raw.name.length === 0) {
296
+ throw new AnthropicRequestError("tool_use requires id and name");
297
+ }
298
+ input.push({ type: "function_call", call_id: raw.id, name: raw.name, arguments: JSON.stringify(raw.input ?? {}) });
299
+ break;
300
+ }
301
+ case "thinking":
302
+ case "redacted_thinking":
303
+ break; // v1 policy: dropped on replay (003 evidence — safe for routed providers)
304
+ default:
305
+ break;
306
+ }
307
+ }
308
+ flush();
309
+ }
310
+
311
+ function toolsToResponses(tools: unknown): Rec[] | undefined {
312
+ if (!Array.isArray(tools) || tools.length === 0) return undefined;
313
+ const out: Rec[] = [];
314
+ for (const raw of tools) {
315
+ if (!isRec(raw)) continue;
316
+ const type = typeof raw.type === "string" ? raw.type : "";
317
+ if (type.startsWith("web_search")) {
318
+ out.push({ type: "web_search" }); // hosted sidecar path
319
+ continue;
320
+ }
321
+ if (typeof raw.name === "string" && raw.name.length > 0 && isRec(raw.input_schema)) {
322
+ out.push({
323
+ type: "function",
324
+ name: raw.name,
325
+ ...(typeof raw.description === "string" ? { description: raw.description } : {}),
326
+ parameters: raw.input_schema as Record<string, unknown>,
327
+ });
328
+ continue;
329
+ }
330
+ // Other server tools (bash_*, text_editor_*, ...) have no routed equivalent: drop.
331
+ }
332
+ return out.length > 0 ? out : undefined;
333
+ }
334
+
335
+ function toolChoiceToResponses(choice: unknown, body: Rec): void {
336
+ if (!isRec(choice)) return;
337
+ if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false;
338
+ switch (choice.type) {
339
+ case "auto": body.tool_choice = "auto"; break;
340
+ case "none": body.tool_choice = "none"; break;
341
+ case "any": body.tool_choice = "required"; break;
342
+ case "tool":
343
+ if (typeof choice.name !== "string" || choice.name.length === 0) {
344
+ throw new AnthropicRequestError("tool_choice.tool requires a name");
345
+ }
346
+ body.tool_choice = { type: "function", name: choice.name };
347
+ break;
348
+ default: break;
349
+ }
350
+ }
351
+
352
+ /** Recursive canonical JSON (keys sorted at every depth) — stable cache-cohort input. */
353
+ function canonicalJson(value: unknown): string {
354
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
355
+ if (value && typeof value === "object") {
356
+ const entries = Object.entries(value as Rec).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
357
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
358
+ }
359
+ return JSON.stringify(value) ?? "null";
360
+ }
361
+
362
+ /** Provenance of the generated prompt_cache_key (never serialized into the wire body). */
363
+ export type ClaudeCacheKeySource = "metadata" | "system" | null;
364
+
365
+ export interface ClaudeInboundTranslation {
366
+ body: Rec;
367
+ cacheKeySource: ClaudeCacheKeySource;
368
+ }
369
+
370
+ /**
371
+ * Translate an Anthropic Messages request body into a /v1/responses request body.
372
+ * Throws AnthropicRequestError (-> 400 invalid_request_error) on malformed input.
373
+ */
374
+ export function anthropicToResponsesBody(raw: unknown, cc?: OcxClaudeCodeConfig): Rec {
375
+ return anthropicToResponsesTranslation(raw, cc).body;
376
+ }
377
+
378
+ /**
379
+ * Full translation result: the wire body plus the prompt-cache-key provenance as an
380
+ * OUT-OF-BODY tuple (audit 133 R3#1 — an in-body marker would leak upstream through
381
+ * the native Responses forward and 400).
382
+ */
383
+ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCodeConfig): ClaudeInboundTranslation {
384
+ if (!isRec(raw)) throw new AnthropicRequestError("request body must be a JSON object");
385
+ if (typeof raw.model !== "string" || raw.model.length === 0) {
386
+ throw new AnthropicRequestError("model is required");
387
+ }
388
+ if (!Array.isArray(raw.messages) || raw.messages.length === 0) {
389
+ throw new AnthropicRequestError("messages must be a non-empty array");
390
+ }
391
+
392
+ const input: Rec[] = [];
393
+ const systemParts: string[] = [];
394
+ const topLevelSystem = systemToInstructions(raw.system);
395
+ if (topLevelSystem !== undefined) systemParts.push(topLevelSystem);
396
+ const blockedNames = (cc?.blockedSkills ?? DEFAULT_BLOCKED_SKILLS).map(n => n.toLowerCase()).filter(n => n.length > 0);
397
+ const elide: SkillElisionContext = {
398
+ callIds: blockedSkillCallIds(raw.messages, blockedNames),
399
+ names: blockedNames,
400
+ };
401
+ for (const msg of raw.messages) {
402
+ if (!isRec(msg)) throw new AnthropicRequestError("each message must be an object");
403
+ if (msg.role === "user") userMessageToItems(msg.content, input, elide);
404
+ else if (msg.role === "assistant") assistantMessageToItems(msg.content, input);
405
+ else if (msg.role === "system") {
406
+ const text = systemMessageText(msg.content);
407
+ if (text.length > 0) systemParts.push(text);
408
+ }
409
+ else throw new AnthropicRequestError(`unsupported message role: ${String(msg.role)}`);
410
+ }
411
+
412
+ const body: Rec = {
413
+ model: resolveInboundModel(raw.model, cc),
414
+ input,
415
+ store: false,
416
+ stream: raw.stream === true,
417
+ };
418
+
419
+ if (systemParts.length > 0) body.instructions = systemParts.join("\n\n");
420
+
421
+ const tools = toolsToResponses(raw.tools);
422
+ if (tools) body.tools = tools;
423
+ toolChoiceToResponses(raw.tool_choice, body);
424
+
425
+ if (typeof raw.max_tokens === "number") body.max_output_tokens = raw.max_tokens;
426
+ if (typeof raw.temperature === "number") body.temperature = raw.temperature;
427
+ if (typeof raw.top_p === "number") body.top_p = raw.top_p;
428
+ // top_k: accepted and dropped (no Responses equivalent).
429
+ if (Array.isArray(raw.stop_sequences) && raw.stop_sequences.length > 0) {
430
+ body.stop = raw.stop_sequences.filter((s): s is string => typeof s === "string");
431
+ }
432
+ let cacheKeySource: ClaudeCacheKeySource = null;
433
+ if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") {
434
+ body.user = raw.metadata.user_id;
435
+ // OpenAI-side prompt caching is routed by prompt_cache_key (Codex clients send
436
+ // their session id; without it consecutive /v1/messages turns reported
437
+ // cached_tokens: 0 on the ChatGPT backend — devlog 090). Claude Code's
438
+ // metadata.user_id embeds the session uuid, so hashing it yields a stable
439
+ // per-session key with a bounded length/charset.
440
+ body.prompt_cache_key = createHash("sha256").update(raw.metadata.user_id).digest("hex").slice(0, 32);
441
+ cacheKeySource = "metadata";
442
+ } else if (systemParts.length > 0) {
443
+ // Claude Desktop sends no metadata.user_id (H1, devlog 130): without any key the
444
+ // ChatGPT/OpenAI backends reported cached_tokens:0 on every turn. Fall back to a
445
+ // cache-cohort hash (devlog 260712 B4 + Pro review 012): fingerprint what the
446
+ // upstream actually receives — resolved model, post-translation system, and the
447
+ // FULL translated tool definitions in WIRE ORDER (sorting the hash while sending
448
+ // a different order would break the key↔prefix correspondence). canonical JSON
449
+ // (recursive key sort) + a version field so future normalization changes never
450
+ // mix cohorts. system-only keys herded different models/toolsets into one key
451
+ // and burned OpenAI's ~15 RPM per-key routing budget (audit R1#4/R2#5/R1#10).
452
+ // Exact-prefix matching still isolates content; the key only steers routing
453
+ // affinity. Callers must NOT synthesize a session_id header from this fallback
454
+ // (audit 133 R2#3).
455
+ body.prompt_cache_key = createHash("sha256")
456
+ .update(canonicalJson({
457
+ version: 2,
458
+ model: body.model,
459
+ system: systemParts,
460
+ tools: Array.isArray(body.tools) ? body.tools : [],
461
+ }))
462
+ .digest("hex").slice(0, 32);
463
+ cacheKeySource = "system";
464
+ }
465
+
466
+ const thinking = raw.thinking;
467
+ const outputConfigEffort = effortFromOutputConfig(raw.output_config);
468
+ const thinkingDisabled = isRec(thinking) && thinking.type === "disabled";
469
+ if (!thinkingDisabled && (isRec(thinking) || outputConfigEffort !== undefined)) {
470
+ const reasoning: Rec = { summary: "auto" };
471
+ if (outputConfigEffort !== undefined) {
472
+ // Adaptive wire: /effort arrives as output_config.effort (devlog 080).
473
+ reasoning.effort = outputConfigEffort;
474
+ } else if (isRec(thinking) && thinking.type === "enabled" && typeof thinking.budget_tokens === "number") {
475
+ reasoning.effort = effortForThinkingBudget(thinking.budget_tokens);
476
+ }
477
+ body.reasoning = reasoning;
478
+ }
479
+
480
+ return { body, cacheKeySource };
481
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Anthropic-flavor /v1/models entries in the official ModelInfo shape
3
+ * (anthropic-sdk-typescript@9e46760 src/resources/models.ts — devlog 131).
4
+ *
5
+ * Why full ModelInfo: Claude Desktop 3P discovery is the only channel that can
6
+ * carry per-model capabilities (effort ladder / thinking types); the static
7
+ * inferenceModels schema has no capability fields. Claude Code CLI 2.1.207 strips
8
+ * unknown fields, so the richer shape is backward-safe (audit 133 R1#4).
9
+ *
10
+ * Honesty rules (audit 133 R2#1/R2#2/R3#2/R4#1):
11
+ * - native ladders start from the injected catalog but advertise ONLY rungs that
12
+ * survive nativeEffortClamp as identity (`(clamp(r) ?? r) === r`), ultra excluded;
13
+ * - routed ladders use the adapter-reported CatalogModel.reasoningEfforts only —
14
+ * no ladder means effort.supported:false, never a guess;
15
+ * - created_at is a fixed constant; max_input_tokens is authoritative-or-null;
16
+ * max_tokens is always null (no authoritative output limit exists proxy-side).
17
+ */
18
+ import { catalogModelEfforts, nativeEffortClamp, nativeOpenAiContextWindow, type CatalogModel } from "../codex/catalog";
19
+ import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias";
20
+ import { desktop3pAlias } from "./desktop-3p";
21
+ import { AUTO_CONTEXT_OFF, shouldMarkOneMillion, type AutoContextMode } from "./context-windows";
22
+
23
+ const MODEL_INFO_CREATED_AT = "2026-01-01T00:00:00Z";
24
+ const ANTHROPIC_EFFORT_RUNGS = new Set(["low", "medium", "high", "xhigh", "max"]);
25
+ const ONE_MILLION = 1_000_000;
26
+
27
+ interface CapabilitySupport { supported: boolean }
28
+
29
+ function cap(supported: boolean): CapabilitySupport {
30
+ return { supported };
31
+ }
32
+
33
+ function effortCapability(ladder: readonly string[]) {
34
+ const rungs = new Set(ladder.filter(r => ANTHROPIC_EFFORT_RUNGS.has(r)));
35
+ const supported = rungs.size > 0;
36
+ return {
37
+ supported,
38
+ low: cap(rungs.has("low")),
39
+ medium: cap(rungs.has("medium")),
40
+ high: cap(rungs.has("high")),
41
+ max: cap(rungs.has("max")),
42
+ xhigh: supported ? cap(rungs.has("xhigh")) : null,
43
+ };
44
+ }
45
+
46
+ function modelCapabilities(ladder: readonly string[], imageInput: boolean) {
47
+ const reasons = ladder.length > 0;
48
+ return {
49
+ batch: cap(false),
50
+ citations: cap(false),
51
+ code_execution: cap(false),
52
+ context_management: {
53
+ supported: false,
54
+ clear_thinking_20251015: null,
55
+ clear_tool_uses_20250919: null,
56
+ compact_20260112: null,
57
+ },
58
+ effort: effortCapability(ladder),
59
+ image_input: cap(imageInput),
60
+ pdf_input: cap(false),
61
+ structured_outputs: cap(false),
62
+ thinking: reasons
63
+ ? { supported: true, types: { adaptive: cap(true), enabled: cap(true) } }
64
+ : { supported: false, types: { adaptive: cap(false), enabled: cap(false) } },
65
+ };
66
+ }
67
+
68
+ /** Native ladder: catalog rungs that the native effort clamp passes through as identity. */
69
+ export function nativeEffectiveLadder(slug: string): string[] {
70
+ const ladder = catalogModelEfforts([slug]).get(slug) ?? [];
71
+ return ladder.filter(r => r !== "ultra" && (nativeEffortClamp(slug, r) ?? r) === r);
72
+ }
73
+
74
+ export interface AnthropicModelInfo {
75
+ id: string;
76
+ display_name: string;
77
+ type: "model";
78
+ created_at: string;
79
+ capabilities: ReturnType<typeof modelCapabilities>;
80
+ max_input_tokens: number | null;
81
+ max_tokens: null;
82
+ }
83
+
84
+ function modelInfo(id: string, displayName: string, ladder: readonly string[], imageInput: boolean, contextWindow?: number): AnthropicModelInfo {
85
+ return {
86
+ id,
87
+ display_name: displayName,
88
+ type: "model",
89
+ created_at: MODEL_INFO_CREATED_AT,
90
+ capabilities: modelCapabilities(ladder, imageInput),
91
+ max_input_tokens: typeof contextWindow === "number" && contextWindow > 0 ? contextWindow : null,
92
+ max_tokens: null,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Which id family the discovery list carries (devlog 050): Claude Code (CLI)
98
+ * gets readable `claude-ocx-*` ids; Claude Desktop keeps the hashed
99
+ * `claude-opus-4-8-<code>` family its 3P config was written with. Both families
100
+ * decode in resolveInboundModel regardless of the style served here.
101
+ */
102
+ export type AnthropicIdStyle = "desktop3p" | "readable";
103
+
104
+ /** Build the full anthropic-flavor discovery list (ids are Desktop 3P aliases). */
105
+ export function buildAnthropicModelInfos(nativeSlugs: readonly string[], routedModels: readonly CatalogModel[], auto: AutoContextMode = AUTO_CONTEXT_OFF, idStyle: AnthropicIdStyle = "desktop3p"): AnthropicModelInfo[] {
106
+ const out: AnthropicModelInfo[] = [];
107
+ const seen = new Set<string>();
108
+ // [1m] picker variant (devlog 260712 B1): Claude Code accounts exactly 1M for ids
109
+ // carrying the marker (2.1.207 binary: /\[1m\]/i → 1e6, compaction preserved), so
110
+ // models with an authoritative >=1M window get a second selectable row. In
111
+ // auto-context mode (devlog 020) the predicate widens to windows > 200k that can
112
+ // host the compact window — display stays honest (real window, not "1M"). Guards
113
+ // (audit R1#11): same dedupe set, never double-suffix.
114
+ const push1mVariant = (base: AnthropicModelInfo, contextWindow: number | undefined, mode: AutoContextMode = auto) => {
115
+ if (!shouldMarkOneMillion(contextWindow, mode)) return;
116
+ if (base.id.includes("[1m]")) return;
117
+ const id = `${base.id}[1m]`;
118
+ if (seen.has(id)) return;
119
+ seen.add(id);
120
+ const window = contextWindow as number;
121
+ const label = window >= ONE_MILLION ? "1M" : `${Math.round(window / 1_000)}k`;
122
+ out.push({ ...base, id, display_name: `${base.display_name} · ${label}`, max_input_tokens: Math.min(window, ONE_MILLION) });
123
+ };
124
+ for (const slug of nativeSlugs) {
125
+ const id = idStyle === "readable" ? claudeCodeNativeAlias(slug) : desktop3pAlias("native", slug);
126
+ if (seen.has(id)) continue;
127
+ seen.add(id);
128
+ const info = modelInfo(id, `${slug} (native)`, nativeEffectiveLadder(slug), true);
129
+ out.push(info);
130
+ push1mVariant(info, nativeOpenAiContextWindow(slug));
131
+ }
132
+ for (const m of routedModels) {
133
+ const id = idStyle === "readable" ? claudeCodeAlias(m.provider, m.id) : desktop3pAlias(m.provider, m.id);
134
+ if (seen.has(id)) continue;
135
+ seen.add(id);
136
+ const ladder = Array.isArray(m.reasoningEfforts) ? m.reasoningEfforts : [];
137
+ const imageInput = Array.isArray(m.inputModalities) ? m.inputModalities.includes("image") : false;
138
+ const info = modelInfo(id, `${m.id} (${m.provider})`, ladder, imageInput, m.contextWindow);
139
+ out.push(info);
140
+ // Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude
141
+ // routes — only a genuine >=1M window earns the variant row there.
142
+ push1mVariant(info, m.contextWindow, m.provider === "anthropic" ? AUTO_CONTEXT_OFF : auto);
143
+ }
144
+ return out;
145
+ }