@bitkyc08/opencodex 2.6.26-preview.20260705 → 2.6.28-preview.20260707

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 (118) hide show
  1. package/README.md +1 -0
  2. package/bin/ocx.mjs +4 -4
  3. package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
  4. package/gui/dist/assets/index-CkV5xFA8.js +15 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -4
  7. package/src/adapters/anthropic-image-guard.ts +195 -0
  8. package/src/adapters/anthropic.ts +85 -14
  9. package/src/adapters/cursor/cursor-errors.ts +2 -2
  10. package/src/adapters/cursor/live-transport.ts +1 -1
  11. package/src/adapters/cursor/transport-retry.ts +2 -2
  12. package/src/adapters/google-errors.ts +1 -1
  13. package/src/adapters/google-http.ts +1 -1
  14. package/src/adapters/google-truncation.ts +1 -1
  15. package/src/adapters/google.ts +1 -1
  16. package/src/adapters/kiro-errors.ts +1 -1
  17. package/src/adapters/kiro-retry.ts +1 -1
  18. package/src/adapters/kiro-truncation.ts +1 -1
  19. package/src/adapters/kiro.ts +1 -1
  20. package/src/adapters/openai-chat.ts +12 -2
  21. package/src/adapters/openai-responses.ts +126 -3
  22. package/src/bridge.ts +164 -7
  23. package/src/{doctor.ts → cli/doctor.ts} +21 -4
  24. package/src/{cli-help.ts → cli/help.ts} +1 -1
  25. package/src/cli/index.ts +584 -0
  26. package/src/{init.ts → cli/init.ts} +6 -6
  27. package/src/{cli-models.ts → cli/models.ts} +2 -2
  28. package/src/{cli-provider.ts → cli/provider.ts} +12 -8
  29. package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
  30. package/src/{cli-status.ts → cli/status.ts} +7 -7
  31. package/src/cli.ts +9 -575
  32. package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
  33. package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
  34. package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
  35. package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
  36. package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
  37. package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
  38. package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
  39. package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
  40. package/src/codex/history-migration-guardian.ts +102 -0
  41. package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
  42. package/src/{codex-home.ts → codex/home.ts} +1 -1
  43. package/src/{codex-inject.ts → codex/inject.ts} +204 -26
  44. package/src/{codex-journal.ts → codex/journal.ts} +2 -2
  45. package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
  46. package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
  47. package/src/{codex-paths.ts → codex/paths.ts} +2 -2
  48. package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
  49. package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
  50. package/src/{codex-routing.ts → codex/routing.ts} +8 -8
  51. package/src/{codex-shim.ts → codex/shim.ts} +6 -5
  52. package/src/{codex-sync.ts → codex/sync.ts} +4 -4
  53. package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
  54. package/src/config.ts +2 -0
  55. package/src/generated/jawcode-model-metadata.ts +2 -0
  56. package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
  57. package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
  58. package/src/{process-control.ts → lib/process-control.ts} +1 -1
  59. package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
  60. package/src/oauth/callback-server.ts +1 -1
  61. package/src/oauth/google-antigravity.ts +7 -4
  62. package/src/oauth/index.ts +67 -16
  63. package/src/oauth/login-cli.ts +2 -2
  64. package/src/oauth/store.ts +236 -20
  65. package/src/oauth/token-guardian.ts +24 -20
  66. package/src/oauth/types.ts +16 -0
  67. package/src/providers/api-keys.ts +121 -0
  68. package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
  69. package/src/providers/derive.ts +2 -0
  70. package/src/providers/key-failover.ts +145 -0
  71. package/src/{provider-label.ts → providers/label.ts} +1 -1
  72. package/src/{provider-quota.ts → providers/quota.ts} +12 -7
  73. package/src/providers/registry.ts +66 -4
  74. package/src/responses/compaction.ts +117 -0
  75. package/src/responses/parser.ts +89 -13
  76. package/src/responses/reasoning-envelope.ts +52 -0
  77. package/src/responses/schema.ts +15 -3
  78. package/src/responses/state.ts +117 -2
  79. package/src/router.ts +2 -0
  80. package/src/server/auth-cors.ts +231 -0
  81. package/src/server/index.ts +523 -0
  82. package/src/server/lifecycle.ts +73 -0
  83. package/src/server/management-api.ts +628 -0
  84. package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
  85. package/src/server/relay.ts +534 -0
  86. package/src/server/request-decompress.ts +46 -0
  87. package/src/server/request-log.ts +310 -0
  88. package/src/server/responses.ts +775 -0
  89. package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
  90. package/src/service.ts +19 -11
  91. package/src/types.ts +27 -0
  92. package/src/{update.ts → update/index.ts} +5 -5
  93. package/src/{update-job.ts → update/job.ts} +7 -6
  94. package/src/{update-notify.ts → update/notify.ts} +3 -3
  95. package/src/{usage-debug.ts → usage/debug.ts} +3 -3
  96. package/src/{usage-log.ts → usage/log.ts} +3 -3
  97. package/src/{usage-summary.ts → usage/summary.ts} +3 -3
  98. package/src/{usage-totals.ts → usage/totals.ts} +1 -1
  99. package/src/vision/describe.ts +3 -3
  100. package/src/vision/index.ts +21 -1
  101. package/src/web-search/executor.ts +4 -4
  102. package/src/web-search/index.ts +1 -1
  103. package/src/web-search/loop.ts +84 -24
  104. package/gui/dist/assets/index-BcHhxo1I.css +0 -1
  105. package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
  106. package/src/server.ts +0 -2501
  107. /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
  108. /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
  109. /package/src/{abort.ts → lib/abort.ts} +0 -0
  110. /package/src/{debug.ts → lib/debug.ts} +0 -0
  111. /package/src/{errors.ts → lib/errors.ts} +0 -0
  112. /package/src/{open-url.ts → lib/open-url.ts} +0 -0
  113. /package/src/{privacy.ts → lib/privacy.ts} +0 -0
  114. /package/src/{redact.ts → lib/redact.ts} +0 -0
  115. /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
  116. /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
  117. /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
  118. /package/src/{ports.ts → server/ports.ts} +0 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Multi-key pool for key-auth providers (the API-key twin of OAuth multiauth).
