@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.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 (166) hide show
  1. package/gui/dist/assets/index-CWXut3rG.js +115 -0
  2. package/gui/dist/assets/index-EdoPnm9_.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/devin.svg +49 -0
  5. package/gui/dist/provider-icons/omo.svg +42 -0
  6. package/package.json +3 -1
  7. package/src/AGENTS.md +1 -1
  8. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
  9. package/src/adapters/command-code.ts +0 -1
  10. package/src/adapters/cursor/checkpoint-store.ts +37 -0
  11. package/src/adapters/cursor/live-transport.ts +74 -2
  12. package/src/adapters/cursor.ts +6 -0
  13. package/src/adapters/devin/cloud-direct/auth.ts +264 -0
  14. package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
  15. package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
  16. package/src/adapters/devin/cloud-direct/index.ts +65 -0
  17. package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
  18. package/src/adapters/devin/cloud-direct/wire.ts +206 -0
  19. package/src/adapters/devin/live-models.ts +133 -0
  20. package/src/adapters/devin-cli/acp.ts +204 -0
  21. package/src/adapters/devin-cli/adapter.ts +345 -0
  22. package/src/adapters/devin-cli/binary.ts +69 -0
  23. package/src/adapters/devin-cli/models.ts +57 -0
  24. package/src/adapters/devin.ts +326 -0
  25. package/src/adapters/google.ts +1 -1
  26. package/src/adapters/openai-chat.ts +31 -10
  27. package/src/adapters/openai-responses.ts +16 -1
  28. package/src/adapters/registry.ts +25 -1
  29. package/src/bridge.ts +8 -2
  30. package/src/claude/inbound-cache-stabilize.ts +130 -0
  31. package/src/claude/inbound.ts +45 -5
  32. package/src/cli/account-auth.ts +60 -1
  33. package/src/cli/account-extended.ts +23 -30
  34. package/src/cli/account.ts +2 -1
  35. package/src/cli/capabilities.ts +50 -8
  36. package/src/cli/config-command.ts +2 -2
  37. package/src/cli/dispatch.ts +14 -2
  38. package/src/cli/export-command.ts +11 -25
  39. package/src/cli/help.ts +2 -2
  40. package/src/cli/opencode.ts +5 -0
  41. package/src/cli/registry.ts +15 -4
  42. package/src/clients/config-export/cline.ts +71 -0
  43. package/src/clients/config-export/contracts.ts +10 -1
  44. package/src/clients/config-export/model-metadata.ts +33 -0
  45. package/src/clients/config-export/zcode.ts +23 -12
  46. package/src/clients/config-export.ts +156 -4
  47. package/src/codex/account-store.ts +7 -2
  48. package/src/codex/auth-context.ts +1 -1
  49. package/src/codex/catalog/effort.ts +4 -3
  50. package/src/codex/catalog/metadata.ts +8 -4
  51. package/src/codex/catalog/native-models.ts +24 -4
  52. package/src/codex/catalog/parsing.ts +19 -1
  53. package/src/codex/catalog/provider-fetch.ts +57 -0
  54. package/src/codex/catalog/sync.ts +1 -1
  55. package/src/codex/context-compat.ts +97 -0
  56. package/src/codex/context-owner.ts +201 -0
  57. package/src/codex/history-provider.ts +161 -14
  58. package/src/codex/inject-coordination.ts +3 -2
  59. package/src/codex/inject.ts +128 -9
  60. package/src/codex/pool-rotation.ts +8 -292
  61. package/src/codex/quota.ts +41 -12
  62. package/src/codex/retired-model-migration.ts +41 -0
  63. package/src/codex/routing.ts +327 -22
  64. package/src/codex/warmup.ts +2 -2
  65. package/src/combos/failover.ts +5 -0
  66. package/src/combos/resolve.ts +11 -15
  67. package/src/config.ts +46 -14
  68. package/src/generated/compatibility-version.json +293 -113
  69. package/src/grok/grpc-web.ts +120 -0
  70. package/src/grok/reset-coupon-ledger.ts +139 -0
  71. package/src/grok/reset-coupons.ts +278 -0
  72. package/src/integrations/catalog-refresh.ts +1 -1
  73. package/src/integrations/cline-document.ts +73 -0
  74. package/src/integrations/cline-io.ts +149 -0
  75. package/src/integrations/cline-transaction.ts +42 -0
  76. package/src/integrations/config-io.ts +21 -2
  77. package/src/integrations/journal.ts +43 -1
  78. package/src/integrations/omp-yaml-source.ts +123 -4
  79. package/src/integrations/ownership-policy.ts +7 -0
  80. package/src/integrations/ownership.ts +36 -0
  81. package/src/integrations/registry.ts +27 -0
  82. package/src/integrations/state.ts +5 -2
  83. package/src/integrations/store.ts +6 -0
  84. package/src/integrations/writer.ts +53 -9
  85. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  86. package/src/lib/abort.ts +36 -0
  87. package/src/lib/local-destinations.ts +1 -1
  88. package/src/lib/upstream-retry.ts +36 -1
  89. package/src/oauth/account-quota-rank.ts +11 -0
  90. package/src/oauth/callback-server.ts +10 -5
  91. package/src/oauth/devin/api-base.ts +63 -0
  92. package/src/oauth/devin/login.ts +1 -0
  93. package/src/oauth/devin/register-user.ts +186 -0
  94. package/src/oauth/devin/types.ts +71 -0
  95. package/src/oauth/devin-cli.ts +149 -0
  96. package/src/oauth/devin.ts +170 -0
  97. package/src/oauth/generic-account-failover.ts +169 -1
  98. package/src/oauth/index.ts +20 -1
  99. package/src/oauth/login-cli.ts +19 -5
  100. package/src/oauth/pool-kernel.ts +321 -0
  101. package/src/oauth/pool-settings-capability.ts +127 -9
  102. package/src/oauth/store.ts +7 -3
  103. package/src/oauth/token-guardian.ts +1 -1
  104. package/src/providers/codebuddy-models.ts +0 -3
  105. package/src/providers/command-code-efforts.ts +23 -4
  106. package/src/providers/default-aliases.ts +4 -0
  107. package/src/providers/derive.ts +8 -0
  108. package/src/providers/devin-cli-authmode-migration.ts +57 -0
  109. package/src/providers/key-failover.ts +175 -0
  110. package/src/providers/model-rename-startup.ts +21 -1
  111. package/src/providers/qoder-models.ts +0 -1
  112. package/src/providers/quota-key-accounts.ts +50 -0
  113. package/src/providers/quota-routing-cache.ts +65 -6
  114. package/src/providers/quota-types.ts +7 -0
  115. package/src/providers/quota.ts +113 -30
  116. package/src/providers/registry.ts +237 -77
  117. package/src/providers/stale-context-window-migration.ts +92 -0
  118. package/src/providers/zai-responses-migration.ts +45 -0
  119. package/src/quota/reset-observer.ts +2 -1
  120. package/src/quota/reset-seen-store.ts +9 -1
  121. package/src/remote-control/crypto.ts +442 -0
  122. package/src/remote-control/host.ts +175 -0
  123. package/src/remote-control/index.ts +100 -0
  124. package/src/remote-control/protocol.ts +200 -0
  125. package/src/remote-control/relay.ts +162 -0
  126. package/src/remote-control/workspace-agent-protocol.ts +246 -0
  127. package/src/remote-control/workspace-rpc-framing.ts +128 -0
  128. package/src/remote-control/workspace-tools.ts +237 -0
  129. package/src/remote-control/workspace-utf8.ts +24 -0
  130. package/src/responses/custom-tool-compat.ts +23 -0
  131. package/src/router.ts +6 -1
  132. package/src/routing/compatibility/behavior.ts +3 -0
  133. package/src/server/adapter-resolve.ts +6 -2
  134. package/src/server/auth-cors.ts +71 -6
  135. package/src/server/chat-completions.ts +5 -3
  136. package/src/server/chat-native.ts +9 -0
  137. package/src/server/claude-messages.ts +25 -30
  138. package/src/server/context-history.ts +207 -0
  139. package/src/server/images.ts +28 -1
  140. package/src/server/index.ts +42 -22
  141. package/src/server/live.ts +2 -1
  142. package/src/server/management/agent-settings-routes.ts +7 -1
  143. package/src/server/management/config-routes.ts +3 -3
  144. package/src/server/management/grok-coupon-routes.ts +287 -0
  145. package/src/server/management/integration-routes.ts +6 -1
  146. package/src/server/management/oauth-account-routes.ts +124 -7
  147. package/src/server/management/provider-routes.ts +77 -2
  148. package/src/server/management/route-registry.ts +6 -1
  149. package/src/server/management-api.ts +7 -0
  150. package/src/server/request-log.ts +4 -0
  151. package/src/server/responses/codex-ws-exchange.ts +90 -14
  152. package/src/server/responses/codex-ws-wire.ts +51 -1
  153. package/src/server/responses/compact.ts +8 -1
  154. package/src/server/responses/core.ts +188 -22
  155. package/src/server/responses/ws-upstream.ts +1 -1
  156. package/src/server/responses-undeclared-tool-guard.ts +261 -12
  157. package/src/server/zai-responses-startup.ts +21 -0
  158. package/src/types/config.ts +33 -3
  159. package/src/types/provider.ts +54 -4
  160. package/src/types/request.ts +1 -1
  161. package/src/types/tools.ts +36 -6
  162. package/src/update/job.ts +33 -11
  163. package/src/vision/plan.ts +40 -37
  164. package/src/web-search/index.ts +1 -0
  165. package/gui/dist/assets/index-BoBRSehJ.css +0 -1
  166. package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Repair a saved `devin-cli` row that still claims `authMode: "local"`.
