@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.
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/gui/dist/assets/index-BcaDQD3i.js +40 -0
- package/gui/dist/assets/index-Cq8maiJf.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +11 -9
- package/src/adapters/cursor/exec-policy.ts +38 -0
- package/src/adapters/cursor/live-transport.ts +4 -3
- package/src/adapters/cursor/protobuf-request.ts +20 -0
- package/src/adapters/cursor/transport.ts +5 -0
- package/src/adapters/cursor.ts +2 -2
- package/src/bridge.ts +4 -2
- package/src/claude/agents-inject.ts +198 -0
- package/src/claude/alias.ts +69 -0
- package/src/claude/context-windows.ts +189 -0
- package/src/claude/desktop-3p.ts +254 -0
- package/src/claude/gateway-cache.ts +70 -0
- package/src/claude/inbound-debug.ts +114 -0
- package/src/claude/inbound.ts +481 -0
- package/src/claude/model-info.ts +145 -0
- package/src/claude/outbound.ts +487 -0
- package/src/cli/claude.ts +157 -0
- package/src/cli/help.ts +12 -0
- package/src/cli/index.ts +86 -7
- package/src/cli/v2.ts +23 -18
- package/src/codex/features.ts +288 -16
- package/src/lib/crash-guard.ts +11 -1
- package/src/lib/debug-settings.ts +14 -2
- package/src/lib/token-estimate.ts +27 -1
- package/src/providers/registry.ts +1 -1
- package/src/server/auth-cors.ts +4 -2
- package/src/server/claude-messages.ts +494 -0
- package/src/server/index.ts +72 -0
- package/src/server/management-api.ts +226 -34
- package/src/server/request-log.ts +19 -4
- package/src/server/responses.ts +13 -1
- package/src/server/system-env.ts +314 -0
- package/src/types.ts +108 -0
- package/src/usage/log.ts +8 -2
- package/src/usage/summary.ts +18 -1
- package/src/usage/totals.ts +7 -18
- package/gui/dist/assets/index-Bp8dDrs5.js +0 -40
- 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
|
-
|
|
31
|
-
|
|
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 {
|
package/src/usage/summary.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/usage/totals.ts
CHANGED
|
@@ -1,25 +1,14 @@
|
|
|
1
1
|
import type { OcxUsage } from "../types";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
}
|