@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
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Claude-surface context-window map + effective model-env computation
3
+ * (devlog/260712_cli_context_cache/010 B2, audit R2#1/R3#1/R3#4/R4#3).
4
+ *
5
+ * The map registers EVERY selector form a Claude Code model slot might store —
6
+ * bare native slug, provider/id, desktop3p alias, legacy claude-ocx-* alias —
7
+ * with first-wins dedupe (mirrors the desktop3p registry collision policy).
8
+ * Values are authoritative context windows only (native override table /
9
+ * adapter-reported CatalogModel.contextWindow); nothing is guessed.
10
+ */
11
+ import { aliasForNative, aliasForRoute } from "./alias";
12
+ import { desktop3pAlias } from "./desktop-3p";
13
+ import { nativeOpenAiContextWindow, type CatalogModel } from "../codex/catalog";
14
+
15
+ const ONE_MILLION = 1_000_000;
16
+
17
+ /** Auto-context defaults (devlog 260712 020, user-approved). */
18
+ export const AUTO_COMPACT_WINDOW_DEFAULT = 350_000;
19
+ export const AUTO_CONTEXT_FLOOR = 200_000;
20
+ /** Binary-verified accepted range for CLAUDE_CODE_AUTO_COMPACT_WINDOW (2.1.207: pSo=1e5, yDs=1e6). */
21
+ export const AUTO_COMPACT_WINDOW_MIN = 100_000;
22
+ export const AUTO_COMPACT_WINDOW_MAX = ONE_MILLION;
23
+
24
+ /** Case-insensitive [1m] marker helpers — the CLI matches /\[1m\]/i (audit 021 #7). */
25
+ const ONE_M_MARKER_RE = /\[1m\]$/i;
26
+ export function hasOneMillionMarker(value: string): boolean {
27
+ return ONE_M_MARKER_RE.test(value);
28
+ }
29
+ export function stripOneMillionMarker(value: string): string {
30
+ return value.replace(ONE_M_MARKER_RE, "");
31
+ }
32
+
33
+ export interface AutoContextMode {
34
+ enabled: boolean;
35
+ /** Effective CLAUDE_CODE_AUTO_COMPACT_WINDOW value (tokens). */
36
+ compactWindow: number;
37
+ }
38
+
39
+ export const AUTO_CONTEXT_OFF: AutoContextMode = { enabled: false, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT };
40
+
41
+ interface AutoContextConfigSlice {
42
+ autoContext?: boolean;
43
+ autoCompactWindow?: number;
44
+ maxContextTokens?: number;
45
+ }
46
+
47
+ function inAutoCompactRange(value: number): boolean {
48
+ return Number.isInteger(value) && value >= AUTO_COMPACT_WINDOW_MIN && value <= AUTO_COMPACT_WINDOW_MAX;
49
+ }
50
+
51
+ /**
52
+ * Resolve the auto-context mode from claudeCode config. Disabled when the user
53
+ * turned it off OR when the legacy maxContextTokens override is set — that pair
54
+ * (MAX_CONTEXT_TOKENS + DISABLE_COMPACT) takes rule-1 precedence inside the CLI,
55
+ * making both AUTO_COMPACT_WINDOW and [1m] accounting inert.
56
+ *
57
+ * `envOverride` is the raw CLAUDE_CODE_AUTO_COMPACT_WINDOW the USER already
58
+ * exported (user-wins injection keeps it): a valid value drives the marking
59
+ * predicate so marker and threshold never separate (audit 021 #2); an invalid
60
+ * value disables auto marking entirely (the CLI would ignore it, leaving marked
61
+ * sub-1M models without their safety net). Out-of-range CONFIG values fall back
62
+ * to the 350k default (the management API rejects them; this guards hand-edits).
63
+ */
64
+ export function resolveAutoContext(claudeCode: AutoContextConfigSlice | undefined, envOverride?: string): AutoContextMode {
65
+ if (claudeCode?.autoContext === false) return AUTO_CONTEXT_OFF;
66
+ const maxCtx = claudeCode?.maxContextTokens;
67
+ if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) return AUTO_CONTEXT_OFF;
68
+ if (typeof envOverride === "string" && envOverride !== "") {
69
+ const parsed = Number(envOverride);
70
+ return inAutoCompactRange(parsed) ? { enabled: true, compactWindow: parsed } : AUTO_CONTEXT_OFF;
71
+ }
72
+ const raw = claudeCode?.autoCompactWindow;
73
+ const compactWindow = typeof raw === "number" && inAutoCompactRange(raw) ? raw : AUTO_COMPACT_WINDOW_DEFAULT;
74
+ return { enabled: true, compactWindow };
75
+ }
76
+
77
+ /**
78
+ * [1m]-marking predicate. Windows >= 1M always mark (CLI accounts exactly 1M).
79
+ * Auto-context additionally marks windows > 200k that can safely host the compact
80
+ * window — marking a model whose real window is BELOW the compact window would put
81
+ * the compaction safety net behind the real API limit (mid-session 400s).
82
+ */
83
+ export function shouldMarkOneMillion(window: number | undefined, auto: AutoContextMode): boolean {
84
+ if (typeof window !== "number" || window <= 0) return false;
85
+ if (window >= ONE_MILLION) return true;
86
+ return auto.enabled && window > AUTO_CONTEXT_FLOOR && window >= auto.compactWindow;
87
+ }
88
+
89
+ export function buildClaudeContextWindows(
90
+ nativeSlugs: readonly string[],
91
+ routedModels: readonly CatalogModel[],
92
+ ): Record<string, number> {
93
+ const out: Record<string, number> = {};
94
+ const put = (key: string | null, value: number) => {
95
+ if (!key) return;
96
+ if (out[key] === undefined) out[key] = value; // first-wins (registry policy)
97
+ };
98
+ for (const slug of nativeSlugs) {
99
+ const window = nativeOpenAiContextWindow(slug);
100
+ if (typeof window !== "number" || window <= 0) continue;
101
+ put(slug, window);
102
+ put(desktop3pAlias("native", slug), window);
103
+ put(aliasForNative(slug), window);
104
+ }
105
+ // Bare routed ids are registered only when unambiguous across providers (audit
106
+ // 021 #5) — natives are registered first, so a native slug always wins the bare key.
107
+ const bareCounts = new Map<string, number>();
108
+ for (const m of routedModels) bareCounts.set(m.id, (bareCounts.get(m.id) ?? 0) + 1);
109
+ for (const m of routedModels) {
110
+ const window = m.contextWindow;
111
+ if (typeof window !== "number" || window <= 0) continue;
112
+ // Anthropic passthrough guard (audit 021 #3): canonical claude ids ride the
113
+ // subscription passthrough — marking a sub-1M one would strap [1m]/1M-beta onto
114
+ // a model that cannot host it. Register anthropic rows only at >=1M.
115
+ if (m.provider === "anthropic" && window < ONE_MILLION) continue;
116
+ put(`${m.provider}/${m.id}`, window);
117
+ put(desktop3pAlias(m.provider, m.id), window);
118
+ put(aliasForRoute(m.provider, m.id), window);
119
+ if (bareCounts.get(m.id) === 1) put(m.id, window);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** Strip a trailing [1m] marker before map lookup (selector may already carry it). */
125
+ function bareSelector(value: string): string {
126
+ return stripOneMillionMarker(value);
127
+ }
128
+
129
+ /**
130
+ * Apply the [1m] context-variant marker to a model selector when its authoritative
131
+ * window is >= 1M (Claude Code accounts exactly 1M for the marker; compaction stays
132
+ * alive) — or, in auto-context mode, when the window clears the marking predicate
133
+ * above. Already-marked selectors pass through; unknown selectors stay untouched.
134
+ */
135
+ export function withOneMillionMarker(selector: string | undefined, windows: Record<string, number>, auto: AutoContextMode = AUTO_CONTEXT_OFF): string | undefined {
136
+ if (!selector) return selector;
137
+ if (hasOneMillionMarker(selector)) return selector;
138
+ const window = windows[bareSelector(selector)];
139
+ return shouldMarkOneMillion(window, auto) ? `${selector}[1m]` : selector;
140
+ }
141
+
142
+ export interface ClaudeTierModels {
143
+ opus?: string;
144
+ sonnet?: string;
145
+ haiku?: string;
146
+ fable?: string;
147
+ }
148
+
149
+ /**
150
+ * The exact env map Claude Code consumes for model slots (audit R4#4):
151
+ * ANTHROPIC_MODEL + the four tier defaults + the legacy small-fast alias.
152
+ * effective-haiku contract (audit R1#8): tierModels.haiku ?? smallFastModel, one
153
+ * value injected into BOTH haiku variables.
154
+ */
155
+ export function effectiveModelEnv(
156
+ claudeCode: { model?: string; smallFastModel?: string; tierModels?: ClaudeTierModels; autoContext?: boolean; autoCompactWindow?: number; maxContextTokens?: number } | undefined,
157
+ windows: Record<string, number>,
158
+ autoOverride?: AutoContextMode,
159
+ ): Record<string, string> {
160
+ const out: Record<string, string> = {};
161
+ const auto = autoOverride ?? resolveAutoContext(claudeCode);
162
+ const set = (name: string, value: string | undefined) => {
163
+ const marked = withOneMillionMarker(value, windows, auto);
164
+ if (marked) out[name] = marked;
165
+ };
166
+ set("ANTHROPIC_MODEL", claudeCode?.model);
167
+ set("ANTHROPIC_DEFAULT_OPUS_MODEL", claudeCode?.tierModels?.opus);
168
+ set("ANTHROPIC_DEFAULT_SONNET_MODEL", claudeCode?.tierModels?.sonnet);
169
+ set("ANTHROPIC_DEFAULT_FABLE_MODEL", claudeCode?.tierModels?.fable);
170
+ const effectiveHaiku = claudeCode?.tierModels?.haiku ?? claudeCode?.smallFastModel;
171
+ set("ANTHROPIC_DEFAULT_HAIKU_MODEL", effectiveHaiku);
172
+ set("ANTHROPIC_SMALL_FAST_MODEL", effectiveHaiku);
173
+ return out;
174
+ }
175
+
176
+ /** Shared 3s bounded acquisition (audit R4#3) for context-window sources. */
177
+ export async function boundedContextWindows(
178
+ acquire: () => Promise<Record<string, number>>,
179
+ timeoutMs = 3_000,
180
+ ): Promise<Record<string, number> | null> {
181
+ try {
182
+ return await Promise.race([
183
+ acquire(),
184
+ new Promise<null>(resolve => setTimeout(() => resolve(null), timeoutMs)),
185
+ ]);
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
@@ -0,0 +1,254 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export interface Desktop3pModelEntry {
7
+ name: string;
8
+ labelOverride: string;
9
+ anthropicFamilyTier: "opus";
10
+ isFamilyDefault?: boolean;
11
+ /**
12
+ * Desktop's documented 1M-context capability assertion. Set ONLY from an
13
+ * authoritative routed contextWindow >= 1M — never guessed (devlog 136 B5).
14
+ */
15
+ supports1m?: true;
16
+ }
17
+
18
+ /**
19
+ * static (default, Pro-verified devlog 138): pinned inferenceModels with
20
+ * modelDiscoveryEnabled:false — a static list OVERRIDES discovery (no merge), so
21
+ * this is the deterministic shape. hybrid keeps discovery:true alongside the list
22
+ * (claude-code-router's version-defensive pattern). discovery: /v1/models only.
23
+ */
24
+ export type Desktop3pConfigMode = "hybrid" | "discovery" | "static";
25
+
26
+ export interface Desktop3pRoutedModel {
27
+ provider: string;
28
+ id: string;
29
+ /** Authoritative context window (CatalogModel.contextWindow); optional. */
30
+ contextWindow?: number;
31
+ }
32
+
33
+ const SUPPORTS_1M_THRESHOLD = 1_000_000;
34
+
35
+ /** CLI arg parsing for `ocx claude desktop` mode flags (mutually exclusive). */
36
+ export function parseDesktop3pModeArgs(flags: string[]): { mode: Desktop3pConfigMode } | { error: string } {
37
+ const known = new Map<string, Desktop3pConfigMode>([
38
+ ["--static", "static"],
39
+ ["--hybrid", "hybrid"],
40
+ ["--discovery-only", "discovery"],
41
+ ]);
42
+ const unknown = flags.filter(a => !known.has(a));
43
+ if (unknown.length > 0) return { error: `알 수 없는 옵션: ${unknown.join(" ")} (지원: --static, --hybrid, --discovery-only)` };
44
+ const picked = [...new Set(flags.map(a => known.get(a)!))];
45
+ if (picked.length > 1) return { error: "모드 옵션은 하나만 쓸 수 있습니다 (--static | --hybrid | --discovery-only)." };
46
+ return { mode: picked[0] ?? "static" };
47
+ }
48
+
49
+ interface Desktop3pMetadataEntry {
50
+ id: string;
51
+ name: string;
52
+ [key: string]: unknown;
53
+ }
54
+
55
+ interface Desktop3pMetadata {
56
+ appliedId?: string;
57
+ entries: Desktop3pMetadataEntry[];
58
+ [key: string]: unknown;
59
+ }
60
+
61
+ let desktop3pRegistry = new Map<string, string>();
62
+
63
+ /** Derive a stable letter-first, three-character base36 code from a route key. */
64
+ export function deriveDesktop3pCode(route: string): string {
65
+ const hash = createHash("sha256").update(route).digest();
66
+ const n = hash.readUInt32BE(0) % 33696;
67
+ const first = String.fromCharCode(97 + Math.floor(n / 1296));
68
+ const rest = (n % 1296).toString(36).padStart(2, "0");
69
+ return first + rest;
70
+ }
71
+
72
+ /**
73
+ * Alias for one proxy model. Real Anthropic models pass through unchanged (they must
74
+ * keep hitting the sk-ant native passthrough); everything else gets a Claude-shaped
75
+ * `claude-opus-4-8-{code}` id. Opus 4.8 is chosen deliberately: Desktop's effort
76
+ * selector is an allowlist keyed on exact supported model ids (Opus 4.8/4.7/4.6,
77
+ * Sonnet 4.6 — devlog 131), and 4.6+ canonical ids are dateless, so the letter-first
78
+ * 3-char suffix can never collide with a real id or a legacy date suffix.
79
+ */
80
+ export function desktop3pAlias(provider: string, modelId: string): string {
81
+ if (provider === "anthropic" && modelId.startsWith("claude-")) return modelId;
82
+ return `claude-opus-4-8-${deriveDesktop3pCode(`${provider}/${modelId}`)}`;
83
+ }
84
+
85
+ /** Pre-rename alias shape (claude-opus-4-{code}) — still decoded for stale Desktop configs. */
86
+ export function legacyDesktop3pAlias(provider: string, modelId: string): string {
87
+ return `claude-opus-4-${deriveDesktop3pCode(`${provider}/${modelId}`)}`;
88
+ }
89
+
90
+ function displayModelId(modelId: string): string {
91
+ return modelId
92
+ .split(/[-_]+/)
93
+ .filter(Boolean)
94
+ .map(part => {
95
+ const lower = part.toLowerCase();
96
+ if (lower === "gpt" || lower === "glm" || lower === "ai") return lower.toUpperCase();
97
+ return part.charAt(0).toUpperCase() + part.slice(1);
98
+ })
99
+ .join(" ");
100
+ }
101
+
102
+ function collectDesktop3pModels(
103
+ nativeSlugs: string[],
104
+ routedModels: Array<Desktop3pRoutedModel>,
105
+ ): { models: Desktop3pModelEntry[]; registry: Map<string, string> } {
106
+ const registry = new Map<string, string>();
107
+ const models: Desktop3pModelEntry[] = [];
108
+ const candidates: Desktop3pRoutedModel[] = [
109
+ ...nativeSlugs.map(id => ({ provider: "native", id })),
110
+ ...routedModels,
111
+ ];
112
+
113
+ for (const { provider, id, contextWindow } of candidates) {
114
+ const route = `${provider}/${id}`;
115
+ const alias = desktop3pAlias(provider, id);
116
+ const supports1m = typeof contextWindow === "number" && contextWindow >= SUPPORTS_1M_THRESHOLD
117
+ ? { supports1m: true as const }
118
+ : {};
119
+ if (alias === id) {
120
+ // Real Anthropic model: keep it OUT of the decode registry — registering it would
121
+ // make resolveInboundModel() non-identity and kill the sk-ant native passthrough
122
+ // (audit 133 #1). It still appears in the static Desktop model list below.
123
+ models.push({
124
+ name: alias,
125
+ labelOverride: `${displayModelId(id)} (${provider})`,
126
+ anthropicFamilyTier: "opus",
127
+ ...supports1m,
128
+ });
129
+ continue;
130
+ }
131
+ const existingRoute = registry.get(alias);
132
+ if (existingRoute !== undefined) {
133
+ console.warn(`[opencodex] Claude Desktop 3P alias collision: ${alias} maps to both ${existingRoute} and ${route}; skipping ${route}`);
134
+ continue;
135
+ }
136
+
137
+ registry.set(alias, route);
138
+ // Back-compat decode for Desktop configs written before the opus-4-8 rename.
139
+ const legacy = legacyDesktop3pAlias(provider, id);
140
+ if (!registry.has(legacy)) registry.set(legacy, route);
141
+ models.push({
142
+ name: alias,
143
+ labelOverride: `${displayModelId(id)} (${provider})`,
144
+ anthropicFamilyTier: "opus",
145
+ ...supports1m,
146
+ });
147
+ }
148
+
149
+ if (models[0]) models[0].isFamilyDefault = true;
150
+ return { models, registry };
151
+ }
152
+
153
+ /** Build and install the registry used to decode Desktop aliases. */
154
+ export function buildDesktop3pRegistry(
155
+ nativeSlugs: string[],
156
+ routedModels: Array<Desktop3pRoutedModel>,
157
+ ): Map<string, string> {
158
+ const { registry } = collectDesktop3pModels(nativeSlugs, routedModels);
159
+ desktop3pRegistry = registry;
160
+ return registry;
161
+ }
162
+
163
+ /** Generate Claude Desktop 3P model entries from the proxy's available models. */
164
+ export function generateDesktop3pModels(
165
+ nativeSlugs: string[],
166
+ routedModels: Array<Desktop3pRoutedModel>,
167
+ ): Desktop3pModelEntry[] {
168
+ const { models, registry } = collectDesktop3pModels(nativeSlugs, routedModels);
169
+ desktop3pRegistry = registry;
170
+ return models;
171
+ }
172
+
173
+ /** Resolve an alias using the most recently generated Desktop model registry. */
174
+ export function resolveDesktop3pAlias(alias: string): string | null {
175
+ return desktop3pRegistry.get(alias) ?? null;
176
+ }
177
+
178
+ /**
179
+ * Generate the complete Claude Desktop 3P gateway config.
180
+ *
181
+ * Default mode is "static" (Pro-verified, devlog 138): the static list is the ONLY
182
+ * channel for supports1m/tier pins and it overrides discovery anyway (no merge), so
183
+ * discovery stays off for determinism. supports1m makes Desktop offer a separate 1M
184
+ * row; selecting it sends the bare id + `anthropic-beta: context-1m-2025-08-07`.
185
+ */
186
+ export function generateDesktop3pConfig(
187
+ port: number,
188
+ nativeSlugs: string[],
189
+ routedModels: Array<Desktop3pRoutedModel>,
190
+ apiKey = "ocx",
191
+ mode: Desktop3pConfigMode = "static",
192
+ ): object {
193
+ const base = {
194
+ inferenceProvider: "gateway",
195
+ inferenceCredentialKind: "static",
196
+ inferenceGatewayBaseUrl: `http://127.0.0.1:${port}`,
197
+ inferenceGatewayApiKey: apiKey,
198
+ };
199
+ if (mode === "discovery") {
200
+ // Build/refresh the decode registry even though no static list is emitted.
201
+ buildDesktop3pRegistry(nativeSlugs, routedModels);
202
+ return { ...base, modelDiscoveryEnabled: true };
203
+ }
204
+ return {
205
+ ...base,
206
+ modelDiscoveryEnabled: mode === "hybrid",
207
+ inferenceModels: generateDesktop3pModels(nativeSlugs, routedModels),
208
+ };
209
+ }
210
+
211
+ function parseMetadata(path: string): Desktop3pMetadata {
212
+ if (!existsSync(path)) return { entries: [] };
213
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<Desktop3pMetadata>;
214
+ if (!Array.isArray(parsed.entries)) throw new Error("Claude Desktop 3P _meta.json has no entries array");
215
+ return { ...parsed, entries: parsed.entries };
216
+ }
217
+
218
+ /** Write and apply the opencodex config in Claude Desktop 3P's config library. */
219
+ export function writeDesktop3pConfig(
220
+ port: number,
221
+ nativeSlugs: string[],
222
+ routedModels: Array<Desktop3pRoutedModel>,
223
+ apiKey?: string,
224
+ mode: Desktop3pConfigMode = "static",
225
+ ): { written: boolean; path: string; reason?: string } {
226
+ const libraryPath = join(homedir(), "Library", "Application Support", "Claude-3p", "configLibrary");
227
+ const metadataPath = join(libraryPath, "_meta.json");
228
+ let configPath = libraryPath;
229
+
230
+ try {
231
+ mkdirSync(libraryPath, { recursive: true, mode: 0o700 });
232
+ const metadata = parseMetadata(metadataPath);
233
+ const existing = metadata.entries.find(entry => entry?.name === "opencodex" && typeof entry.id === "string");
234
+ const id = existing?.id ?? randomUUID();
235
+ configPath = join(libraryPath, `${id}.json`);
236
+ const entry: Desktop3pMetadataEntry = existing ? { ...existing, id, name: "opencodex" } : { id, name: "opencodex" };
237
+ const entries = existing
238
+ ? metadata.entries.map(current => current === existing ? entry : current)
239
+ : [...metadata.entries, entry];
240
+
241
+ writeFileSync(configPath, JSON.stringify(generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode), null, 2) + "\n", {
242
+ encoding: "utf8",
243
+ mode: 0o600,
244
+ });
245
+ writeFileSync(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n", {
246
+ encoding: "utf8",
247
+ mode: 0o600,
248
+ });
249
+ return { written: true, path: configPath };
250
+ } catch (error) {
251
+ const reason = error instanceof Error ? error.message : String(error);
252
+ return { written: false, path: configPath, reason };
253
+ }
254
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Claude Code gateway-model cache writer (devlog 260712 030).
3
+ *
4
+ * Claude Code 2.1.207 refreshes ~/.claude/cache/gateway-models.json ONLY when it
5
+ * holds a credential (q5l(): `if(!ANTHROPIC_AUTH_TOKEN && !apiKey) return`). Our
6
+ * subscription-preserving launch deliberately sets no token, so the CLI can never
7
+ * refresh its picker list itself — it reads whatever cache exists. We therefore
8
+ * pre-write the cache in the exact on-disk schema the CLI uses:
9
+ * { baseUrl, fetchedAt, models: [{ id, display_name? }] } (mode 0600)
10
+ * mirroring its `/^(claude|anthropic)/i` usable-id filter. The picker validates
11
+ * only `baseUrl === ANTHROPIC_BASE_URL`, so a foreign base URL is simply ignored.
12
+ */
13
+ import { mkdirSync, writeFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ export interface GatewayModelRow {
18
+ id: string;
19
+ display_name?: string;
20
+ }
21
+
22
+ /** Claude Code config dir (CLAUDE_CONFIG_DIR override honored, like the CLI). */
23
+ export function claudeConfigDir(): string {
24
+ const custom = process.env.CLAUDE_CONFIG_DIR;
25
+ return custom && custom.length > 0 ? custom : join(homedir(), ".claude");
26
+ }
27
+
28
+ /** Write the cache file; returns its path or null (best-effort, never throws). */
29
+ export function writeGatewayModelCache(baseUrl: string, models: readonly GatewayModelRow[], configDir = claudeConfigDir()): string | null {
30
+ try {
31
+ // Mirror the CLI's usable-id filter so our file matches what it would cache.
32
+ const usable = models.filter(m => /^(claude|anthropic)/i.test(m.id));
33
+ if (usable.length === 0) return null;
34
+ const cacheDir = join(configDir, "cache");
35
+ mkdirSync(cacheDir, { recursive: true });
36
+ const path = join(cacheDir, "gateway-models.json");
37
+ const payload = {
38
+ baseUrl,
39
+ fetchedAt: Date.now(),
40
+ models: usable.map(m => (m.display_name === undefined ? { id: m.id } : { id: m.id, display_name: m.display_name })),
41
+ };
42
+ writeFileSync(path, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 });
43
+ return path;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /** Fetch the anthropic-flavor /v1/models from the local proxy and write the cache. */
50
+ export async function refreshGatewayModelCacheFromProxy(port: number, timeoutMs = 3_000, configDir?: string): Promise<string | null> {
51
+ try {
52
+ // ?ids=cli pins the readable claude-ocx id family deterministically (audit 051
53
+ // #5): the cache prewrite must not depend on UA sniffing.
54
+ const res = await fetch(`http://127.0.0.1:${port}/v1/models?limit=1000&ids=cli`, {
55
+ headers: { "anthropic-version": "2023-06-01" },
56
+ signal: AbortSignal.timeout(timeoutMs),
57
+ });
58
+ if (!res.ok) return null;
59
+ const body = await res.json() as { data?: Array<Record<string, unknown>> };
60
+ const models: GatewayModelRow[] = (Array.isArray(body.data) ? body.data : [])
61
+ .filter(m => typeof m.id === "string" && (m.id as string).length > 0)
62
+ .map(m => ({
63
+ id: m.id as string,
64
+ display_name: typeof m.display_name === "string" ? m.display_name : undefined,
65
+ }));
66
+ return writeGatewayModelCache(`http://127.0.0.1:${port}`, models, configDir);
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Claude inbound debug capture (devlog/260711_claude_inbound/130 B1).
3
+ *
4
+ * Opt-in ring (last 20) of ALLOWLIST SCALARS from inbound Anthropic requests so the
5
+ * user can watch live what Claude Desktop/Code actually sends per effort-slider
6
+ * position (thinking.type / output_config.effort) and whether metadata.user_id
7
+ * exists (prompt-cache affinity, H1/H2).
8
+ *
9
+ * Privacy contract (audit 133 R1#6/R2#3): no prompt text, no raw objects, no stable
10
+ * hashes. The only identity-ish values are 8-char HMAC tags salted with a
11
+ * process-random key — comparable for equality WITHIN one proxy run, useless as a
12
+ * cross-run fingerprint. Capture is gated on the `claude` debug flag and the ring is
13
+ * cleared when the flag turns off.
14
+ */
15
+ import { createHmac, randomBytes } from "node:crypto";
16
+ import { isClaudeDebugEnabled } from "../lib/debug-settings";
17
+
18
+ export interface ClaudeInboundDebugEntry {
19
+ at: number;
20
+ endpoint: "messages" | "count_tokens";
21
+ model: string;
22
+ resolvedModel?: string;
23
+ stream?: boolean;
24
+ maxTokens?: number;
25
+ thinkingType?: string;
26
+ thinkingBudgetTokens?: number;
27
+ outputConfigEffort?: string;
28
+ metadataKeys?: string[];
29
+ hasMetadataUserId: boolean;
30
+ hasSystem: boolean;
31
+ /** Raw anthropic-beta header (comma list) — carries context-1m / effort betas. */
32
+ anthropicBeta?: string;
33
+ /** Ephemeral equality tags (process-salted HMAC, 8 chars) — run-local identity only. */
34
+ userIdTag?: string;
35
+ systemTag?: string;
36
+ }
37
+
38
+ const RING_LIMIT = 20;
39
+ const ring: ClaudeInboundDebugEntry[] = [];
40
+ const salt = randomBytes(16).toString("hex");
41
+ let lastEnabled = false;
42
+
43
+ function tag(value: string): string {
44
+ return createHmac("sha256", salt).update(value).digest("hex").slice(0, 8);
45
+ }
46
+
47
+ type Rec = Record<string, unknown>;
48
+
49
+ function isRec(v: unknown): v is Rec {
50
+ return !!v && typeof v === "object" && !Array.isArray(v);
51
+ }
52
+
53
+ function systemText(system: unknown): string | undefined {
54
+ if (typeof system === "string") return system.length > 0 ? system : undefined;
55
+ if (Array.isArray(system)) {
56
+ const parts = system
57
+ .filter((b): b is Rec => isRec(b) && b.type === "text" && typeof b.text === "string")
58
+ .map(b => b.text as string);
59
+ return parts.length > 0 ? parts.join("\n\n") : undefined;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ /** Record one inbound request. No-op (and ring flush) when the claude debug flag is off. */
65
+ export function captureClaudeInbound(
66
+ endpoint: "messages" | "count_tokens",
67
+ body: unknown,
68
+ resolvedModel?: string,
69
+ anthropicBeta?: string,
70
+ ): void {
71
+ const enabled = isClaudeDebugEnabled();
72
+ if (!enabled) {
73
+ if (lastEnabled) ring.length = 0; // flag turned off: drop captured entries
74
+ lastEnabled = false;
75
+ return;
76
+ }
77
+ lastEnabled = true;
78
+ if (!isRec(body)) return;
79
+ const thinking = isRec(body.thinking) ? body.thinking : undefined;
80
+ const outputConfig = isRec(body.output_config) ? body.output_config : undefined;
81
+ const metadata = isRec(body.metadata) ? body.metadata : undefined;
82
+ const userId = metadata && typeof metadata.user_id === "string" ? metadata.user_id : undefined;
83
+ const system = systemText(body.system);
84
+ const entry: ClaudeInboundDebugEntry = {
85
+ at: Date.now(),
86
+ endpoint,
87
+ model: typeof body.model === "string" ? body.model : "unknown",
88
+ ...(resolvedModel ? { resolvedModel } : {}),
89
+ ...(typeof body.stream === "boolean" ? { stream: body.stream } : {}),
90
+ ...(typeof body.max_tokens === "number" ? { maxTokens: body.max_tokens } : {}),
91
+ ...(thinking && typeof thinking.type === "string" ? { thinkingType: thinking.type } : {}),
92
+ ...(thinking && typeof thinking.budget_tokens === "number" ? { thinkingBudgetTokens: thinking.budget_tokens } : {}),
93
+ ...(outputConfig && typeof outputConfig.effort === "string" ? { outputConfigEffort: outputConfig.effort } : {}),
94
+ ...(metadata ? { metadataKeys: Object.keys(metadata) } : {}),
95
+ hasMetadataUserId: userId !== undefined,
96
+ hasSystem: system !== undefined,
97
+ ...(anthropicBeta ? { anthropicBeta } : {}),
98
+ ...(userId !== undefined ? { userIdTag: tag(userId) } : {}),
99
+ ...(system !== undefined ? { systemTag: tag(system) } : {}),
100
+ };
101
+ ring.push(entry);
102
+ if (ring.length > RING_LIMIT) ring.shift();
103
+ }
104
+
105
+ /** Newest-first snapshot for /api/claude/inbound-debug. */
106
+ export function getClaudeInboundDebugEntries(): ClaudeInboundDebugEntry[] {
107
+ return [...ring].reverse();
108
+ }
109
+
110
+ /** Test isolation / explicit clear. */
111
+ export function clearClaudeInboundDebug(): void {
112
+ ring.length = 0;
113
+ lastEnabled = false;
114
+ }