@bitkyc08/opencodex 2.27.0 → 2.28.0

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/gui/dist/assets/{index-7jlKgmJd.js → index-D2sP-biU.js} +11 -11
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +59 -0
  5. package/src/adapters/base.ts +2 -0
  6. package/src/adapters/google-antigravity-replay.ts +16 -8
  7. package/src/adapters/google.ts +21 -4
  8. package/src/adapters/openai-chat.ts +151 -54
  9. package/src/adapters/openai-responses.ts +37 -0
  10. package/src/cli/index.ts +19 -6
  11. package/src/codex/account-usability.ts +3 -0
  12. package/src/codex/auth-api.ts +22 -5
  13. package/src/codex/auth-context.ts +55 -2
  14. package/src/codex/catalog/metadata.ts +17 -3
  15. package/src/codex/catalog/native-models.ts +22 -14
  16. package/src/codex/catalog/sync.ts +57 -11
  17. package/src/codex/convergence.ts +61 -13
  18. package/src/codex/model-entitlements.ts +353 -0
  19. package/src/codex/quota.ts +28 -3
  20. package/src/codex/routing.ts +14 -8
  21. package/src/generated/compatibility-version.json +51 -39
  22. package/src/lib/destination-policy.ts +47 -0
  23. package/src/lib/shadow-call.ts +15 -0
  24. package/src/oauth/index.ts +33 -5
  25. package/src/oauth/store.ts +11 -5
  26. package/src/providers/fastwire.ts +39 -8
  27. package/src/providers/quota.ts +9 -2
  28. package/src/providers/registry.ts +74 -5
  29. package/src/providers/service-tier.ts +16 -8
  30. package/src/responses/parser.ts +3 -9
  31. package/src/responses/tool-search-compat.ts +301 -0
  32. package/src/router.ts +7 -0
  33. package/src/routing/capability.ts +26 -9
  34. package/src/routing/compatibility/behavior.ts +41 -3
  35. package/src/server/chat-native.ts +11 -2
  36. package/src/server/index.ts +54 -8
  37. package/src/server/management/agent-settings-routes.ts +16 -2
  38. package/src/server/request-log.ts +31 -0
  39. package/src/server/responses/compact.ts +54 -7
  40. package/src/server/responses/core.ts +246 -39
  41. package/src/server/responses/responses-field-backfill.ts +88 -6
  42. package/src/server/responses/terminal-guard.ts +10 -0
  43. package/src/server/responses-tool-search-repair.ts +217 -0
  44. package/src/server/system-env.ts +74 -5
  45. package/src/usage/log.ts +4 -0