3
+ *
4
+ * `provider.apiKey` stays the single source of truth for routing — it always mirrors the
5
+ * ACTIVE pool entry, so the router/adapters never learn about the pool. The pool itself
6
+ * lives in `provider.apiKeyPool` in config.json (same file that already holds apiKey).
7
+ * A provider with a legacy bare `apiKey` is seeded into a one-entry pool on first touch.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { saveConfig } from "../config";
11
+ import type { OcxConfig, OcxProviderConfig } from "../types";
12
+
13
+ export interface ProviderApiKeyInfo {
14
+ id: string;
15
+ label?: string;
16
+ /** First/last 4 chars only; env references (`${VAR}`) are shown verbatim (not secrets). */
17
+ masked: string;
18
+ active: boolean;
19
+ addedAt?: number;
20
+ }
21
+
22
+ function isEnvReference(value: string): boolean {
23
+ return /^\$\{?\w+\}?$/.test(value);
24
+ }
25
+
26
+ export function maskApiKey(value: string): string {
27
+ if (isEnvReference(value)) return value;
28
+ if (value.length <= 8) return "****";
29
+ return `${value.slice(0, 4)}****${value.slice(-4)}`;
30
+ }
31
+
32
+ /** Content-derived id: re-adding the same key upserts instead of duplicating. */
33
+ function keyId(key: string): string {
34
+ return createHash("sha256").update(key).digest("hex").slice(0, 8);
35
+ }
36
+
37
+ /** True for providers whose upstream auth is a configured API key (not oauth/forward). */
38
+ export function isKeyAuthProvider(provider: OcxProviderConfig): boolean {
39
+ return provider.authMode !== "oauth" && provider.authMode !== "forward";
40
+ }
41
+
42
+ /** Seed the pool from a legacy bare `apiKey`, and keep `apiKey` mirrored to the active entry. */
43
+ function ensurePool(provider: OcxProviderConfig): NonNullable<OcxProviderConfig["apiKeyPool"]> {
44
+ if (!provider.apiKeyPool) provider.apiKeyPool = [];
45
+ if (provider.apiKeyPool.length === 0 && provider.apiKey) {
46
+ provider.apiKeyPool.push({ id: keyId(provider.apiKey), key: provider.apiKey });
47
+ }
48
+ return provider.apiKeyPool;
49
+ }
50
+
51
+ function activeEntryId(provider: OcxProviderConfig): string | null {
52
+ const pool = provider.apiKeyPool ?? [];
53
+ if (pool.length === 0) return null;
54
+ return (pool.find(e => e.key === provider.apiKey) ?? pool[0]!).id;
55
+ }
56
+
57
+ export function listProviderApiKeys(config: OcxConfig, name: string): { activeId: string | null; keys: ProviderApiKeyInfo[] } {
58
+ const provider = config.providers[name];
59
+ if (!provider || !isKeyAuthProvider(provider)) return { activeId: null, keys: [] };
60
+ const pool = ensurePool(provider);
61
+ const activeId = activeEntryId(provider);
62
+ return {
63
+ activeId,
64
+ keys: pool.map(entry => ({
65
+ id: entry.id,
66
+ ...(entry.label ? { label: entry.label } : {}),
67
+ masked: maskApiKey(entry.key),
68
+ active: entry.id === activeId,
69
+ ...(entry.addedAt !== undefined ? { addedAt: entry.addedAt } : {}),
70
+ })),
71
+ };
72
+ }
73
+
74
+ /** Add (or upsert) a key and make it ACTIVE. Persists config. */
75
+ export function addProviderApiKey(config: OcxConfig, name: string, key: string, label?: string): { id: string } | { error: string } {
76
+ const provider = config.providers[name];
77
+ if (!provider || !isKeyAuthProvider(provider)) return { error: "provider does not use API-key auth" };
78
+ const trimmed = key.trim();
79
+ if (!trimmed) return { error: "key is required" };
80
+ if (/[\r\n]/.test(trimmed)) return { error: "key must not include line breaks" };
81
+ const pool = ensurePool(provider);
82
+ const id = keyId(trimmed);
83
+ const existing = pool.find(e => e.id === id);
84
+ if (existing) {
85
+ if (label?.trim()) existing.label = label.trim();
86
+ } else {
87
+ pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
88
+ }
89
+ provider.apiKey = trimmed;
90
+ saveConfig(config);
91
+ return { id };
92
+ }
93
+
94
+ /** Switch the ACTIVE key (mirrors into `provider.apiKey`). Persists config. */
95
+ export function setActiveProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
96
+ const provider = config.providers[name];
97
+ if (!provider || !isKeyAuthProvider(provider)) return false;
98
+ const entry = ensurePool(provider).find(e => e.id === id);
99
+ if (!entry) return false;
100
+ provider.apiKey = entry.key;
101
+ saveConfig(config);
102
+ return true;
103
+ }
104
+
105
+ /** Remove one key; removing the active one promotes the first remaining. Persists config. */
106
+ export function removeProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
107
+ const provider = config.providers[name];
108
+ if (!provider || !isKeyAuthProvider(provider)) return false;
109
+ const pool = ensurePool(provider);
110
+ const entry = pool.find(e => e.id === id);
111
+ if (!entry) return false;
112
+ provider.apiKeyPool = pool.filter(e => e.id !== id);
113
+ if (provider.apiKey === entry.key) {
114
+ const next = provider.apiKeyPool[0];
115
+ if (next) provider.apiKey = next.key;
116
+ else delete provider.apiKey;
117
+ }
118
+ if (provider.apiKeyPool.length === 0) delete provider.apiKeyPool;
119
+ saveConfig(config);
120
+ return true;
121
+ }
@@ -1,4 +1,4 @@
1
- import type { OcxConfig } from "./types";
1
+ import type { OcxConfig } from "../types";
2
2
 