3
+ *
4
+ * `derive.ts` seeds `authMode` from the registry's `authKind`, so every config
5
+ * written while `devin-cli` was a local provider carries `"local"`. The registry
6
+ * now classifies it as an account provider, and the management write boundary
7
+ * fails closed on that mismatch: `auth-cors.ts` rejects `authMode: "local"` when
8
+ * the registry entry is not local. Without this, an existing install can no
9
+ * longer save provider changes from the dashboard.
10
+ *
11
+ * It rewrites exactly that one field, on exactly that mismatch. A row the user
12
+ * retargeted at another adapter is left alone, and so is any other value.
13
+ *
14
+ * It also WARNS, without changing anything, when the saved row still names the
15
+ * ACP adapter. That field no longer selects the transport — `routedProviderConfig`
16
+ * pins the adapter from the registry for any row whose name is a registry id — so
17
+ * an operator who deliberately chose ACP would otherwise switch transports
18
+ * silently. The warning names the custom-id row that still gets them ACP.
19
+ */
20
+ import { PROVIDER_REGISTRY } from "./registry";
21
+ import type { OcxConfig } from "../types";
22
+
23
+ export interface DevinCliAuthModeProjection {
24
+ config: OcxConfig;
25
+ changed: boolean;
26
+ warnings: string[];
27
+ }
28
+
29
+ const ACP_ESCAPE_HATCH =
30
+ 'a custom-named row still reaches it, e.g. "devin-acp": { "adapter": "devin-cli", "baseUrl": "https://cli.devin.ai" }';
31
+
32
+ export function projectDevinCliAuthMode(config: OcxConfig): DevinCliAuthModeProjection {
33
+ const warnings: string[] = [];
34
+ const prov = config.providers?.["devin-cli"];
35
+ if (!prov) return { config, changed: false, warnings };
36
+
37
+ const entry = PROVIDER_REGISTRY.find(row => row.id === "devin-cli");
38
+ if (!entry || entry.authKind !== "oauth") return { config, changed: false, warnings };
39
+
40
+ // Non-mutating: the saved adapter no longer decides the transport, so leaving
41
+ // it in place preserves nothing except a signal worth reporting once.
42
+ if (prov.adapter === "devin-cli") {
43
+ warnings.push(
44
+ 'the saved "devin-cli" row names the ACP adapter, but that provider id now streams over '
45
+ + `Cognition's api-server and the adapter is pinned from the registry; for the CLI's own agent loop, ${ACP_ESCAPE_HATCH}.`,
46
+ );
47
+ }
48
+
49
+ if (prov.authMode !== "local") return { config, changed: warnings.length > 0 ? false : false, warnings };
50
+ prov.authMode = "oauth";
51
+ warnings.push(
52
+ 'rewrote "devin-cli" authMode local -> oauth: the registry no longer classifies it as local, '
53
+ + "and the management write boundary fails closed on the mismatch.",
54
+ );
55
+ return { config, changed: true, warnings };
56
+ }
57
+
@@ -15,6 +15,10 @@ import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetry
15
15
  import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport";
