@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,314 @@
1
+ import { execSync } from "node:child_process";
2
+ import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getConfigDir } from "../config";
5
+ import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows";
6
+ import type { OcxConfig } from "../types";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Shell-hook env file: written on inject, sourced by the shell hook in .zshrc.
10
+ // This works for ALL new shells immediately, unlike launchctl setenv which only
11
+ // reaches processes launched directly by launchd (not Terminal.app children).
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export function getShellEnvFilePath(): string {
15
+ return join(getConfigDir(), "claude-env.sh");
16
+ }
17
+
18
+ function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<string, string> = {}, auto?: AutoContextMode): void {
19
+ const lines = [
20
+ `# Generated by opencodex — do not edit manually`,
21
+ `export ANTHROPIC_BASE_URL="http://127.0.0.1:${port}"`,
22
+ `export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,
23
+ ];
24
+ if (config.apiKeys?.length) {
25
+ lines.push(`export ANTHROPIC_AUTH_TOKEN="${config.apiKeys[0].key.replace(/"/g, '\\"')}"`);
26
+ }
27
+ // New lever keys are CONDITIONAL exports (audit 139 R2#1): a value the user already
28
+ // exported in their shell wins even though launchctl knows nothing about it.
29
+ const conditional = (name: string, value: string) =>
30
+ `[ -z "\${${name}+x}" ] && export ${name}="${value.replace(/"/g, '\\"')}"`;
31
+ // Model slots (default + tiers + legacy small-fast) with [1m] applied (devlog 260712 B2).
32
+ if (modelEnv.ANTHROPIC_MODEL) {
33
+ lines.push(`export ANTHROPIC_MODEL="${modelEnv.ANTHROPIC_MODEL.replace(/"/g, '\\"')}"`);
34
+ } else if (config.claudeCode?.model) {
35
+ lines.push(`export ANTHROPIC_MODEL="${config.claudeCode.model}"`);
36
+ }
37
+ for (const [name, value] of Object.entries(modelEnv)) {
38
+ if (name === "ANTHROPIC_MODEL") continue;
39
+ lines.push(conditional(name, value));
40
+ }
41
+ const maxCtx = config.claudeCode?.maxContextTokens;
42
+ if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) {
43
+ lines.push(conditional("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx))));
44
+ lines.push(conditional("DISABLE_COMPACT", "1"));
45
+ }
46
+ // Auto-context (devlog 260712 020): same contract as `ocx claude` / launchctl.
47
+ const autoShell = auto ?? resolveAutoContext(config.claudeCode);
48
+ if (autoShell.enabled) lines.push(conditional("CLAUDE_CODE_AUTO_COMPACT_WINDOW", String(autoShell.compactWindow)));
49
+ if (config.claudeCode?.alwaysEnableEffort === true) {
50
+ lines.push(conditional("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1"));
51
+ }
52
+ mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
53
+ writeFileSync(getShellEnvFilePath(), lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
54
+ }
55
+
56
+ function removeShellEnvFile(): void {
57
+ try { unlinkSync(getShellEnvFilePath()); } catch { /* already gone */ }
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // .zshrc hook auto-install: adds a one-liner that sources claude-env.sh.
62
+ // Idempotent — skips if the hook line already exists.
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const SHELL_HOOK_MARKER = "# opencodex claude-env hook";
66
+ const SHELL_HOOK_LINE = `${SHELL_HOOK_MARKER}\n[ -f ~/.opencodex/claude-env.sh ] && source ~/.opencodex/claude-env.sh`;
67
+
68
+ export function installShellHook(): { installed: boolean; reason?: string } {
69
+ if (process.platform !== "darwin") return { installed: false, reason: "not macOS" };
70
+ const home = process.env.HOME;
71
+ if (!home) return { installed: false, reason: "no HOME" };
72
+ const zshrcPath = join(home, ".zshrc");
73
+ try {
74
+ let content = "";
75
+ try { content = readFileSync(zshrcPath, "utf8"); } catch { /* file doesn't exist yet */ }
76
+ if (content.includes(SHELL_HOOK_MARKER)) return { installed: false, reason: "already installed" };
77
+ const addition = `\n${SHELL_HOOK_LINE}\n`;
78
+ writeFileSync(zshrcPath, content + addition, { encoding: "utf8", mode: 0o644 });
79
+ return { installed: true };
80
+ } catch (err) {
81
+ return { installed: false, reason: `write failed: ${err instanceof Error ? err.message : String(err)}` };
82
+ }
83
+ }
84
+
85
+ export function uninstallShellHook(): { removed: boolean; reason?: string } {
86
+ if (process.platform !== "darwin") return { removed: false, reason: "not macOS" };
87
+ const home = process.env.HOME;
88
+ if (!home) return { removed: false, reason: "no HOME" };
89
+ const zshrcPath = join(home, ".zshrc");
90
+ try {
91
+ const content = readFileSync(zshrcPath, "utf8");
92
+ if (!content.includes(SHELL_HOOK_MARKER)) return { removed: false, reason: "not installed" };
93
+ // Remove the hook block (marker line + source line + surrounding newlines)
94
+ const cleaned = content.replace(/\n?# opencodex claude-env hook\n\[.*claude-env\.sh.*\n?/g, "\n");
95
+ writeFileSync(zshrcPath, cleaned, { encoding: "utf8", mode: 0o644 });
96
+ return { removed: true };
97
+ } catch {
98
+ return { removed: false, reason: "read/write failed" };
99
+ }
100
+ }
101
+
102
+ const SYSTEM_ENV_NAMES = [
103
+ "ANTHROPIC_BASE_URL",
104
+ "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
105
+ "ANTHROPIC_AUTH_TOKEN",
106
+ ] as const;
107
+
108
+ interface SystemEnvTracking {
109
+ pid: number;
110
+ port: number;
111
+ injectedAt: string;
112
+ /** Keys that were actually set by injection (revert only unsets these). */
113
+ injectedKeys?: string[];
114
+ }
115
+
116
+ type SystemEnvResult = { injected: boolean; reason?: string };
117
+ type RevertResult = { reverted: boolean; reason?: string };
118
+ type CleanupResult = { cleaned: boolean; reason?: string };
119
+
120
+ export function getSystemEnvTrackingPath(): string {
121
+ return join(getConfigDir(), "system-env-port");
122
+ }
123
+
124
+ export function launchctlGetenv(name: string): string | undefined {
125
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return undefined;
126
+ try {
127
+ const value = execSync(`launchctl getenv ${name}`, { encoding: "utf8" }).trim();
128
+ return value || undefined;
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ function readTracking(): SystemEnvTracking | undefined {
135
+ try {
136
+ const tracking = JSON.parse(readFileSync(getSystemEnvTrackingPath(), "utf8")) as Partial<SystemEnvTracking>;
137
+ if (!Number.isInteger(tracking.port) || typeof tracking.pid !== "number" || typeof tracking.injectedAt !== "string") {
138
+ return undefined;
139
+ }
140
+ return tracking as SystemEnvTracking;
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
146
+ function shellArg(value: string): string {
147
+ return /^[A-Za-z0-9_./:-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
148
+ }
149
+
150
+ function setLaunchctlEnv(name: string, value: string): void {
151
+ execSync(`launchctl setenv ${name} ${shellArg(value)}`);
152
+ }
153
+
154
+ function unsetLaunchctlEnv(name: string): void {
155
+ execSync(`launchctl unsetenv ${name}`);
156
+ }
157
+
158
+ function ownedBaseUrl(port: number): string {
159
+ return `http://127.0.0.1:${port}`;
160
+ }
161
+
162
+ /**
163
+ * In-process effective model-env (default + tier slots, [1m] applied) under the shared
164
+ * 3s bound (audit R4#3). Returns {} on timeout/failure so injection degrades safely.
165
+ */
166
+ async function computeEffectiveModelEnv(config: OcxConfig, auto?: AutoContextMode): Promise<{ modelEnv: Record<string, string>; windows: Record<string, number> }> {
167
+ const { boundedContextWindows, buildClaudeContextWindows, effectiveModelEnv } = await import("../claude/context-windows");
168
+ const windows = await boundedContextWindows(async () => {
169
+ const { gatherRoutedModels, visibleNativeSlugs } = await import("../codex/catalog");
170
+ return buildClaudeContextWindows([...visibleNativeSlugs(config)], await gatherRoutedModels(config));
171
+ });
172
+ return { modelEnv: effectiveModelEnv(config.claudeCode, windows ?? {}, auto), windows: windows ?? {} };
173
+ }
174
+
175
+ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<SystemEnvResult> {
176
+ if (process.platform !== "darwin") return { injected: false, reason: "not macOS" };
177
+ if (config.claudeCode?.enabled === false) return { injected: false, reason: "claude disabled" };
178
+
179
+ // Default OFF — only inject when explicitly enabled by the user.
180
+ if (config.claudeCode?.systemEnv !== true) return { injected: false, reason: "systemEnv disabled" };
181
+
182
+ await cleanStaleSystemEnv();
183
+
184
+ const currentBaseUrl = launchctlGetenv("ANTHROPIC_BASE_URL");
185
+ if (currentBaseUrl && !/^http:\/\/127\.0\.0\.1:\d+$/.test(currentBaseUrl)) {
186
+ return { injected: false, reason: "user has custom ANTHROPIC_BASE_URL" };
187
+ }
188
+ // After stale cleanup, if a tracking file still exists with a DIFFERENT port,
189
+ // another live instance owns the env — don't overwrite it.
190
+ const existingTracking = readTracking();
191
+ if (existingTracking && existingTracking.port !== port) {
192
+ return { injected: false, reason: `another instance owns env (port ${existingTracking.port})` };
193
+ }
194
+
195
+ setLaunchctlEnv("ANTHROPIC_BASE_URL", ownedBaseUrl(port));
196
+ setLaunchctlEnv("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
197
+ const injectedKeys = [
198
+ "ANTHROPIC_BASE_URL",
199
+ "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
200
+ ];
201
+ if (config.apiKeys?.length) {
202
+ setLaunchctlEnv("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
203
+ injectedKeys.push("ANTHROPIC_AUTH_TOKEN");
204
+ }
205
+ // Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the
206
+ // launchd domain, and track ONLY the keys we actually injected so revert cannot
207
+ // delete a pre-existing user value (audit 139 #3).
208
+ const injectLever = (name: string, value: string) => {
209
+ if (launchctlGetenv(name) !== undefined) return;
210
+ setLaunchctlEnv(name, value);
211
+ injectedKeys.push(name);
212
+ };
213
+ // Model slots (default + tier defaults + legacy small-fast) with [1m] auto-marking
214
+ // (devlog 260712 B2, audit R2#3/R4#3): in-process context-window computation under
215
+ // the same 3s bound; on timeout the tier keys are simply not injected this run.
216
+ // Auto-context: a user-owned launchd value drives the marking predicate so the
217
+ // marker and threshold never separate (audit 021 #2); injectLever's user-wins
218
+ // check below keeps that value untouched.
219
+ const userAutoCompact = launchctlGetenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW");
220
+ const auto = resolveAutoContext(config.claudeCode, userAutoCompact);
221
+ const { modelEnv, windows } = await computeEffectiveModelEnv(config, auto);
222
+ for (const [name, value] of Object.entries(modelEnv)) {
223
+ if (name === "ANTHROPIC_MODEL") continue; // legacy slot handled by shell file only (back-compat)
224
+ injectLever(name, value);
225
+ }
226
+ const maxCtx = config.claudeCode?.maxContextTokens;
227
+ if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) {
228
+ injectLever("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx)));
229
+ injectLever("DISABLE_COMPACT", "1");
230
+ }
231
+ // Auto-context (devlog 260712 020): user-wins lever, inert when maxContextTokens set.
232
+ if (auto.enabled) injectLever("CLAUDE_CODE_AUTO_COMPACT_WINDOW", String(auto.compactWindow));
233
+ if (config.claudeCode?.alwaysEnableEffort === true) {
234
+ injectLever("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1");
235
+ }
236
+
237
+ // Shell-hook env file: works for new shells in already-running Terminal.app.
238
+ writeShellEnvFile(port, config, modelEnv, auto);
239
+
240
+ // Gateway-model cache pre-write (devlog 030): plain `claude` sessions read the
241
+ // picker list from ~/.claude/cache/gateway-models.json and cannot refresh it
242
+ // without a token — keep it in sync with this proxy's /v1/models. Best-effort.
243
+ try {
244
+ const { refreshGatewayModelCacheFromProxy } = await import("../claude/gateway-cache");
245
+ await refreshGatewayModelCacheFromProxy(port);
246
+ } catch { /* best-effort */ }
247
+
248
+ // Roster agent definitions (devlog 070): same launch-time sync for plain `claude`.
249
+ // Reuses the window map computed above (audit 071 #5 — no second acquisition).
250
+ try {
251
+ const { injectClaudeAgentDefs } = await import("../claude/agents-inject");
252
+ injectClaudeAgentDefs(config, windows);
253
+ } catch { /* best-effort */ }
254
+
255
+ mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
256
+ writeFileSync(getSystemEnvTrackingPath(), JSON.stringify({
257
+ pid: process.pid,
258
+ port,
259
+ injectedAt: new Date().toISOString(),
260
+ injectedKeys,
261
+ }), { encoding: "utf8", mode: 0o600 });
262
+
263
+ return { injected: true };
264
+ }
265
+
266
+ export function revertSystemEnv(): RevertResult {
267
+ if (process.platform !== "darwin") return { reverted: false, reason: "not macOS" };
268
+
269
+ const tracking = readTracking();
270
+ if (!tracking) return { reverted: false, reason: "no tracking file" };
271
+
272
+ try {
273
+ if (launchctlGetenv("ANTHROPIC_BASE_URL") !== ownedBaseUrl(tracking.port)) {
274
+ return { reverted: false, reason: "ownership mismatch" };
275
+ }
276
+
277
+ // Only unset keys that were actually injected (preserves pre-existing user tokens).
278
+ const keysToUnset = tracking.injectedKeys ?? SYSTEM_ENV_NAMES as unknown as string[];
279
+ for (const name of keysToUnset) {
280
+ try {
281
+ unsetLaunchctlEnv(name);
282
+ } catch {
283
+ // Continue removing the remaining variables during shutdown.
284
+ }
285
+ }
286
+ removeShellEnvFile();
287
+ try {
288
+ unlinkSync(getSystemEnvTrackingPath());
289
+ } catch {
290
+ // The environment was reverted even if the tracking file disappeared concurrently.
291
+ }
292
+ return { reverted: true };
293
+ } catch {
294
+ return { reverted: false, reason: "revert failed" };
295
+ }
296
+ }
297
+
298
+ export async function cleanStaleSystemEnv(): Promise<CleanupResult> {
299
+ const tracking = readTracking();
300
+ if (!tracking) return { cleaned: false, reason: "no tracking file" };
301
+
302
+ try {
303
+ const response = await fetch(`${ownedBaseUrl(tracking.port)}/healthz`, {
304
+ signal: AbortSignal.timeout(1_000),
305
+ });
306
+ if (response.ok) return { cleaned: false, reason: "proxy still alive" };
307
+ } catch {
308
+ // A failed or timed-out health check means the tracked proxy is stale.
309
+ }
310
+
311
+ const reverted = revertSystemEnv();
312
+ if (!reverted.reverted) return { cleaned: false, reason: reverted.reason };
313
+ return { cleaned: true };
314
+ }
package/src/types.ts CHANGED
@@ -224,6 +224,15 @@ export interface OcxUrlCitation {
224
224
  title?: string;
225
225
  }
226
226
 
227
+ /**
228
+ * Canonical usage convention (devlog/260711_claude_inbound/070):
229
+ * - `inputTokens` is the TOTAL prompt size, INCLUDING cache reads and cache writes
230
+ * (OpenAI Responses convention). Anthropic parse sites normalize into this shape.
231
+ * - `cachedInputTokens` is cache READ tokens only (a subset of `inputTokens`).
232
+ * - `cacheReadInputTokens`/`cacheCreationInputTokens` carry the read/write split when
233
+ * the provider reports both; reads mirror `cachedInputTokens`.
234
+ * - `totalTokens` = inputTokens + outputTokens. Never re-add cache detail on top.
235
+ */
227
236
  export interface OcxUsage {
228
237
  inputTokens: number;
229
238
  outputTokens: number;
@@ -235,10 +244,90 @@ export interface OcxUsage {
235
244
  estimated?: boolean;
236
245
  }
237
246
 
247
+ /**
248
+ * Claude Code inbound settings (devlog/260711_claude_inbound). Consumed by the
249
+ * /v1/messages surface, the `ocx claude` launcher, and the GUI Claude page.
250
+ */
251
+ export interface OcxClaudeCodeConfig {
252
+ /** Kill switch for the /v1/messages inbound (GUI "Claude ON" toggle). Default: enabled. */
253
+ enabled?: boolean;
254
+ /**
255
+ * Verbatim passthrough of unmapped claude/anthropic models to api.anthropic.com with the
256
+ * caller's own sk-ant-* credential (Claude Code subscription OAuth). Default: enabled.
257
+ */
258
+ nativePassthrough?: boolean;
259
+ /** Upstream for the native passthrough (tests/enterprise gateways). Default: https://api.anthropic.com */
260
+ anthropicBaseUrl?: string;
261
+ /** Default model slot injected as ANTHROPIC_MODEL by `ocx claude`. */
262
+ model?: string;
263
+ /** Haiku/small-fast slot injected as ANTHROPIC_DEFAULT_HAIKU_MODEL (+ legacy SMALL_FAST). */
264
+ smallFastModel?: string;
265
+ /** Inbound model id remaps: exact id first, then date-stripped (`-\d{8}$`). */
266
+ modelMap?: Record<string, string>;
267
+ /**
268
+ * Inject ANTHROPIC_BASE_URL etc. into the macOS user domain via `launchctl setenv`
269
+ * so plain `claude` commands route through the proxy without `ocx claude`. Reverted
270
+ * on stop/shutdown. Default: true when `enabled` is not false. macOS only.
271
+ */
272
+ systemEnv?: boolean;
273
+ /**
274
+ * Context-window override for Claude Code/Desktop clients (devlog 136 B6):
275
+ * injected as CLAUDE_CODE_MAX_CONTEXT_TOKENS + DISABLE_COMPACT=1 (the official
276
+ * env pair — recognized claude-shaped ids need both). WARNING: DISABLE_COMPACT
277
+ * turns off auto-compaction. Unset = client defaults.
278
+ */
279
+ maxContextTokens?: number;
280
+ /**
281
+ * Opt-in CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1 injection. Default OFF: opus-shaped
282
+ * aliases already carry output_config.effort on the wire (devlog 136 실측), and
283
+ * forcing effort on every request can leak reasoning params to non-reasoning routes.
284
+ */
285
+ alwaysEnableEffort?: boolean;
286
+ /**
287
+ * Subagent tier slots (devlog 260712 B2): injected as ANTHROPIC_DEFAULT_*_MODEL so
288
+ * Claude Code's Agent-tool aliases (opus/sonnet/haiku/fable + parent-inherit) route
289
+ * to proxy models. haiku falls back to smallFastModel (one effective value feeds
290
+ * both ANTHROPIC_DEFAULT_HAIKU_MODEL and legacy ANTHROPIC_SMALL_FAST_MODEL).
291
+ */
292
+ tierModels?: { opus?: string; sonnet?: string; haiku?: string; fable?: string };
293
+ /**
294
+ * Auto-context (devlog 260712 020): when not false, routed/native models whose
295
+ * authoritative window is > 200k AND >= the compact window get the [1m] marker
296
+ * (Claude Code then accounts 1M) and CLAUDE_CODE_AUTO_COMPACT_WINDOW is injected
297
+ * so compaction fires at the real budget. 2.1.207 semantics (binary-verified):
298
+ * effective compact window = min(believed window, env) — one global env behaves
299
+ * like a per-model floor. Default: enabled. Inert while maxContextTokens is set
300
+ * (the legacy DISABLE_COMPACT pair takes rule-1 precedence in the CLI).
301
+ */
302
+ autoContext?: boolean;
303
+ /** Compact-window tokens for auto-context. Default 350_000. */
304
+ autoCompactWindow?: number;
305
+ /**
306
+ * Bundled-skill content elision for ROUTED (non-Anthropic) models (devlog 260712
307
+ * 060): Skill-tool results whose skill name matches an entry here are replaced
308
+ * with a short stub in the anthropic->responses translation. Third-party models
309
+ * are not trained on these Anthropic doc bundles, and claude-api alone injects
310
+ * ~136k tokens (GitHub anthropics/claude-code#74473). Native Anthropic
311
+ * passthrough never goes through the translation, so Claude models keep the
312
+ * full content. Default: ["claude-api"]. Empty array = explicitly off.
313
+ */
314
+ blockedSkills?: string[];
315
+ /**
316
+ * Sync the featured subagent roster (config.subagentModels + main model) into
317
+ * ~/.claude/agents/ocx-*.md custom agent definitions at launch (devlog 260712
318
+ * 070) so any routed model is dispatchable as a subagent_type — the Agent
319
+ * tool's model argument is a hard 4-alias enum, but definition frontmatter is
320
+ * free. Only ocx-*.md files are owned/pruned. Default: enabled.
321
+ */
322
+ injectAgents?: boolean;
323
+ }
324
+
238
325
  export interface OcxConfig {
239
326
  port: number;
240
327
  providers: Record<string, OcxProviderConfig>;
241
328
  defaultProvider: string;
329
+ /** Claude Code inbound + launcher settings. */
330
+ claudeCode?: OcxClaudeCodeConfig;
242
331
  /**
243
332
  * Up to 5 routed model ids ("<provider>/<model>") to feature FIRST in the injected Codex catalog.
244
333
  * Codex's spawn_agent only advertises the first 5 routed models, so this picks which 5 appear.
@@ -251,6 +340,12 @@ export interface OcxConfig {
251
340
  * the Codex ladder (src/reasoning-effort.ts CODEX_REASONING_LEVELS) at the API boundary.
252
341
  */
253
342
  injectionEffort?: string;
343
+ /**
344
+ * When true, OpenAI-routed requests include `service_tier: "priority"` (fast inference).
345
+ * When false, service_tier is stripped so requests use default speed.
346
+ * Undefined = passthrough (don't modify what the client sends).
347
+ */
348
+ fastMode?: boolean;
254
349
  /**
255
350
  * Custom override for the injected multi-agent guidance body (the text inside the
256
351
  * <multi_agent_mode> tags). When set, it replaces the built-in prompt on whichever
@@ -551,6 +646,19 @@ export interface OcxProviderConfig {
551
646
  * controlled by their own opt-in config.
552
647
  */
553
648
  unsafeAllowNativeLocalExec?: boolean;
649
+ /**
650
+ * Cursor adapter only: native local exec policy mode (exec-policy.ts).
651
+ * "off" (default) rejects all server-driven local exec; "on" always allows
652
+ * (same as legacy unsafeAllowNativeLocalExec:true); "codex-sandbox" allows only
653
+ * when the request's instructions/developer text declares the Codex
654
+ * danger-full-access sandbox. NOTE: the declaration is CALLER-CONTROLLED prose —
655
+ * the proxy cannot verify it. Enable "codex-sandbox" only where every client
656
+ * that can reach the data plane is trusted: the default loopback bind admits
657
+ * ANY process on this host without auth (including other local users on
658
+ * multi-user machines), and isAllowedRequestOrigin blocks non-loopback
659
+ * browser origins by default but not loopback-origin or origin-less callers.
660
+ */
661
+ nativeLocalExec?: "off" | "codex-sandbox" | "on";
554
662
  }
555
663
 
556
664
  export interface CodexAccount {
package/src/usage/log.ts CHANGED
@@ -27,8 +27,14 @@ export function usageTotalTokens(usage: OcxUsage | undefined): number | undefine
27
27
  return usageDisplayTotalTokens(usage);
28
28
  }
29
29
 
30
- function isEstimatedUsageProvider(provider: string): boolean {
31
- return provider === "kiro" || provider.startsWith("kiro-") || provider === "cursor";
30
+ /**
31
+ * Providers whose adapters can only estimate usage (no authoritative per-turn frame).
32
+ * Callers should pass the route ADAPTER when available; the name-prefix match is a
33
+ * fallback for paths that only know the configured provider name (e.g. "cursor-mykey").
34
+ */
35
+ function isEstimatedUsageProvider(providerOrAdapter: string): boolean {
36
+ return providerOrAdapter === "kiro" || providerOrAdapter.startsWith("kiro-")
37
+ || providerOrAdapter === "cursor" || providerOrAdapter.startsWith("cursor-");
32
38
  }
33
39
 
34
40
  export function usageForFinalLog(provider: string, usage: OcxUsage | undefined): OcxUsage | undefined {
@@ -14,6 +14,8 @@ export interface UsageSummaryTotals {
14
14
  inputTokens: number;
15
15
  outputTokens: number;
16
16
  cachedInputTokens: number;
17
+ cacheReadInputTokens: number;
18
+ cacheCreationInputTokens: number;
17
19
  reasoningOutputTokens: number;
18
20
  totalTokens: number;
19
21
  coverageRatio: number;
@@ -108,6 +110,8 @@ function blankTotals(): UsageSummaryTotals {
108
110
  inputTokens: 0,
109
111
  outputTokens: 0,
110
112
  cachedInputTokens: 0,
113
+ cacheReadInputTokens: 0,
114
+ cacheCreationInputTokens: 0,
111
115
  reasoningOutputTokens: 0,
112
116
  totalTokens: 0,
113
117
  coverageRatio: 0,
@@ -131,7 +135,20 @@ function addTokens(totals: UsageSummaryTotals, entry: PersistedUsageEntry): void
131
135
  if (!entry.usage) return;
132
136
  totals.inputTokens += entry.usage.inputTokens;
133
137
  totals.outputTokens += entry.usage.outputTokens;
134
- if (typeof entry.usage.cachedInputTokens === "number") totals.cachedInputTokens += entry.usage.cachedInputTokens;
138
+ // Prefer the explicit read/write split; legacy claude-route rows stored read+write
139
+ // combined in cachedInputTokens with only the creation split present (devlog 070),
140
+ // so recover reads by subtracting the write share for those rows.
141
+ const creation = entry.usage.cacheCreationInputTokens;
142
+ const read = typeof entry.usage.cacheReadInputTokens === "number"
143
+ ? entry.usage.cacheReadInputTokens
144
+ : typeof entry.usage.cachedInputTokens === "number" && typeof creation === "number"
145
+ ? Math.max(0, entry.usage.cachedInputTokens - creation)
146
+ : entry.usage.cachedInputTokens;
147
+ if (typeof read === "number") {
148
+ totals.cachedInputTokens += read;
149
+ totals.cacheReadInputTokens += read;
150
+ }
151
+ if (typeof creation === "number") totals.cacheCreationInputTokens += creation;
135
152
  if (typeof entry.usage.reasoningOutputTokens === "number") totals.reasoningOutputTokens += entry.usage.reasoningOutputTokens;
136
153
  totals.totalTokens += usageDisplayTotalTokens(entry.usage, entry.totalTokens) ?? 0;
137
154
  }
@@ -1,25 +1,14 @@
1
1
  import type { OcxUsage } from "../types";
2
2
 
3
- function cacheDetailTokens(usage: OcxUsage): number | undefined {
4
- const hasRead = typeof usage.cacheReadInputTokens === "number";
5
- const hasCreate = typeof usage.cacheCreationInputTokens === "number";
6
- if (!hasRead && !hasCreate) return undefined;
7
- return (usage.cacheReadInputTokens ?? 0) + (usage.cacheCreationInputTokens ?? 0);
8
- }
9
-
10
- export function usageInputTokensWithCacheDetail(usage: OcxUsage): number {
11
- const cacheDetailTotal = cacheDetailTokens(usage);
12
- return usage.inputTokens + (cacheDetailTotal ?? 0);
13
- }
14
-
3
+ /**
4
+ * Canonical display total (devlog 070): `inputTokens` is already INCLUSIVE of cache
5
+ * read/write, so the total is simply input+output. Cache detail is never re-added —
6
+ * that was the 2x inflation bug on cache-heavy Claude rows. `Math.max` keeps legacy
7
+ * persisted rows (pre-070 exclusive input) honest via their stored explicit total.
8
+ */
15
9
  export function usageDisplayTotalTokens(usage: OcxUsage | undefined, storedTotal?: number): number | undefined {
16
10
  if (!usage) return storedTotal;
17
11
  const baseTotal = usage.inputTokens + usage.outputTokens;
18
12
  const explicitTotal = usage.totalTokens ?? storedTotal;
19
- const cacheDetailTotal = cacheDetailTokens(usage);
20
- if (cacheDetailTotal !== undefined) {
21
- const detailedTotal = baseTotal + cacheDetailTotal;
22
- return typeof explicitTotal === "number" ? Math.max(explicitTotal, detailedTotal) : detailedTotal;
23
- }
24
- return explicitTotal ?? baseTotal;
13
+ return typeof explicitTotal === "number" ? Math.max(explicitTotal, baseTotal) : baseTotal;
25
14
  }