3
3
  export const DEFAULT_PROVIDER_CONTEXT_CAP = 350_000;
4
4
 
@@ -82,6 +82,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
82
82
  ...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}),
83
83
  ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
84
84
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
85
+ ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
85
86
  ...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
86
87
  ...(entry.googleMode ? { googleMode: entry.googleMode } : {}),
87
88
  ...(entry.project ? { project: entry.project } : {}),
@@ -178,6 +179,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
178
179
  if (!prov.noPenaltyModels && seed.noPenaltyModels) prov.noPenaltyModels = [...seed.noPenaltyModels];
179
180
  if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels];
180
181
  if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels];
182
+ if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels];
181
183
  if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames;
182
184
  }
183
185
 
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Multi-key 429 failover for non-OpenAI providers.
3
+ *
4
+ * When a provider's upstream returns 429, this module picks the next available key
5
+ * from `apiKeyPool`, puts the exhausted key into cooldown (respecting Retry-After),
6
+ * and returns a fresh provider config with the swapped key. If all keys are in
7
+ * cooldown, returns null so the caller surfaces the 429 to the client.
8
+ *
9
+ * Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools.
10
+ */
11
+ import { saveConfig } from "../config";
12
+ import type { OcxConfig, OcxProviderConfig } from "../types";
13
+
14
+ // ---- cooldown state (in-memory, same as codex/routing.ts) ----
15
+
16
+ interface KeyCooldown {
17
+ cooldownUntil: number;
18
+ }
19
+
20
+ const DEFAULT_COOLDOWN_MS = 60_000;
21
+ const MAX_COOLDOWN_MS = 10 * 60_000; // cap at 10 min for api-key rotation
22
+
23
+ /** Map<`${providerName}\0${keyId}`, KeyCooldown> */
24
+ const keyCooldowns = new Map<string, KeyCooldown>();
25
+
26
+ function cooldownKey(providerName: string, keyId: string): string {
27
+ return `${providerName}\0${keyId}`;
28
+ }
29
+
30
+ function parseRetryAfterMs(value: string | null | undefined, now = Date.now()): number | undefined {
31
+ const text = value?.trim();
32
+ if (!text) return undefined;
33
+ if (/^\d+(?:\.\d+)?$/.test(text)) {
34
+ const seconds = Number(text);
35
+ if (Number.isFinite(seconds) && seconds > 0) {
36
+ return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS);
37
+ }
38
+ }
39
+ const timestamp = Date.parse(text);
40
+ if (!Number.isFinite(timestamp)) return undefined;
41
+ const delay = timestamp - now;
42
+ return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined;
43
+ }
44
+
45
+ function isKeyInCooldown(providerName: string, keyId: string, now = Date.now()): boolean {
46
+ const entry = keyCooldowns.get(cooldownKey(providerName, keyId));
47
+ if (!entry) return false;
48
+ if (entry.cooldownUntil <= now) {
49
+ keyCooldowns.delete(cooldownKey(providerName, keyId));
50
+ return false;
51
+ }
52
+ return true;
53
+ }
54
+
55
+ // ---- public API ----
56
+
57
+ /**
58
+ * Check whether a provider has multiple keys available for failover.
59
+ * Returns true only for key-auth providers with 2+ pool entries.
60
+ */
61
+ export function hasKeyPoolFailover(provider: OcxProviderConfig): boolean {
62
+ if (provider.authMode === "oauth" || provider.authMode === "forward") return false;
63
+ return (provider.apiKeyPool?.length ?? 0) >= 2;
64
+ }
65
+
66
+ /**
67
+ * Record a 429 for the current key and attempt to switch to the next available one.
68
+ *
69
+ * @returns A new OcxProviderConfig with the swapped key (and mutated config on disk),
70
+ * or `null` when no alternative key is available (all in cooldown or pool < 2).
71
+ */
72
+ export function rotateKeyOn429(
73
+ config: OcxConfig,
74
+ providerName: string,
75
+ retryAfterHeader: string | null | undefined,
76
+ now = Date.now(),
77
+ attemptedKey?: string,
78
+ ): OcxProviderConfig | null {
79
+ const provider = config.providers[providerName];
80
+ if (!provider) return null;
81
+ if (provider.authMode === "oauth" || provider.authMode === "forward") return null;
82
+
83
+ const pool = provider.apiKeyPool;
84
+ if (!pool || pool.length < 2) return null;
85
+
86
+ // Cool the key that ACTUALLY failed. Under concurrent 429s another request may already have
87
+ // rotated provider.apiKey — cooling the live key would punish an innocent replacement and can
88
+ // exhaust a 2-key pool from a single bad key. CAS semantics: callers pass the key they used.
89
+ const failedKey = attemptedKey ?? provider.apiKey;
90
+ const currentEntry = pool.find(e => e.key === failedKey);
91
+ if (currentEntry) {
92
+ const cooldownMs = parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS;
93
+ keyCooldowns.set(cooldownKey(providerName, currentEntry.id), {
94
+ cooldownUntil: now + cooldownMs,
95
+ });
96
+ }
97
+
98
+ // Lost the race: someone already rotated away from the failed key. If the live key is healthy,
99
+ // retry with it as-is instead of rotating a second time.
100
+ if (attemptedKey !== undefined && provider.apiKey !== attemptedKey) {
101
+ const liveEntry = pool.find(e => e.key === provider.apiKey);
102
+ if (liveEntry && !isKeyInCooldown(providerName, liveEntry.id, now)) {
103
+ return { ...provider };
104
+ }
105
+ }
106
+
107
+ // Pick the next key that is NOT in cooldown
108
+ const currentIndex = currentEntry ? pool.indexOf(currentEntry) : -1;
109
+ for (let i = 1; i < pool.length; i++) {
110
+ const candidate = pool[(currentIndex + i) % pool.length]!;
111
+ if (!isKeyInCooldown(providerName, candidate.id, now)) {
112
+ // Swap active key
113
+ provider.apiKey = candidate.key;
114
+ saveConfig(config);
115
+ console.warn(
116
+ // Log ids only — labels are user-supplied free text and could carry secret material.
117
+ `[key-failover] ${providerName}: 429 on key ${currentEntry?.id ?? "?"}; rotating to key ${candidate.id}`,
118
+ );
119
+ return { ...provider };
120
+ }
121
+ }
122
+
123
+ // All keys in cooldown
124
+ console.warn(`[key-failover] ${providerName}: all ${pool.length} keys in cooldown; returning 429 to client`);
125
+ return null;
126
+ }
127
+
128
+ /** Clear cooldown state for a provider (e.g. after manual key management). */
129
+ export function clearKeyCooldowns(providerName?: string): void {
130
+ if (!providerName) {
131
+ keyCooldowns.clear();
132
+ return;
133
+ }
134
+ const prefix = `${providerName}\0`;
135
+ for (const key of keyCooldowns.keys()) {
136
+ if (key.startsWith(prefix)) keyCooldowns.delete(key);
137
+ }
138
+ }
139
+
140
+ /** Visible-for-testing: get the cooldown-until timestamp for a key. */
141
+ export function getKeyCooldownUntil(providerName: string, keyId: string, now = Date.now()): number | null {
142
+ const entry = keyCooldowns.get(cooldownKey(providerName, keyId));
143
+ if (!entry) return null;
144
+ return entry.cooldownUntil > now ? entry.cooldownUntil : null;
145
+ }
@@ -1,4 +1,4 @@
1
- import { CODEX_ACCOUNT_LOG_LABEL_RE } from "./codex-account-label";
1
+ import { CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
2
2
 
3
3
  function canonicalUsageProviderLabel(provider: string): string {
4
4
  return provider === "chatgpt" ? "openai" : provider;
@@ -1,10 +1,10 @@
1
- import { listCodexAuthAccounts } from "./codex-auth-api";
2
- import { MAIN_CODEX_ACCOUNT_ID } from "./codex-main-account";
3
- import { getValidAccessToken } from "./oauth";
4
- import { getCredential } from "./oauth/store";
5
- import { antigravityUserAgent } from "./adapters/client-fingerprint";
6
- import { getProviderRegistryEntry } from "./providers/registry";
7
- import type { OcxConfig, OcxProviderConfig } from "./types";
1
+ import { listCodexAuthAccounts } from "../codex/auth-api";
2
+ import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
3
+ import { getValidAccessToken } from "../oauth";
4
+ import { getCredential } from "../oauth/store";
5
+ import { antigravityUserAgent } from "../adapters/client-fingerprint";
6
+ import { getProviderRegistryEntry } from "./registry";
7
+ import type { OcxConfig, OcxProviderConfig } from "../types";
8
8
 
9
9
  const CACHE_TTL_MS = 5 * 60_000;
10
10
  const REQUEST_TIMEOUT_MS = 8_000;
@@ -43,6 +43,11 @@ export interface ProviderQuotaResponse {
43
43
 
44
44
  let cache: { key: string; ts: number; response: ProviderQuotaResponse } | null = null;
45
45
 
46
+ /** Invalidate the report cache (e.g. after switching a provider's active account). */
47
+ export function clearProviderQuotaCache(): void {
48
+ cache = null;
49
+ }
50
+
46
51
  function cacheKey(config: OcxConfig): string {
47
52
  const providers = Object.entries(config.providers)
48
53
  .map(([name, provider]) => `${name}:${provider.authMode ?? "key"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}`)
@@ -39,6 +39,7 @@ export interface ProviderRegistryEntry {
39
39
  noPenaltyModels?: string[];
40
40
  autoToolChoiceOnlyModels?: string[];
41
41
  preserveReasoningContentModels?: string[];
42
+ thinkingToggleModels?: string[];
42
43
  escapeBuiltinToolNames?: boolean;
43
44
  oauthId?: string;
44
45
  jawcodeBundle?: string;
@@ -55,13 +56,18 @@ export type ProviderConfigSeed = Pick<
55
56
  | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities"
56
57
  | "reasoningEfforts" | "modelReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
57
58
  | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
58
- | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "escapeBuiltinToolNames"
59
+ | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "thinkingToggleModels" | "escapeBuiltinToolNames"
59
60
  | "googleMode" | "project" | "location"
60
61
  >;
61
62
 
62
63
 
63
64
  const OLLAMA_REASONING_MAP: Record<string, string> = { xhigh: "max" };
64
65
 
66
+ // Shared between the OAuth (Claude account) and API-key Anthropic entries so both expose the
67
+ // same static model seed.
68
+ const ANTHROPIC_MODELS = ["claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
69
+ const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record<string, number> = { "claude-sonnet-5": 1_000_000 };
70
+
65
71
  const ZAI_GLM_52_MODELS = ["glm-5.2", "glm-5.2[1m]"];
66
72
  const ZAI_GLM_52_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"];
67
73
  const ZAI_GLM_52_REASONING_MAP: Record<string, string> = {
@@ -73,6 +79,26 @@ const ZAI_GLM_52_REASONING_MAP: Record<string, string> = {
73
79
  xhigh: "max",
74
80
  max: "max",
75
81
  };
82
+
83
+ /**
84
+ * Vendor thinking-toggle models (MiMo v2.x, GLM 5/5.1 on Zen Go): the wire knob is
85
+ * `thinking: {type: enabled|disabled}` — a binary. Advertise a two-step Codex ladder
86
+ * (low = thinking off, high = thinking on) and map efforts onto the toggle. Zen Go
87
+ * pass-through probed live 2026-07-07 (glm-5.2 toggle verified; mimo/minimax accept shape).
88
+ */
89
+ const THINKING_TOGGLE_EFFORTS = ["low", "high"];
90
+ const THINKING_TOGGLE_MAP: Record<string, string> = {
91
+ none: "disabled",
92
+ minimal: "disabled",
93
+ low: "disabled",
94
+ medium: "enabled",
95
+ high: "enabled",
96
+ xhigh: "enabled",
97
+ max: "enabled",
98
+ };
99
+ const OPENCODE_GO_THINKING_TOGGLE_MODELS = [
100
+ "mimo-v2.5", "mimo-v2.5-pro", "mimo-v2-omni", "mimo-v2-pro", "glm-5", "glm-5.1",
101
+ ];
76
102
  const DEEPSEEK_THINKING_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"];
77
103
  const DEEPSEEK_THINKING_EFFORTS = ["high", "xhigh"];
78
104
  const DEEPSEEK_THINKING_REASONING_MAP: Record<string, string> = {
@@ -148,6 +174,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
148
174
  modelContextWindows: cursorModelContextWindows(CURSOR_STATIC_MODELS),
149
175
  modelInputModalities: cursorModelInputModalities(CURSOR_STATIC_MODELS),
150
176
  modelReasoningEfforts: cursorModelReasoningEfforts(CURSOR_STATIC_MODELS),
177
+ // Cursor's wire protocol never forwards image parts (request-builder emits an unsupported-
178
+ // content marker), so the vision sidecar covers ALL cursor models regardless of what the
179
+ // upstream model could natively do. Live-discovered models outside the static list fall back
180
+ // to the same marker until they appear here.
181
+ noVisionModels: cursorModelIds(CURSOR_STATIC_MODELS),
151
182
  },
152
183
  {
153
184
  id: "xai",
@@ -174,8 +205,24 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
174
205
  oauthId: "anthropic",
175
206
  jawcodeBundle: "anthropic",
176
207
  note: "Log in with your Claude account",
177
- models: ["claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
178
- modelContextWindows: { "claude-sonnet-5": 1_000_000 },
208
+ models: [...ANTHROPIC_MODELS],
209
+ modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS },
210
+ defaultModel: "claude-sonnet-4-6",
211
+ },
212
+ {
213
+ id: "anthropic-apikey",
214
+ label: "Anthropic (API key)",
215
+ adapter: "anthropic",
216
+ baseUrl: "https://api.anthropic.com",
217
+ authKind: "key",
218
+ featured: true,
219
+ dashboardUrl: "https://console.anthropic.com/settings/keys",
220
+ jawcodeBundle: "anthropic",
221
+ extraMetadataAliases: ["anthropic-key"],
222
+ note: "Direct Anthropic API billing — no Claude subscription",
223
+ models: [...ANTHROPIC_MODELS],
224
+ liveModels: true,
225
+ modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS },
179
226
  defaultModel: "claude-sonnet-4-6",
180
227
  },
181
228
  {
@@ -251,9 +298,24 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
251
298
  "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
252
299
  "kimi-k2.7-code": [],
253
300
  "kimi-k2.7-code-highspeed": [],
301
+ ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS])),
254
302
  },
255
- modelReasoningEffortMap: { "glm-5.2": ZAI_GLM_52_REASONING_MAP },
303
+ modelReasoningEffortMap: {
304
+ "glm-5.2": ZAI_GLM_52_REASONING_MAP,
305
+ ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])),
306
+ },
307
+ thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS,
256
308
  noReasoningModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