16
16
  import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
17
17
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
18
+ // quota-key-accounts imports only node:crypto, the key store and the quota types -- NOT
19
+ // providers/quota.ts -- so the cached reader reaches the dispatch path without dragging the
20
+ // probe machinery onto it.
21
+ import { cachedApiKeyQuota } from "./quota-key-accounts";
18
22
 
19
23
  // ---- cooldown state (in-memory, same as codex/routing.ts) ----
20
24
 
@@ -100,6 +104,177 @@ export function hasKeyPoolFailover(provider: OcxProviderConfig): boolean {
100
104
  return (provider.apiKeyPool?.length ?? 0) >= 2;
101
105
  }
102
106
 
107
+ /**
108
+ * Process-local round-robin cursor per provider, deliberately parallel to `keyCooldowns`
109
+ * rather than borrowing the Codex pool-rotation state: an API key is not an OAuth account
110
+ * and must not share a quota scope key. Multi-process desync is the same accepted limit
111
+ * the cooldown map already carries.
112
+ */
113
+ const keyRotationCursor = new Map<string, string>();
114
+
115
+ /**
116
+ * Forget a provider's cursor so an operator's manual key selection is not second-guessed.
117
+ *
118
+ * Optional name, mirroring `clearKeyCooldowns`, because the batch provider PUT rewrites the
119
+ * entire roster: a cursor that survives a reorder still names a real id, so round-robin
120
+ * resumes after the pre-edit position and can skip the first eligible key in the new pool.
121
+ */
122
+ export function forgetApiKeyRotationCursor(providerName?: string): void {
123
+ if (!providerName) {
124
+ keyRotationCursor.clear();
125
+ return;
126
+ }
127
+ keyRotationCursor.delete(providerName);
128
+ }
129
+
130
+ /** The pool entry shape is inline on OcxProviderConfig; name it once rather than re-spelling it. */
131
+ type ApiKeyPoolEntry = NonNullable<OcxProviderConfig["apiKeyPool"]>[number];
132
+
133
+ /**
134
+ * Remaining headroom for one key, or null when nothing current measures it.
135
+ *
136
+ * Same definition as `headroomOf` on the OAuth side, so the two pools cannot disagree about
137
+ * what "more room" means. `creditsUsd` is deliberately excluded: it is a currency amount, not
138
+ * a percentage, and ranking one against the other produces an order that means nothing.
139
+ */
140
+ function keyHeadroom(providerName: string, provider: OcxProviderConfig, entry: ApiKeyPoolEntry): number | null {
141
+ const quota = cachedApiKeyQuota(providerName, provider, entry.id, entry.key);
142
+ if (!quota) return null;
143
+ const percents = [
144
+ quota.fiveHourPercent,
145
+ quota.weeklyPercent,
146
+ quota.monthlyPercent,
147
+ ...(quota.customWindows ?? []).map((window: { percent?: number }) => window.percent),
148
+ ].filter((value): value is number => typeof value === "number");
149
+ if (percents.length === 0) return null;
150
+ return 100 - Math.max(...percents);
151
+ }
152
+
153
+ /**
154
+ * Order eligible keys best-first, in the same three buckets `rankAccountsByHeadroom` uses:
155
+ * measured-with-headroom, then unmeasured, then measured-and-spent. Ties keep the roster order.
156
+ *
157
+ * An unmeasured key is NOT assumed spent, and not assumed fresh either -- it sits between the
158
+ * two, which is the only honest position for a key nothing has looked at. A provider that
159
+ * publishes no per-key differentiation (DeepSeek reports every key at the same percent) ties
160
+ * across the board and falls through to the roster order, which is exactly today's behaviour.
161
+ */
162
+ function rankKeysByHeadroom(
163
+ providerName: string,
164
+ provider: OcxProviderConfig,
165
+ eligible: readonly ApiKeyPoolEntry[],
166
+ ): ApiKeyPoolEntry[] {
167
+ return eligible
168
+ .map((entry, index) => {
169
+ const headroom = keyHeadroom(providerName, provider, entry);
170
+ const bucket = headroom === null ? 1 : headroom <= 0 ? 2 : 0;
171
+ return { entry, bucket, headroom: headroom ?? 0, index };
172
+ })
173
+ .sort((left, right) => (left.bucket - right.bucket)
174
+ || (right.headroom - left.headroom)
175
+ || (left.index - right.index))
176
+ .map(row => row.entry);
177
+ }
178
+
179
+
180
+ /**
181
+ * Pick a better key BEFORE the first attempt when the committed one is already cooling.
182
+ *
183
+ * This is intentionally narrow. It never overrides a healthy key: if the committed
184
+ * `apiKey` is not in cooldown it returns null, so an operator's manual selection stands
185
+ * and no config write happens. It only acts when the committed key is known-cooled (or
186
+ * missing from the pool), which is exactly the case where the first request would
187
+ * otherwise be spent earning a 429 the runtime could already predict.
188
+ *
189
+ * Returning null is the common path, so the persisted-selection transaction is not on
190
+ * the per-request hot path.
191
+ *
192
+ * Like `rotateKeyAfterFailure`, the returned object is a snapshot of the PERSISTED config
193
+ * and carries none of the registry backfills `routedProviderConfig` merges in at request
194
+ * time. A request path must not assign it to an active route wholesale -- for a built-in
195
+ * provider stored in its valid minimal form that would drop the adapter id, the base URL and
196
+ * the static headers, so `resolveAdapter()` throws `Unknown adapter: undefined` and a
197
+ * hand-built URL dereferences a missing `baseUrl`. Use
198
+ * `selectProactiveApiKeyTransport`, the pre-dispatch twin of `rotateProviderTransportOn429`.
199
+ */
200
+ export function selectProactiveApiKey(
201
+ config: OcxConfig,
202
+ providerName: string,
203
+ now = Date.now(),
204
+ ): OcxProviderConfig | null {
205
+ const provider = config.providers?.[providerName];
206
+ if (!provider) return null;
207
+ const strategy = provider.apiKeyPoolStrategy;
208
+ if (!strategy) return null;
209
+ if (!hasKeyPoolFailover(provider)) return null;
210
+ const pool = provider.apiKeyPool ?? [];
211
+
212
+ const activeEntry = pool.find(entry => entry.key === provider.apiKey);
213
+ // A healthy committed key wins, whether the operator chose it or a previous rotation did.
214
+ if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) return null;
215
+
216
+ const eligible = pool.filter(entry => !isKeyInCooldown(providerName, entry.id, now));
217
+ if (eligible.length === 0) return null;
218
+
219
+ let chosen = eligible[0]!;
220
+ if (strategy === "round-robin") {
221
+ const lastId = keyRotationCursor.get(providerName);
222
+ const lastIndex = lastId ? pool.findIndex(entry => entry.id === lastId) : -1;
223
+ for (let offset = 1; offset <= pool.length; offset += 1) {
224
+ const candidate = pool[(lastIndex + offset) % pool.length]!;
225
+ if (isKeyInCooldown(providerName, candidate.id, now)) continue;
226
+ chosen = candidate;
227
+ break;
228
+ }
229
+ } else if (strategy === "quota") {
230
+ // else-if, deliberately. `fill-first` is not a named branch here -- it is the eligible[0]
231
+ // default above, so replacing that default would silently retarget it.
232
+ chosen = rankKeysByHeadroom(providerName, provider, eligible)[0] ?? chosen;
233
+ }
234
+ if (chosen.key === provider.apiKey) return null;
235
+
236
+ const outcome = commitProviderApiKeySelection<string | null>(config, providerName, freshProvider => {
237
+ const freshPool = freshProvider.apiKeyPool ?? [];
238
+ const target = freshPool.find(entry => entry.id === chosen.id);
239
+ if (!target) return { changed: false, value: null };
240
+ if (freshProvider.apiKey === target.key) return { changed: false, value: null };
241
+ const freshActive = freshPool.find(entry => entry.key === freshProvider.apiKey);
242
+ // Re-check under the lock: a concurrent manual selection may have landed a healthy key.
243
+ if (freshActive && !isKeyInCooldown(providerName, freshActive.id, now)) {
244
+ return { changed: false, value: null };
245
+ }
246
+ freshProvider.apiKey = target.key;
247
+ return { changed: true, value: target.id };
248
+ });
249
+ if (outcome.status !== "committed" || outcome.value === null) return null;
250
+
251
+ keyRotationCursor.set(providerName, outcome.value);
252
+ const committed = structuredClone(outcome.provider);
253
+ config.providers[providerName] = committed;
254
+ return structuredClone(committed);
255
+ }
256
+
257
+ /**
258
+ * Pre-dispatch twin of `rotateProviderTransportOn429`: pick a warm key, then rebuild the
259
+ * active route from the committed row through the same seam the 429 path uses, so the
260
+ * registry backfills survive and only explicit runtime transport state (`fetch` and a
261
+ * generated OpenCode session header) is carried over from the route being replaced.
262
+ *
263
+ * Every request path that assigns the result to a live route must call THIS, not
264
+ * `selectProactiveApiKey`, which answers with a persisted snapshot.
265
+ */
266
+ export function selectProactiveApiKeyTransport(
267
+ config: OcxConfig,
268
+ providerName: string,
269
+ routedProvider: OcxProviderTransport,
270
+ promptCacheKey?: string,
271
+ now = Date.now(),
272
+ ): OcxProviderTransport | null {
273
+ const committed = selectProactiveApiKey(config, providerName, now);
274
+ if (!committed) return null;
275
+ return applyRotatedTransport(providerName, routedProvider, committed, promptCacheKey);
276
+ }
277
+
103
278
  /**
104
279
  * Normalize a provider's `retryOn429` policy, or return null when the knob is absent,
105
280
  * explicitly disabled, or the provider is not key-auth (OAuth/forward credentials must not be
@@ -1,7 +1,27 @@
1
1
  import { mutatePersistedConfig } from "../config";
2
2
  import { projectModelRenames } from "./model-rename-migration";
3
+ import { projectStaleContextWindows } from "./stale-context-window-migration";
4
+ import { projectDevinCliAuthMode } from "./devin-cli-authmode-migration";
3
5
  import type { OcxConfig } from "../types";
4
6
 
7
+ /**
8
+ * The startup projection: registry model renames, then the context-window
9
+ * repair. Both fix a saved row the registry can no longer reach on its own —
10
+ * `enrichProviderFromRegistry` backfills a missing field and never rewrites a
11
+ * present one — so they share this pass rather than adding a second boot step
12
+ * with its own persistence, adopt, and failure handling.
13
+ */
14
+ export function projectStartupConfigRepairs(config: OcxConfig): ReturnType<typeof projectModelRenames> {
15
+ const renames = projectModelRenames(config);
16
+ const windows = projectStaleContextWindows(renames.config);
17
+ const devinCli = projectDevinCliAuthMode(windows.config);
18
+ return {
19
+ config: devinCli.config,
20
+ changed: renames.changed || windows.changed || devinCli.changed,
21
+ warnings: [...renames.warnings, ...windows.warnings, ...devinCli.warnings],
22
+ };
23
+ }
24
+
5
25
  export interface ModelRenameStartupDeps {
6
26
  project: typeof projectModelRenames;
7
27
  /** Injected writer for tests and callers that own their own persistence. */
@@ -59,7 +79,7 @@ function adoptConfig(target: OcxConfig, source: OcxConfig): void {
59
79
  */
60
80
  export function runModelRenameStartupMigration(
61
81
  config: OcxConfig,
62
- deps: ModelRenameStartupDeps = { project: projectModelRenames },
82
+ deps: ModelRenameStartupDeps = { project: projectStartupConfigRepairs },
63
83
  ): OcxConfig {
64
84
  const projection = deps.project(structuredClone(config));
65
85
  if (!projection.changed) {
@@ -10,7 +10,6 @@ export const QODER_GLOBAL_MODELS = [
10
10
  "Kimi-K2.7-Code",
11
11
  "GLM-5.3",
12
12
  "GLM-5.2",
13
- "DeepSeek-V4-Pro",
14
13
  ] as const;
15
14
 
16
15
  /** Live Qoder CN roster captured from the official CLI on 2026-09-03. */
@@ -29,6 +29,56 @@ export function clearProviderApiKeyQuotaCache(): void {
29
29
  flights.clear();
30
30
  }
31
31
 
32
+ /**
33
+ * Cached-only, synchronous per-key quota. Never probes, never awaits, never schedules a read.
34
+ *
35
+ * The selector that calls this sits on the first-attempt path, where a network read would be a
36
+ * worse defect than the one it is there to fix. A miss is simply "no evidence".
37
+ *
38
+ * An `unavailable` row is a miss too, and that is the whole point of the check. `readEntry`
39
+ * keeps a last-good quota attached for up to LAST_GOOD_MS after a probe starts failing, so
40
+ * returning `entry.quota` on any hit would rank on a number up to half an hour stale -- and
41
+ * rank it ABOVE a key with no row at all. Last-good is a display value, not a selection input.
42
+ *
43
+ * A SUCCESSFUL row expires too, on exactly `readEntry`'s freshness predicate. Checking only
44
+ * `unavailable` was not enough: nothing on the selection path probes or sweeps, so once a
45
+ * dashboard or CLI read had populated the cache, a row could outlive ACCOUNT_QUOTA_TTL_MS and
46
+ * keep a "roomy" ten-minute-old measurement ranked above a key with no evidence at all --
47
+ * until some unrelated write happened to sweep it. Expired is no evidence, same as absent.
48
+ */
49
+ export function cachedApiKeyQuota(
50
+ name: string,
51
+ provider: OcxProviderConfig,
52
+ keyId: string,
53
+ key: string,
54
+ ): ProviderQuota | null {
55
+ let resolved: string | undefined;
56
+ // resolveProviderApiKey swallows its own failures; the catch is belt-and-braces because this
57
+ // runs on the dispatch path and must not throw there under any future change.
58
+ try { resolved = resolveProviderApiKey(key)?.trim(); } catch { return null; }
59
+ if (!resolved) return null;
60
+ const entry = cache.get(identity(name, provider, keyId, resolved));
61
+ if (!entry || entry.unavailable || !entry.quota) return null;
62
+ const now = Date.now();
63
+ if (now - entry.ts >= ACCOUNT_QUOTA_TTL_MS) return null;
64
+ if (now - entry.quota.updatedAt >= LAST_GOOD_MS) return null;
65
+ return entry.quota;
66
+ }
67
+
68
+ /** Test seam: keyed on identity(), so it takes the raw key rather than an account id. */
69
+ export function setCachedProviderApiKeyQuotaForTests(
70
+ name: string,
71
+ provider: OcxProviderConfig,
72
+ keyId: string,
73
+ key: string,
74
+ quota: ProviderQuota | null,
75
+ unavailable?: true,
76
+ ): void {
77
+ const resolved = resolveProviderApiKey(key)?.trim();
78
+ if (!resolved) return;
79
+ remember(identity(name, provider, keyId, resolved), { ts: Date.now(), quota, ...(unavailable ? { unavailable } : {}) });
80
+ }
81
+
32
82
  /** Four workers per roster, not a process-wide network limit. */
33
83
  export async function mapQuotaRoster<T, R>(rows: readonly T[], read: (row: T) => Promise<R>): Promise<R[]> {
34
84
  const out = new Array<R>(rows.length);
@@ -1,32 +1,91 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { OcxProviderConfig } from "../types";
1
3
  import type { ProviderQuota, ProviderQuotaReport } from "./quota";
4
+ import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store";
5
+ import { getProviderRegistryEntry } from "./registry";
6
+ import { PROVIDER_QUOTA_MAX_AGE_MS } from "./quota-types";
2
7
 
3
- const quotaCache = new Map<string, ProviderQuota>();
8
+ export interface ProviderQuotaRoutingEvidence {
9
+ quota: ProviderQuota;
10
+ binding: string;
11
+ }
12
+
13
+ type CachedQuota = {
14
+ quota: ProviderQuota;
15
+ routing?: ProviderQuotaRoutingEvidence | { quota: ProviderQuota; testOnly: true };
16
+ };
17
+
18
+ const quotaCache = new Map<string, CachedQuota>();
19
+
20
+ /** Private cache identity; neither key material nor this digest enters management reports. */
21
+ export function providerQuotaRoutingBinding(
22
+ name: string,
23
+ provider: OcxProviderConfig,
24
+ credential = resolveProviderApiKey(provider.apiKey)?.trim(),
25
+ ): string | null {
26
+ if ((provider.authMode ?? "key") !== "key" || !credential) return null;
27
+ // Registry-owned OAuth/forward rows normalize saved authMode before dispatch.
28
+ // A key probe must not constrain that later account selection.
29
+ const entry = getProviderRegistryEntry(name);
30
+ if (entry && (entry.authKind === "oauth" || entry.authKind === "forward")
31
+ && !providerUsesKeyAuthOverride(entry, provider, credential)) return null;
32
+ // Static auth headers can replace or combine with the probed API-key header.
33
+ // Its semantics belong to the adapter, so it is not provider-wide quota evidence.
34
+ if (Object.keys(provider.headers ?? {}).some(header =>
35
+ ["authorization", "x-api-key", "x-goog-api-key"].includes(header.toLowerCase()))) return null;
36
+ return createHash("sha256").update(JSON.stringify([
37
+ name, provider.adapter, provider.baseUrl, credential,
38
+ ])).digest("hex");
39
+ }
4
40
 
5
41
  export function clearCachedProviderQuotas(): void {
6
42
  quotaCache.clear();
7
43
  }
8
44
 
9
- export function replaceCachedProviderQuotas(reports: ProviderQuotaReport[]): void {
45
+ export function replaceCachedProviderQuotas(
46
+ reports: ProviderQuotaReport[],
47
+ routingEvidence?: WeakMap<ProviderQuotaReport, ProviderQuotaRoutingEvidence>,
48
+ ): void {
10
49
  quotaCache.clear();
11
50
  for (const report of reports) {
12
- quotaCache.set(report.provider, report.quota);
51
+ quotaCache.set(report.provider, { quota: report.quota, routing: routingEvidence?.get(report) });
13
52
  }
14
53
  }
15
54
 
16
55
  export function getCachedProviderQuota(
17
56
  provider: string,
18
57
  now: number,
19
- maxAgeMs = 30 * 60_000,
58
+ maxAgeMs = PROVIDER_QUOTA_MAX_AGE_MS,
20
59
  ): ProviderQuota | null {
21
- const quota = quotaCache.get(provider);
60
+ const quota = quotaCache.get(provider)?.quota;
22
61
  if (!quota) return null;
23
62
  if (now - quota.updatedAt > maxAgeMs) return null;
24
63
  return quota;
25
64
  }
26
65
 
66
+ /** Only inference-wide evidence for this sole credential may rank or veto a whole provider. */
67
+ export function getCachedProviderRoutingQuota(
68
+ name: string,
69
+ provider: OcxProviderConfig | undefined,
70
+ now: number,
71
+ maxAgeMs = PROVIDER_QUOTA_MAX_AGE_MS,
72
+ ): ProviderQuota | null {
73
+ if (!provider || provider.disabled === true || (provider.authMode ?? "key") !== "key") return null;
74
+ // An active-key report cannot speak for the other keys the dispatcher may select.
75
+ if ((provider.apiKeyPool?.length ?? 0) > 1) return null;
76
+ const routing = quotaCache.get(name)?.routing;
77
+ if (!routing || !Number.isFinite(routing.quota.updatedAt) || routing.quota.updatedAt < 0
78
+ || routing.quota.updatedAt > now || now - routing.quota.updatedAt >= maxAgeMs) return null;
79
+ const binding = providerQuotaRoutingBinding(name, provider);
80
+ if (!binding || (!("testOnly" in routing) && routing.binding !== binding)) return null;
81
+ return routing.quota;
82
+ }
83
+
27
84
  export function setCachedProviderQuotaForTests(
28
85
  provider: string,
29
86
  quota: ProviderQuota,
30
87
  ): void {
31
- quotaCache.set(provider, quota);
88
+ // Unit tests deliberately assert the supplied quota's scope. Production publication
89
+ // requires the producer's private, credential-bound evidence map above.
90
+ quotaCache.set(provider, { quota, routing: { quota, testOnly: true } });
32
91
  }
@@ -8,6 +8,13 @@
8
8
  * blocks any later attempt to load one side without the other.
9
9
  */
10
10
 
11
+ export const PROVIDER_QUOTA_MAX_AGE_MS = 30 * 60_000;
12
+
13
+ /** Management-only eligibility evidence; private credential binding never leaves the server. */
14
+ export type ProviderRoutingQuota =
15
+ | { state: "unknown" }
16
+ | { state: "available" | "exhausted"; updatedAt: number; validUntil: number };
17
+
11
18
  export interface ProviderQuotaWindow {
12
19
  label: string;
13
20
  percent: number;