@coseung2/opencodex 2.8.0-cs.13 → 2.8.0-cs.15

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 (60) hide show
  1. package/gui/dist/assets/index-BhXIu7c0.js +67 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +3 -3
  4. package/packages/ocx-notch/README.md +2 -1
  5. package/src/adapters/cursor/discovery.ts +6 -2
  6. package/src/adapters/cursor/effort-map.ts +3 -0
  7. package/src/adapters/google-antigravity-replay.ts +24 -0
  8. package/src/adapters/google.ts +16 -11
  9. package/src/chat/inbound.ts +5 -11
  10. package/src/cli/account-api.ts +9 -1
  11. package/src/cli/account-extended.ts +4 -1
  12. package/src/codex/account-label.ts +14 -1
  13. package/src/codex/account-lifecycle.ts +12 -1
  14. package/src/codex/account-namespaces.ts +21 -0
  15. package/src/codex/account-priority.ts +49 -0
  16. package/src/codex/account-store.ts +2 -1
  17. package/src/codex/auth-api.ts +108 -17
  18. package/src/codex/auth-context.ts +61 -16
  19. package/src/codex/catalog/metadata.ts +34 -12
  20. package/src/codex/catalog/parsing.ts +8 -1
  21. package/src/codex/catalog/provider-fetch.ts +24 -6
  22. package/src/codex/catalog.ts +1 -1
  23. package/src/codex/pool-rotation.ts +51 -4
  24. package/src/codex/quota.ts +154 -35
  25. package/src/codex/routing.ts +139 -33
  26. package/src/codex/warmup.ts +193 -85
  27. package/src/config.ts +84 -1
  28. package/src/lib/bounded-body.ts +13 -6
  29. package/src/lib/bun-stream-caps.ts +5 -6
  30. package/src/lib/redact.ts +13 -0
  31. package/src/oauth/index.ts +79 -12
  32. package/src/oauth/log.ts +3 -1
  33. package/src/oauth/store.ts +31 -8
  34. package/src/providers/antigravity-models.ts +53 -24
  35. package/src/providers/codex-capacity.ts +303 -0
  36. package/src/providers/model-rename-migration.ts +147 -0
  37. package/src/providers/model-rename-startup.ts +29 -0
  38. package/src/providers/quota.ts +126 -16
  39. package/src/providers/registry.ts +258 -38
  40. package/src/responses/parser.ts +19 -12
  41. package/src/responses/spill-store.ts +14 -1
  42. package/src/responses/state.ts +108 -14
  43. package/src/server/index.ts +9 -1
  44. package/src/server/management/logs-usage-routes.ts +1 -0
  45. package/src/server/management/oauth-account-routes.ts +8 -1
  46. package/src/server/relay.ts +10 -42
  47. package/src/server/request-log.ts +42 -1
  48. package/src/server/responses/compact.ts +16 -4
  49. package/src/server/responses/core.ts +217 -59
  50. package/src/server/responses/empty-completion-guard.ts +275 -0
  51. package/src/server/responses/encrypted-payload.ts +54 -39
  52. package/src/server/responses/fetch-helpers.ts +24 -3
  53. package/src/server/responses/ws-upstream.ts +318 -0
  54. package/src/server/sse-frame-buffer.ts +292 -0
  55. package/src/server/ws-bridge.ts +17 -11
  56. package/src/types.ts +8 -0
  57. package/src/usage/log.ts +24 -0
  58. package/src/usage/summary.ts +152 -2
  59. package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
  60. package/gui/dist/assets/index-BucjyD4I.js +0 -67
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BucjyD4I.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-BhXIu7c0.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-CxisOo-q.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coseung2/opencodex",
3
- "version": "2.8.0-cs.13",
3
+ "version": "2.8.0-cs.15",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -62,11 +62,11 @@
62
62
  "dependencies": {
63
63
  "@bufbuild/protobuf": "^2.12.0",
64
64
  "@modelcontextprotocol/sdk": "^1",
65
- "bun": "1.3.14",
65
+ "bun": "1.4.0",
66
66
  "zod": "4.4.3"
67
67
  },
68
68
  "devDependencies": {
69
- "@types/bun": "1.3.14",
69
+ "@types/bun": "1.4.0",
70
70
  "typescript": "5.9.3"
71
71
  },