@@ -0,0 +1,217 @@
1
+ import {
2
+ isTranslatorBudgetExceededError,
3
+ type TranslatorBudget,
4
+ } from "../lib/translator-budget";
5
+ import {
6
+ restoreRoutedToolSearchCalls,
7
+ } from "../responses/tool-search-compat";
8
+ import {
9
+ replaceSseDataPayload,
10
+ sseDataPayload,
11
+ type SseBlockRewrite,
12
+ } from "./sse-payload-rewrite";
13
+
14
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
15
+ return !!value && typeof value === "object" && !Array.isArray(value);
16
+ }
17
+
18
+ type PendingArgumentBlock = {
19
+ block: string;
20
+ itemId?: string;
21
+ outputIndex?: number;
22
+ retainedBytes: number;
23
+ };
24
+
25
+ const MAX_PENDING_ARGUMENT_FRAMES = 256;
26
+ const MAX_PENDING_ARGUMENT_BYTES = 1024 * 1024;
27
+
28
+ /**
29
+ * Public Responses gateways stream a lowered search as a normal function lifecycle. Codex expects
30
+ * only `tool_search_call` items, so classify each item before dropping its function-argument
31
+ * frames. Unknown early argument frames stay bounded until their item arrives.
32
+ */
33
+ export function createRoutedToolSearchRestoreBlockRewrite(
34
+ names: ReadonlySet<string>,
35
+ budget?: TranslatorBudget,
36
+ ): SseBlockRewrite {
37
+ const routedItemIds = new Set<string>();
38
+ const ordinaryItemIds = new Set<string>();
39
+ let pendingArguments: PendingArgumentBlock[] = [];
40
+ let pendingArgumentBytes = 0;
41
+ let passthrough = false;
42
+ let disposed = false;
43
+
44
+ const releaseAll = (): void => {
45
+ if (disposed) return;
46
+ disposed = true;
47
+ if (pendingArgumentBytes > 0) {
48
+ budget?.releaseRetained(pendingArgumentBytes, { kind: "retained_collectors" });
49
+ }
50
+ pendingArguments = [];
51
+ pendingArgumentBytes = 0;
52
+ routedItemIds.clear();
53
+ ordinaryItemIds.clear();
54
+ };
55
+
56
+ const retainPending = (
57
+ block: string,
58
+ itemId: string | undefined,
59
+ outputIndex: number | undefined,
60
+ ): readonly string[] | null => {
61
+ const retainedBytes = Buffer.byteLength(block, "utf8");
62
+ const overflow = pendingArguments.length >= MAX_PENDING_ARGUMENT_FRAMES
63
+ || pendingArgumentBytes + retainedBytes > MAX_PENDING_ARGUMENT_BYTES;
64
+ if (overflow) {
65
+ const flushed = [...pendingArguments.map(pending => pending.block), block];
66
+ if (pendingArgumentBytes > 0) {
67
+ budget?.releaseRetained(pendingArgumentBytes, { kind: "retained_collectors" });
68
+ }
69
+ pendingArguments = [];
70
+ pendingArgumentBytes = 0;
71
+ passthrough = true;
72
+ // Deliberately KEEP routedItemIds. Overflow means we stop buffering UNKNOWN frames, not
73
+ // that we forget what we already classified: an item restored to `tool_search_call`
74
+ // upstream of here would otherwise start emitting `function_call_arguments.*` again and
75
+ // the client would see a mixed private/public lifecycle for one call.
76
+ ordinaryItemIds.clear();
77
+ return flushed;
78
+ }
79
+ if (retainedBytes > 0) {
80
+ try {
81
+ budget?.chargeRetained(retainedBytes, { kind: "retained_collectors" });
82
+ } catch (error) {
83
+ if (!isTranslatorBudgetExceededError(error)) throw error;
84
+ const flushed = [...pendingArguments.map(pending => pending.block), block];
85
+ if (pendingArgumentBytes > 0) {
86
+ budget?.releaseRetained(pendingArgumentBytes, { kind: "retained_collectors" });
87
+ }
88
+ pendingArguments = [];
89
+ pendingArgumentBytes = 0;
90
+ passthrough = true;
91
+ // Same reasoning as the frame/byte overflow above: an already-restored routed item
92
+ // must keep its frames suppressed even once buffering stops.
93
+ ordinaryItemIds.clear();
94
+ return flushed;
95
+ }
96
+ }
97
+ pendingArguments.push({ block, itemId, outputIndex, retainedBytes });
98
+ pendingArgumentBytes += retainedBytes;
99
+ return null;
100
+ };
101
+
102
+ const takePending = (
103
+ itemId: string | undefined,
104
+ outputIndex: number | undefined,
105
+ ): string[] => {
106
+ const matched: PendingArgumentBlock[] = [];
107
+ const remaining: PendingArgumentBlock[] = [];
108
+ for (const pending of pendingArguments) {
109
+ const matches = pending.itemId !== undefined
110
+ ? itemId !== undefined && pending.itemId === itemId
111
+ : outputIndex !== undefined && pending.outputIndex === outputIndex;
112
+ (matches ? matched : remaining).push(pending);
113
+ }
114
+ pendingArguments = remaining;
115
+ const retainedBytes = matched.reduce((total, pending) => total + pending.retainedBytes, 0);
116
+ if (retainedBytes > 0) {
117
+ budget?.releaseRetained(retainedBytes, { kind: "retained_collectors" });
118
+ pendingArgumentBytes = Math.max(0, pendingArgumentBytes - retainedBytes);
119
+ }
120
+ return matched.map(pending => {
121
+ if (pending.itemId !== undefined || itemId === undefined) return pending.block;
122
+ const payload = sseDataPayload(pending.block);
123
+ if (payload === null) return pending.block;
124
+ try {
125
+ const parsed: unknown = JSON.parse(payload);
126
+ return isPlainObject(parsed)
127
+ ? replaceSseDataPayload(pending.block, JSON.stringify({ ...parsed, item_id: itemId }))
128
+ : pending.block;
129
+ } catch {
130
+ return pending.block;
131
+ }
132
+ });
133
+ };
134
+
135
+ const rewrite: SseBlockRewrite = (block: string): readonly string[] => {
136
+ if (disposed) return [block];
137
+ const payload = sseDataPayload(block);
138
+ if (payload === null || payload === "[DONE]") return [block];
139
+ let parsed: unknown;
140
+ try {
141
+ parsed = JSON.parse(payload);
142
+ } catch {
143
+ return [block];
144
+ }
145
+ if (!isPlainObject(parsed)) return [block];
146
+
147
+ const type = typeof parsed.type === "string" ? parsed.type : "";
148
+ // After overflow we stop BUFFERING unknown frames, but an item already restored to
149
+ // `tool_search_call` must keep its public argument frames suppressed — otherwise the client
150
+ // receives a private item followed by `function_call_arguments.*` for the same id, which is
151
+ // exactly the mixed lifecycle this rewrite exists to prevent. Everything else passes through.
152
+ if (passthrough) {
153
+ const passthroughItemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined;
154
+ const isArgumentEvent = type === "response.function_call_arguments.delta"
155
+ || type === "response.function_call_arguments.done";
156
+ if (isArgumentEvent && passthroughItemId && routedItemIds.has(passthroughItemId)) return [];
157
+ return [block];
158
+ }
159
+ const outputIndex = typeof parsed.output_index === "number"
160
+ && Number.isInteger(parsed.output_index)
161
+ && parsed.output_index >= 0
162
+ ? parsed.output_index
163
+ : undefined;
164
+ if (
165
+ (type === "response.output_item.added" || type === "response.output_item.done")
166
+ && isPlainObject(parsed.item)
167
+ && parsed.item.type === "function_call"
168
+ && typeof parsed.item.name === "string"
169
+ ) {
170
+ const itemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined;
171
+ const routed = names.has(parsed.item.name);
172
+ if (itemId) {
173
+ if (routed) {
174
+ routedItemIds.add(itemId);
175
+ ordinaryItemIds.delete(itemId);
176
+ } else {
177
+ ordinaryItemIds.add(itemId);
178
+ routedItemIds.delete(itemId);
179
+ }
180
+ }
181
+ const pending = takePending(itemId, outputIndex);
182
+ const restored = routed ? restoreRoutedToolSearchCalls(parsed, names) : { value: parsed, changed: false };
183
+ const restoredBlock = restored.changed
184
+ ? replaceSseDataPayload(block, JSON.stringify(restored.value))
185
+ : block;
186
+ // Classification is retained past `output_item.done` for BOTH kinds, until the terminal
187
+ // event releases everything.
188
+ //
189
+ // `done` ends the item, not the id's relevance. Forgetting a ROUTED id let a trailing
190
+ // `function_call_arguments.*` — which some upstreams emit after done — fall through to
191
+ // the unknown-id branch and reach the client as a public frame for an item the client
192
+ // was told is a private `tool_search_call`: the mixed lifecycle this rewrite exists to
193
+ // prevent. Forgetting an ORDINARY id is not leak-shaped but is not free either, because
194
+ // the same fall-through buffers its trailing frames as unknown and delays them until an
195
+ // item that will never arrive. Neither id is dropped early.
196
+ return routed ? [restoredBlock] : [...pending, restoredBlock];
197
+ }
198
+
199
+ const itemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined;
200
+ const argumentEvent = type === "response.function_call_arguments.delta"
201
+ || type === "response.function_call_arguments.done";
202
+ if (argumentEvent && (!itemId || (!routedItemIds.has(itemId) && !ordinaryItemIds.has(itemId)))) {
203
+ return retainPending(block, itemId, outputIndex) ?? [];
204
+ }
205
+ if (argumentEvent && itemId && routedItemIds.has(itemId)) return [];
206
+
207
+ const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete";
208
+ if (!terminal) return [block];
209
+ const restored = restoreRoutedToolSearchCalls(parsed, names);
210
+ releaseAll();
211
+ return restored.changed
212
+ ? [replaceSseDataPayload(block, JSON.stringify(restored.value))]
213
+ : [block];
214
+ };
215
+ rewrite.dispose = releaseAll;
216
+ return rewrite;
217
+ }
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { accessSync, constants, readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
4
  import { getConfigDir } from "../config";
5
5
  import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows";
6
6
  import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens } from "../claude/auth-detect";