309
+ // Text-only Zen Go models (jawcode metadata) — the vision sidecar describes images for
310
+ // every model listed here (and the catalog advertises image input on their behalf).
311
+ // Kimi K2.7 Code accepts text+image+video: do NOT list it here.
312
+ noVisionModels: [
313
+ "glm-5.2", "glm-5", "glm-5.1",
314
+ "deepseek-v4-flash", "deepseek-v4-pro",
315
+ "mimo-v2-pro", "mimo-v2.5-pro",
316
+ "minimax-m2.5", "minimax-m2.7",
317
+ "qwen3.7-max",
318
+ ],
257
319
  noTemperatureModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
258
320
  noTopPModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
259
321
  noPenaltyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Remote compaction v2 support for ROUTED providers.
3
+ *
4
+ * Codex decides "this provider supports remote compaction" by provider name (built-in `OpenAI`),
5
+ * and Design B points that provider at this proxy — so Codex sends remote compaction v2 requests
6
+ * for EVERY routed model. The request is a normal /responses call whose input ends with
7
+ * `{"type":"compaction_trigger"}`; codex-rs `collect_compaction_output` then requires the stream
8
+ * to carry EXACTLY ONE `{"type":"compaction","encrypted_content":...}` output item
9
+ * (compact_remote_v2.rs) or it fatals with "expected exactly one compaction output item".
10
+ *
11
+ * Routed models cannot produce OpenAI's encrypted blob, so the proxy runs the model as a plain
12
+ * summarizer and wraps the summary text in a transparent envelope: `ocx1:` + base64(utf8 summary).
13
+ * Codex stores the item and replays it in later input; the parser decodes our envelope back into
14
+ * plain text for routed models. Real OpenAI-encrypted blobs (no `ocx1:` prefix) are opaque —
15
+ * routed models get a short "history was compacted" note instead.
16
+ */
17
+
18
+ export const OCX_COMPACTION_PREFIX = "ocx1:";
19
+
20
+ /** Mirrors codex-rs core/templates/compact/prompt.md (the local-compaction instruction). */
21
+ export const COMPACT_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
22
+
23
+ Include:
24
+ - Current progress and key decisions made
25
+ - Important context, constraints, or user preferences
26
+ - What remains to be done (clear next steps)
27
+ - Any critical data, examples, or references needed to continue
28
+
29
+ Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;
30
+
31
+ /** Mirrors codex-rs core/templates/compact/summary_prefix.md (framing for a replayed summary). */
32
+ export const SUMMARY_PREFIX = "Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:";
33
+
34
+ export const OPAQUE_COMPACTION_NOTE = "[earlier conversation was compacted; the summary is stored in a format this model cannot read]";
35
+
36
+ export function encodeCompactionSummary(summary: string): string {
37
+ return OCX_COMPACTION_PREFIX + Buffer.from(summary, "utf-8").toString("base64");
38
+ }
39
+
40
+ /** Decode an `ocx1:` envelope; returns null for real (OpenAI-encrypted) blobs or garbage. */
41
+ export function decodeCompactionSummary(encryptedContent: string): string | null {
42
+ if (!encryptedContent.startsWith(OCX_COMPACTION_PREFIX)) return null;
43
+ try {
44
+ return Buffer.from(encryptedContent.slice(OCX_COMPACTION_PREFIX.length), "base64").toString("utf-8");
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Render a replayed compaction item as plain user-visible text for a routed model. */
51
+ export function compactionItemToText(encryptedContent: string | undefined): string {
52
+ const decoded = typeof encryptedContent === "string" ? decodeCompactionSummary(encryptedContent) : null;
53
+ return decoded ? `${SUMMARY_PREFIX}\n\n${decoded}` : OPAQUE_COMPACTION_NOTE;
54
+ }
55
+
56
+ /**
57
+ * Remote compaction v1 (`POST /responses/compact`, unary) — codex-rs installs the returned
58
+ * `{"output":[ResponseItem...]}` as the REPLACEMENT history (compact_remote.rs
59
+ * process_compacted_history). Mirror codex-rs local `build_compacted_history`: recent real user
60
+ * messages within a token budget, then one user message `SUMMARY_PREFIX\n<summary>`. Plain user
61
+ * message items parse as real user messages on the codex side (event_mapping parse_user_message);
62
+ * contextual wrappers are filtered there, and v2-style `compaction` items are NOT expected here.
63
+ */
64
+
65
+ /** codex-rs compact.rs COMPACT_USER_MESSAGE_MAX_TOKENS = 20k tokens (~4 chars/token). */
66
+ const COMPACT_V1_RETAINED_CHAR_BUDGET = 20_000 * 4;
67
+
68
+ /** Extract plain-text user messages from a Responses `input` array (for v1 compact retention). */
69
+ export function extractCompactUserMessages(input: unknown): string[] {
70
+ if (!Array.isArray(input)) return [];
71
+ const out: string[] = [];
72
+ for (const item of input) {
73
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
74
+ const rec = item as { type?: string; role?: string; content?: unknown };
75
+ if (rec.type !== undefined && rec.type !== "message") continue;
76
+ if (rec.role !== "user") continue;
77
+ let text = "";
78
+ if (typeof rec.content === "string") text = rec.content;
79
+ else if (Array.isArray(rec.content)) {
80
+ text = rec.content
81
+ .map(b => {
82
+ if (!b || typeof b !== "object") return "";
83
+ const block = b as { type?: string; text?: string };
84
+ return (block.type === "input_text" || block.type === "text") && typeof block.text === "string" ? block.text : "";
85
+ })
86
+ .join("");
87
+ }
88
+ if (text.trim().length > 0) out.push(text);
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function compactUserMessageItem(text: string): Record<string, unknown> {
94
+ return { type: "message", role: "user", content: [{ type: "input_text", text }] };
95
+ }
96
+
97
+ /** Build the v1 compact `output` array: retained recent user messages + the summary message. */
98
+ export function buildCompactV1Output(userMessages: string[], summary: string): Record<string, unknown>[] {
99
+ const selected: string[] = [];
100
+ let remaining = COMPACT_V1_RETAINED_CHAR_BUDGET;
101
+ for (let i = userMessages.length - 1; i >= 0 && remaining > 0; i--) {
102
+ const msg = userMessages[i];
103
+ if (msg.length <= remaining) {
104
+ selected.push(msg);
105
+ remaining -= msg.length;
106
+ } else {
107
+ // Budget partially covers this older message: keep its tail (most recent context) and stop.
108
+ selected.push(msg.slice(msg.length - remaining));
109
+ break;
110
+ }
111
+ }
112
+ selected.reverse();
113
+ // codex-rs compact.rs uses "{SUMMARY_PREFIX}\n{summary}" (single newline) and detects stored
114
+ // summaries by that exact prefix — keep the same shape.
115
+ const summaryText = summary.trim().length > 0 ? `${SUMMARY_PREFIX}\n${summary}` : "(no summary available)";
116
+ return [...selected.map(compactUserMessageItem), compactUserMessageItem(summaryText)];
117
+ }