72
72
  "overrides": {
@@ -51,9 +51,10 @@ Startup failures and Rust panics are written to the bounded diagnostic log `%LOC
51
51
  - Drag anywhere on the notch to move it; its chosen position is preserved while it expands, collapses, or refreshes.
52
52
  - Click a provider with multiple accounts to expand or collapse its account rows.
53
53
  - Click the pause icon beside an OpenAI account to exclude it from the rotation pool. A paused account shows a play icon that includes it again. The icon updates immediately and rolls back if OCX rejects the request.
54
+ - OpenAI accounts with reset credits show the same compact ticket badge used by the dashboard beside the pause/play control. Click it to open an in-app modal with every ticket's grant and expiry dates; OCX identifies the FIFO next ticket, confirms consumption in the modal, and refreshes the displayed count after a successful reset.
54
55
  - Accounts that require renewed credentials expose an inline **재인증** action on the same row. OpenAI's main account is intentionally excluded; eligible OpenAI pool and generic OAuth accounts can start, cancel, and retry browser authentication.
55
56
  - Providers with quota data are shown first. Configured providers without quota data remain visible with their usage and account controls.
56
57
  - Press **Esc** to collapse.
57
58
  - Right-click to add a provider, set the real OCX account rotation threshold, fine-tune it by 1%, **Refresh**, or **Exit**. The opaque provider modal groups supported presets into Account, Free, and Paid tabs. Canonical OpenAI adds another Codex account, OAuth presets use browser/device authorization, and required-key presets use masked API-key entry. Fixed-endpoint key-optional presets (including OpenCode Free and MiMo Free) accept an empty key; entering a key instead adds a distinct account slot to OCX's existing key pool, which can be switched from the provider row. Ollama, vLLM, and LM Studio use local auth without a key. Cloudflare Workers AI also appears in Free and asks for its Account ID before creating the provider. Endpoint-choice and other unresolved placeholder-URL presets remain omitted because this compact modal cannot preserve those setup contracts safely. `Off` writes threshold `0`.
58
59
 
59
- Quota percentages are shown as used percentages with the same 5-hour/weekly/monthly/custom-window rows, reset countdowns, 5px progress bars, green fill, and green-to-amber threshold warning used by the OCX dashboard. Provider usage is merged by exact provider name, limited to the newest day, and formatted with Korean `만/억/조` units. OpenAI account rows show their own weekly/monthly quotas; OAuth and key-pool rows show their masked identity and active/health state, while provider-level quota remains associated with the active account. The native window uses a subtle 238/255 global alpha.
60
+ Quota percentages are shown as used percentages with 5-hour/weekly/monthly/custom-window columns in one compact row, reset countdowns, 5px progress bars, green fill, and the green-to-amber threshold warning used by the OCX dashboard. Columns are derived from whichever quota windows the management API returns, so provider-specific windows appear without a plan-name list in the notch. Provider usage is merged by exact provider name, limited to the newest day, and formatted with Korean `만/억/조` units. OpenAI account rows show their own 5-hour/weekly/monthly quotas; OAuth and key-pool rows show their masked identity and active/health state, while provider-level quota remains associated with the active account. The native window uses a subtle 238/255 global alpha.
@@ -189,10 +189,14 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
189
189
  { id: "claude-4.6-opus", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
190
190
  { id: "claude-4.6-sonnet", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
191
191
  { id: "claude-opus-4-7", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
192
- // opus-4-7-fast: effort-suffix tiers unverified -> no tier picker; sent bare like live-only ids.
193
- { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K },
192
+ // Opus Fast families are exposed only as effort-suffixed wire ids. The
193
+ // request mapper therefore always resolves a tier and never sends a bare
194
+ // `-fast` id.
195
+ { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
196
+ { id: "claude-opus-4-8-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
194
197
  { id: "claude-opus-4-8", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
195
198
  { id: "claude-opus-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
199
+ { id: "claude-opus-5-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
196
200
  { id: "claude-fable-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
197
201
 
198
202
  { id: "composer-1", contextWindow: CONTEXT_200K },
@@ -24,8 +24,11 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
24
24
  // against Anthropic's effort ladder docs and Cursor's live model lineup.
25
25
  "claude-fable-5": ["low", "medium", "high", "xhigh", "max"],
26
26
  "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"],
27
+ "claude-opus-4-7-fast": ["low", "medium", "high", "xhigh", "max"],
27
28
  "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"],
29
+ "claude-opus-4-8-fast": ["low", "medium", "high", "xhigh", "max"],
28
30
  "claude-opus-5": ["low", "medium", "high", "xhigh", "max"],
31
+ "claude-opus-5-fast": ["low", "medium", "high"],
29
32
  "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"],
30
33
  "glm-5.2": ["high", "max"],
31
34
  // GetUsableModels (2026-07-28) lists kimi-k3 only as effort-suffixed kimi-k3-{low,high,max};
@@ -1,3 +1,4 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
1
2
  import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
2
3
 
3
4
  /**
@@ -48,6 +49,7 @@ const DEFAULT_REPLAY_LIMITS: ReplayLimits = {
48
49
  };
49
50
 
50
51
  const replayCache = new Map<string, ReplayEntry>();
52
+ const replayIsolationKey = randomBytes(32);
51
53
  const utf8 = new TextEncoder();
52
54
  let replayLimits = { ...DEFAULT_REPLAY_LIMITS };
53
55
  let replayBytes = 0;
@@ -58,6 +60,28 @@ function replayKey(model: string, sessionId: string): string {
58
60
  return `${model}::session:${sessionId}`;
59
61
  }
60
62
 
63
+ /**
64
+ * Process-local replay namespace isolated by both destination and credential.
65
+ * Raw credentials never become cache keys; the HMAC output is non-reversible and
66
+ * intentionally changes after restart because this replay cache is memory-only.
67
+ */
68
+ export function scopedAntigravityReplaySessionId(
69
+ sessionId: string,
70
+ baseUrl: string | undefined,
71
+ credential: string | undefined,
72
+ headers?: Record<string, string>,
73
+ ): string | undefined {
74
+ const session = sessionId.trim();
75
+ const destination = baseUrl?.trim().replace(/\/+$/, "");
76
+ const secret = credential?.trim();
77
+ if (!session || !destination || !secret) return undefined;
78
+ const headerEntries = Object.entries(headers ?? {}).sort(([a], [b]) => a.localeCompare(b));
79
+ const scope = createHmac("sha256", replayIsolationKey)
80
+ .update(JSON.stringify([destination, secret, headerEntries]))
81
+ .digest("hex");
82
+ return `${scope}:${session}`;
83
+ }
84
+
61
85
  /** Recursively canonicalize a JSON value: object keys sorted, arrays preserved. */
62
86
  function canonicalJson(value: unknown): string {
63
87
  if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
@@ -21,7 +21,7 @@ import { isVertexTruncationReason, vertexTruncationErrorMessage } from "./google
21
21
  import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
22
22
  import { compileGoogleWireBody } from "./google-wire-compiler";
23
23
  import { neutralizeIdentity } from "./identity";
24
- import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
24
+ import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay, scopedAntigravityReplaySessionId } from "./google-antigravity-replay";
25
25
  import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
26
26
  import {
27
27
  isTranslatorBudgetExceededError,
@@ -275,7 +275,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
275
275
  // Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest
276
276
  // can stash the CCA model/session for parseStream's reasoning-replay observation.
277
277
  let antigravityModel: string | undefined;
278
- let antigravitySession: string | undefined;
278
+ let antigravityReplaySession: string | undefined;
279
279
  let restoreGoogleToolName = (name: string): string => name;
280
280
  return {
281
281
  name: "google",
@@ -333,7 +333,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
333
333
  const mappedEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
334
334
  const { wireModelId, thinkingLevel } = resolveAntigravityEffortWireModel(parsed.modelId, mappedEffort);
335
335
  antigravityModel = wireModelId;
336
- antigravitySession = sessionId;
336
+ antigravityReplaySession = scopedAntigravityReplaySessionId(
337
+ sessionId,
338
+ provider.baseUrl,
339
+ provider.apiKey,
340
+ provider.headers,
341
+ );
337
342
  // Effort → thinkingConfig for CCA (CLIProxyAPI proven: request.generationConfig.thinkingConfig).
338
343
  // Suffix/compat IDs return thinkingLevel=undefined — the suffix IS the effort, no contradiction.
339
344
  if (thinkingLevel) {
@@ -359,8 +364,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
359
364
  // Compile names before replay: signatures are keyed by the exact provider-visible name.
360
365
  if (Array.isArray((request as { contents?: unknown[] }).contents)) {
361
366
  const contents = (request as { contents: unknown[] }).contents;
362
- if (antigravityUsesReplayCache(wireModelId)) {
363
- applyAntigravityReplay(wireModelId, sessionId, contents);
367
+ if (antigravityUsesReplayCache(wireModelId) && antigravityReplaySession) {
368
+ applyAntigravityReplay(wireModelId, antigravityReplaySession, contents);
364
369
  } else {
365
370
  sanitizeAntigravityClaudeSignatures(contents);
366
371
  }
@@ -457,9 +462,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
457
462
  const err = chunk.error as { message?: string } | undefined;
458
463
  // Clear-on-invalid: a signature rejection means our replayed thoughtSignatures are stale.
459
464
  // Drop the cache entry so the next turn starts clean instead of re-injecting a bad sig.
460
- if (provider.googleMode === "cloud-code-assist" && antigravityModel && antigravitySession
465
+ if (provider.googleMode === "cloud-code-assist" && antigravityModel && antigravityReplaySession
461
466
  && /signature|invalid_argument|invalid argument/i.test(err?.message ?? "")) {
462
- clearAntigravityReplay(antigravityModel, antigravitySession);
467
+ clearAntigravityReplay(antigravityModel, antigravityReplaySession);
463
468
  }
464
469
  yield { type: "error", message: err?.message ?? "upstream error" };
465
470
  return "terminate";
@@ -494,8 +499,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
494
499
 
495
500
  const parts = candidates[0].content?.parts as { text?: string; functionCall?: { name: string; args: unknown } }[] | undefined;
496
501
  // Antigravity reasoning-replay: record thoughtSignatures from the model parts for the next turn.
497
- if (provider.googleMode === "cloud-code-assist" && parts && antigravityModel && antigravitySession) {
498
- observeAntigravityReplay(antigravityModel, antigravitySession, parts as unknown[]);
502
+ if (provider.googleMode === "cloud-code-assist" && parts && antigravityModel && antigravityReplaySession) {
503
+ observeAntigravityReplay(antigravityModel, antigravityReplaySession, parts as unknown[]);
499
504
  }
500
505
  if (parts) {
501
506
  for (const part of parts) {
@@ -711,8 +716,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
711
716
  const imageBudget = createImageBudget();
712
717
  if (candidates?.[0]?.content?.parts) {
713
718
  // Non-streaming CCA: observe thoughtSignatures for the next turn, same as the stream path.
714
- if (provider.googleMode === "cloud-code-assist" && antigravityModel && antigravitySession) {
715
- observeAntigravityReplay(antigravityModel, antigravitySession, candidates[0].content.parts as unknown[]);
719
+ if (provider.googleMode === "cloud-code-assist" && antigravityModel && antigravityReplaySession) {
720
+ observeAntigravityReplay(antigravityModel, antigravityReplaySession, candidates[0].content.parts as unknown[]);
716
721
  }
717
722
  for (const part of candidates[0].content.parts) {
718
723
  if (part.text) events.push({ type: "text_delta", text: part.text });
@@ -86,17 +86,8 @@ function pushSystemText(parts: string[], content: unknown): void {
86
86
  if (text) parts.push(text);
87
87
  }
88
88
 
89
- function toolCallsToItems(toolCalls: unknown, input: Rec[]): void {
89
+ function toolCallsToItems(toolCalls: unknown, input: Rec[], knownNameByCallId: Map<string, string>): void {
90
90
  if (!Array.isArray(toolCalls)) return;
91
- // Recover names from earlier function_call items in the same transcript when a client
92
- // re-sends tool_calls with only id/arguments (replace-style merge lost function.name).
93
- const knownNameByCallId = new Map<string, string>();
94
- for (const item of input) {
95
- if (!isRec(item) || item.type !== "function_call") continue;
96
- if (typeof item.call_id === "string" && typeof item.name === "string" && item.name.length > 0) {
97
- knownNameByCallId.set(item.call_id, item.name);
98
- }
99
- }
100
91
  for (const raw of toolCalls) {
101
92
  if (!isRec(raw)) continue;
102
93
  const fn = isRec(raw.function) ? raw.function : null;
@@ -220,6 +211,9 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
220
211
 
221
212
  const systemParts: string[] = [];
222
213
  const input: Rec[] = [];
214
+ // Recover replace-style tool calls incrementally instead of rebuilding the
215
+ // call-id index from the entire translated transcript for every message.
216
+ const knownNameByCallId = new Map<string, string>();
223
217
 
224
218
  for (const msg of raw.messages) {
225
219
  if (!isRec(msg)) continue;
@@ -237,7 +231,7 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
237
231
  case "assistant": {
238
232
  const blocks = assistantContentToBlocks(msg.content);
239
233
  if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks });
240
- if (msg.tool_calls !== undefined) toolCallsToItems(msg.tool_calls, input);
234
+ if (msg.tool_calls !== undefined) toolCallsToItems(msg.tool_calls, input, knownNameByCallId);
241
235
  break;
242
236
  }
243
237
  case "tool": {
@@ -113,8 +113,10 @@ export interface FamilyRows {
113
113
  }
114
114
 
115
115
  export interface CodexQuotaDto {
116
+ fiveHourPercent?: number;
116
117
  weeklyPercent?: number;
117
118
  monthlyPercent?: number;
119
+ fiveHourResetAt?: number;
118
120
  weeklyResetAt?: number;
119
121
  monthlyResetAt?: number;
120
122
  }
@@ -123,6 +125,12 @@ export interface ProviderQuotaWindowDto {
123
125
  label: string;
124
126
  percent: number;
125
127
  resetAt?: number;
128
+ valueLabel?: string;
129
+ segments?: Array<{
130
+ label: string;
131
+ percent: number;
132
+ resetAt?: number;
133
+ }>;
126
134
  }
127
135
 
128
136
  export interface ProviderQuotaDto extends CodexQuotaDto {
@@ -154,7 +162,7 @@ interface CodexAccountDto {
154
162
  function projectQuota(quota: CodexQuotaDto | null | undefined): CodexQuotaDto | null {
155
163
  if (!quota) return null;
156
164
  const projected: CodexQuotaDto = {};
157
- for (const key of ["weeklyPercent", "monthlyPercent", "weeklyResetAt", "monthlyResetAt"] as const) {
165
+ for (const key of ["fiveHourPercent", "weeklyPercent", "monthlyPercent", "fiveHourResetAt", "weeklyResetAt", "monthlyResetAt"] as const) {
158
166
  if (typeof quota[key] === "number" && Number.isFinite(quota[key])) projected[key] = quota[key];
159
167
  }
160
168
  return projected;
@@ -70,9 +70,12 @@ function resetIso(value: number | undefined): string | null {
70
70
  function refreshLine(row: FamilyRows["rows"][number]): string {
71
71
  const parts = [row.id === MAIN_ID ? "main" : row.id, row.email, row.plan];
72
72
  const quota = row.quota;
73
- if (!quota || (quota.weeklyPercent === undefined && quota.monthlyPercent === undefined)) {
73
+ if (!quota || (quota.fiveHourPercent === undefined && quota.weeklyPercent === undefined && quota.monthlyPercent === undefined)) {
74
74
  parts.push("quota: unknown");
75
75
  } else {
76
+ if (quota.fiveHourPercent !== undefined) parts.push(`5h ${quota.fiveHourPercent}%`);
77
+ const fiveHourReset = resetIso(quota.fiveHourResetAt);
78
+ if (fiveHourReset) parts.push(`resets ${fiveHourReset}`);
76
79
  if (quota.weeklyPercent !== undefined) parts.push(`weekly ${quota.weeklyPercent}%`);
77
80
  const weeklyReset = resetIso(quota.weeklyResetAt);
78
81
  if (weeklyReset) parts.push(`resets ${weeklyReset}`);
@@ -1,5 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import type { CodexAccount } from "../types";
2
+ import type { CodexAccount, OcxConfig } from "../types";
3
+ import type { CodexAuthContext } from "./auth-context";
4
+ import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
3
5
 
4
6
  export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/;
5
7
 
@@ -22,6 +24,17 @@ export function codexAccountLogLabel(account: CodexAccount): string {
22
24
  : fallbackCodexAccountLogLabel(account.id);
23
25
  }
24
26
 
27
+ /** Effective privacy-safe label for the Codex Pool account selected to serve a request. */
28
+ export function codexAuthContextLogLabel(
29
+ authCtx: CodexAuthContext,
30
+ config: Pick<OcxConfig, "codexAccounts">,
31
+ ): "main" | `p${string}` | undefined {
32
+ if (authCtx.kind !== "pool" && authCtx.kind !== "main-pool") return undefined;
33
+ if (authCtx.accountId === MAIN_CODEX_ACCOUNT_ID) return "main";
34
+ const account = (config.codexAccounts ?? []).find(candidate => candidate.id === authCtx.accountId);
35
+ return account ? codexAccountLogLabel(account) as `p${string}` : undefined;
36
+ }
37
+
25
38
  export function withCodexAccountLogLabel(
26
39
  account: Omit<CodexAccount, "logLabel"> & Partial<Pick<CodexAccount, "logLabel">>,
27
40
  existingAccounts: readonly CodexAccount[],
@@ -7,6 +7,8 @@ import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } f
7
7
  import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
8
8
  import { clearMainAccountInfoCache } from "./main-account-cache";
9
9
  import { forgetCodexAccountPause } from "./account-pause";
10
+ import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority";
11
+ import { codexAccountNamespaceEntries, codexAccountPickerEnabled } from "./account-namespaces";
10
12
  import type { OcxConfig } from "../types";
11
13
 
12
14
  let observedMainChatgptAccountId: string | undefined;
@@ -44,12 +46,21 @@ export function resetMainCodexAccountIdentityTrackingForTests(): void {
44
46
  observedMainChatgptAccountId = undefined;
45
47
  }
46
48
 
47
- export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void {
49
+ /** Delete credentials and runtime state while retaining the account's stable selector binding. */
50
+ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): boolean {
51
+ const hadStoredAccount = (runtimeConfig.codexAccounts ?? [])
52
+ .some(account => !account.isMain && account.id === accountId);
53
+ const hadVisiblePickerBinding = hadStoredAccount
54
+ && codexAccountPickerEnabled(runtimeConfig)
55
+ && codexAccountNamespaceEntries(runtimeConfig).some(([, target]) => target === accountId);
48
56
  removeCodexAccountCredential(accountId);
49
57
  runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? [])
50
58
  .filter(account => account.isMain || account.id !== accountId);
51
59
  forgetCodexAccountPause(runtimeConfig, accountId);
60
+ forgetCodexAccountPriority(runtimeConfig, accountId);
61
+ clearCodexAccountPin(runtimeConfig, accountId);
52
62
  if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined;
53
63
  purgeCodexAccountRuntimeState(accountId);
54
64
  invalidateCodexWebSocketsForAccount(accountId);
65
+ return hadVisiblePickerBinding;
55
66
  }
@@ -99,6 +99,19 @@ export function defaultCodexAccountNamespaces(
99
99
  return namespaces;
100
100
  }
101
101
 
102
+ /** Initialize generated selectors only for an explicit opt-in with no existing bindings. */
103
+ export function initializeDefaultCodexAccountNamespaces(
104
+ config: Pick<
105
+ OcxConfig,
106
+ "codexAccountPickerEnabled" | "codexAccountNamespaces" | "codexAccounts" | "combos" | "providers"
107
+ >,
108
+ ): boolean {
109
+ if (config.codexAccountPickerEnabled !== true
110
+ || Object.keys(config.codexAccountNamespaces ?? {}).length > 0) return false;
111
+ config.codexAccountNamespaces = defaultCodexAccountNamespaces(config);
112
+ return true;
113
+ }
114
+
102
115
  /**
103
116
  * Add one account to a generated map without renaming or replacing explicit existing entries.
104
117
  * The account-creation layer must reject a new id that already equals an existing selector key.
@@ -147,3 +160,11 @@ export function codexAccountNamespaceEntries(
147
160
  return Object.entries(config.codexAccountNamespaces ?? {})
148
161
  .map(([namespace, accountId]) => [namespace, normalizeCodexAccountNamespaceTarget(accountId)]);
149
162
  }
163
+
164
+ /** Existing hand-written maps remain enabled unless explicitly hidden. */
165
+ export function codexAccountPickerEnabled(
166
+ config: Pick<OcxConfig, "codexAccountNamespaces" | "codexAccountPickerEnabled">,
167
+ ): boolean {
168
+ return (config.codexAccountPickerEnabled === undefined || config.codexAccountPickerEnabled === true)
169
+ && Object.keys(config.codexAccountNamespaces ?? {}).length > 0;
170
+ }
@@ -0,0 +1,49 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
3
+ import { DEFAULT_ACCOUNT_PRIORITY, normalizeAccountPriority } from "./pool-rotation";
4
+
5
+ /** Account ids that may carry persisted selection order. */
6
+ export function isCodexAccountPriorityKey(key: unknown): key is string {
7
+ return key === MAIN_CODEX_ACCOUNT_ID || isValidCodexAccountId(key);
8
+ }
9
+
10
+ export function getCodexAccountPriority(config: OcxConfig, accountId: string): number {
11
+ return codexAccountPriorityLookup(config)(accountId);
12
+ }
13
+
14
+ /** Store default priority as absence so unconfigured pools retain their old behavior. */
15
+ export function setCodexAccountPriority(config: OcxConfig, accountId: string, priority: number): void {
16
+ const entries = new Map(Object.entries(config.codexAccountPriorities ?? {}));
17
+ if (priority === DEFAULT_ACCOUNT_PRIORITY) entries.delete(accountId);
18
+ else entries.set(accountId, priority);
19
+ if (entries.size > 0) config.codexAccountPriorities = Object.fromEntries(entries);
20
+ else delete config.codexAccountPriorities;
21
+ }
22
+
23
+ export function forgetCodexAccountPriority(config: OcxConfig, accountId: string): void {
24
+ setCodexAccountPriority(config, accountId, DEFAULT_ACCOUNT_PRIORITY);
25
+ }
26
+
27
+ /** Stable lookup for one routing pass. */
28
+ export function codexAccountPriorityLookup(config: OcxConfig): (accountId: string) => number {
29
+ const priorities = config.codexAccountPriorities;
30
+ if (!priorities) return () => DEFAULT_ACCOUNT_PRIORITY;
31
+ return accountId => Object.hasOwn(priorities, accountId)
32
+ ? normalizeAccountPriority(priorities[accountId])
33
+ : DEFAULT_ACCOUNT_PRIORITY;
34
+ }
35
+
36
+ export function pinnedCodexAccountId(config: OcxConfig): string | undefined {
37
+ return config.activeCodexAccountPinned;
38
+ }
39
+
40
+ export function setCodexAccountPin(config: OcxConfig, accountId: string): void {
41
+ config.activeCodexAccountPinned = accountId;
42
+ }
43
+
44
+ /** Release the pin, optionally only when it belongs to one account. */
45
+ export function clearCodexAccountPin(config: OcxConfig, accountId?: string): void {
46
+ if (accountId === undefined || config.activeCodexAccountPinned === accountId) {
47
+ delete config.activeCodexAccountPinned;
48
+ }
49
+ }
@@ -179,6 +179,7 @@ export function markCodexAccountQuotaValidationPending(
179
179
  id: string,
180
180
  reason: string,
181
181
  retryAtMs: number | undefined,
182
+ pauseOwned = true,
182
183
  ): void {
183
184
  withCredentialMutationLockSync(() => {
184
185
  const store = loadCodexAccountRecordStore();
@@ -189,7 +190,7 @@ export function markCodexAccountQuotaValidationPending(
189
190
  lastCodexValidationStatus: "quota_pending",
190
191
  lastCodexValidationError: reason,
191
192
  ...(retryAtMs !== undefined ? { codexQuotaRetryAt: retryAtMs } : {}),
192
- codexQuotaPauseOwned: true,
193
+ codexQuotaPauseOwned: pauseOwned ? true : undefined,
193
194
  };
194
195
  persist(store);
195
196
  });