@@ -118,15 +118,84 @@ export function uninstallShellHook(): { removed: boolean; reason?: string } {
118
118
  try {
119
119
  const content = readFileSync(zshrcPath, "utf8");
120
120
  if (!content.includes(SHELL_HOOK_MARKER)) return { removed: false, reason: "not installed" };
121
- // Remove the hook block (marker line + source line + surrounding newlines)
122
- const cleaned = content.replace(/\n?# opencodex claude-env hook\n\[.*claude-env\.sh.*\n?/g, "\n");
121
+ // Match CR?LF, not LF alone. A .zshrc with CRLF line endings ordinary on a home
122
+ // directory an editor or another OS has touched — did not match, so the file was
123
+ // rewritten unchanged and the caller was told the hook was removed. Reporting success
124
+ // while the hook still sources on every new shell is the worse of the two failures.
125
+ const cleaned = content.replace(/\r?\n?# opencodex claude-env hook\r?\n\[.*claude-env\.sh.*(?:\r?\n)?/g, "\n");
126
+ // Verify instead of assuming: if the marker survives, the block is shaped in a way this
127
+ // pattern does not own, and the honest answer is failure rather than a silent no-op.
128
+ if (cleaned.includes(SHELL_HOOK_MARKER)) {
129
+ return { removed: false, reason: "hook block present but not in the expected shape; remove it manually" };
130
+ }
123
131
  writeFileSync(zshrcPath, cleaned, { encoding: "utf8", mode: 0o644 });
124
132
  return { removed: true };
125
- } catch {
133
+ } catch (error) {
134
+ if (error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT") {
135
+ return { removed: false, reason: "not installed" };
136
+ }
126
137
  return { removed: false, reason: "read/write failed" };
127
138
  }
128
139
  }
129
140
 
141
+ /** Whether a real `claude` executable is discoverable from this process's PATH. */
142
+ export function claudeCodeCliInstalled(pathValue = process.env.PATH): boolean {
143
+ if (!pathValue) return false;
144
+ for (const directory of pathValue.split(delimiter)) {
145
+ // An empty PATH segment means the current directory. Do not let the proxy treat a
146
+ // workspace-local file as a durable user installation.
147
+ if (!directory) continue;
148
+ const candidate = join(directory, "claude");
149
+ try {
150
+ if (!statSync(candidate).isFile()) continue;
151
+ accessSync(candidate, constants.X_OK);
152
+ return true;
153
+ } catch {
154
+ // Keep scanning PATH after missing, non-file, and non-executable entries.
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * Keep the shell hook aligned with the integration that can actually consume it.
162
+ * Claude Desktop uses its own profile and does not source `.zshrc`; this hook exists
163
+ * only for plain Claude Code CLI launches.
164
+ *
165
+ * Reconciliation is PATH-sensitive by construction: "Claude Code is installed" is answered
166
+ * from the PATH of whichever process calls this. A launchd/service context with a stripped
167
+ * PATH can therefore fail to see a `claude` the user's interactive shell finds, and this will
168
+ * remove the hook. That is the intended failure direction — removing an OpenCodex-owned block
169
+ * is reversible on the next foreground `ocx start`, whereas leaving a hook pointing at an
170
+ * uninstalled CLI is the stale state this reconciliation exists to clear. Only the block
171
+ * carrying our own marker is ever touched; user lines are preserved.
172
+ */
173
+ export function reconcileShellHook(systemEnvInjected: boolean): {
174
+ changed: boolean;
175
+ state: "installed" | "absent" | "failed";
176
+ reason?: string;
177
+ } {
178
+ if (process.platform !== "darwin") return { changed: false, state: "absent", reason: "not macOS" };
179
+ if (systemEnvInjected && claudeCodeCliInstalled()) {
180
+ const result = installShellHook();
181
+ if (result.installed) return { changed: true, state: "installed" };
182
+ if (result.reason === "already installed") {
183
+ return { changed: false, state: "installed", reason: result.reason };
184
+ }
185
+ return { changed: false, state: "failed", reason: result.reason ?? "install failed" };
186
+ }
187
+
188
+ const result = uninstallShellHook();
189
+ if (!result.removed && result.reason !== "not installed") {
190
+ return { changed: false, state: "failed", reason: result.reason ?? "remove failed" };
191
+ }
192
+ return {
193
+ changed: result.removed,
194
+ state: "absent",
195
+ reason: systemEnvInjected ? "Claude Code not installed" : "system environment inactive",
196
+ };
197
+ }
198
+
130
199
  const SYSTEM_ENV_NAMES = [
131
200
  "ANTHROPIC_BASE_URL",
132
201
  "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
package/src/usage/log.ts CHANGED
@@ -84,6 +84,8 @@ export interface PersistedUsageEntry {
84
84
  conversationId?: string;
85
85
  resolvedModel?: string;
86
86
  requestedModel?: string;
87
+ /** Original bare helper model when the opt-in shadow-call route rewrote this request. */
88
+ shadowCallRewrittenFrom?: string;
87
89
  /** Reasoning effort / service-tier metadata for GUI Logs after restart. */
88
90
  requestedEffort?: string;
89
91
  /** Adapter-normalized tier and exact upstream parameter emitted for this request. */
@@ -427,6 +429,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
427
429
  const tierOutcome = entry.tierOutcome ? normalizeAttemptTierOutcome(entry.tierOutcome) : undefined;
428
430
  const callerServiceTier = sanitizeLogMetadataString(entry.callerServiceTier);
429
431
  const responseServiceTier = sanitizeLogMetadataString(entry.responseServiceTier);
432
+ const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom);
430
433
  const routeDecision = entry.routeDecision
431
434
  ? normalizeRouteDecisionTrace(entry.routeDecision)
432
435
  : undefined;
@@ -453,6 +456,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
453
456
  : {}),
454
457
  ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
455
458
  ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
459
+ ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}),
456
460
  ...(typeof entry.requestedEffort === "string" && entry.requestedEffort
457
461
  ? { requestedEffort: capMetadataString(entry.requestedEffort) }
458
462
  : {}),