@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.
- package/gui/dist/assets/index-CWXut3rG.js +115 -0
- package/gui/dist/assets/index-EdoPnm9_.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/devin.svg +49 -0
- package/gui/dist/provider-icons/omo.svg +42 -0
- package/package.json +3 -1
- package/src/AGENTS.md +1 -1
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
- package/src/adapters/command-code.ts +0 -1
- package/src/adapters/cursor/checkpoint-store.ts +37 -0
- package/src/adapters/cursor/live-transport.ts +74 -2
- package/src/adapters/cursor.ts +6 -0
- package/src/adapters/devin/cloud-direct/auth.ts +264 -0
- package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
- package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
- package/src/adapters/devin/cloud-direct/index.ts +65 -0
- package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
- package/src/adapters/devin/cloud-direct/wire.ts +206 -0
- package/src/adapters/devin/live-models.ts +133 -0
- package/src/adapters/devin-cli/acp.ts +204 -0
- package/src/adapters/devin-cli/adapter.ts +345 -0
- package/src/adapters/devin-cli/binary.ts +69 -0
- package/src/adapters/devin-cli/models.ts +57 -0
- package/src/adapters/devin.ts +326 -0
- package/src/adapters/google.ts +1 -1
- package/src/adapters/openai-chat.ts +31 -10
- package/src/adapters/openai-responses.ts +16 -1
- package/src/adapters/registry.ts +25 -1
- package/src/bridge.ts +8 -2
- package/src/claude/inbound-cache-stabilize.ts +130 -0
- package/src/claude/inbound.ts +45 -5
- package/src/cli/account-auth.ts +60 -1
- package/src/cli/account-extended.ts +23 -30
- package/src/cli/account.ts +2 -1
- package/src/cli/capabilities.ts +50 -8
- package/src/cli/config-command.ts +2 -2
- package/src/cli/dispatch.ts +14 -2
- package/src/cli/export-command.ts +11 -25
- package/src/cli/help.ts +2 -2
- package/src/cli/opencode.ts +5 -0
- package/src/cli/registry.ts +15 -4
- package/src/clients/config-export/cline.ts +71 -0
- package/src/clients/config-export/contracts.ts +10 -1
- package/src/clients/config-export/model-metadata.ts +33 -0
- package/src/clients/config-export/zcode.ts +23 -12
- package/src/clients/config-export.ts +156 -4
- package/src/codex/account-store.ts +7 -2
- package/src/codex/auth-context.ts +1 -1
- package/src/codex/catalog/effort.ts +4 -3
- package/src/codex/catalog/metadata.ts +8 -4
- package/src/codex/catalog/native-models.ts +24 -4
- package/src/codex/catalog/parsing.ts +19 -1
- package/src/codex/catalog/provider-fetch.ts +57 -0
- package/src/codex/catalog/sync.ts +1 -1
- package/src/codex/context-compat.ts +97 -0
- package/src/codex/context-owner.ts +201 -0
- package/src/codex/history-provider.ts +161 -14
- package/src/codex/inject-coordination.ts +3 -2
- package/src/codex/inject.ts +128 -9
- package/src/codex/pool-rotation.ts +8 -292
- package/src/codex/quota.ts +41 -12
- package/src/codex/retired-model-migration.ts +41 -0
- package/src/codex/routing.ts +327 -22
- package/src/codex/warmup.ts +2 -2
- package/src/combos/failover.ts +5 -0
- package/src/combos/resolve.ts +11 -15
- package/src/config.ts +46 -14
- package/src/generated/compatibility-version.json +293 -113
- package/src/grok/grpc-web.ts +120 -0
- package/src/grok/reset-coupon-ledger.ts +139 -0
- package/src/grok/reset-coupons.ts +278 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/cline-document.ts +73 -0
- package/src/integrations/cline-io.ts +149 -0
- package/src/integrations/cline-transaction.ts +42 -0
- package/src/integrations/config-io.ts +21 -2
- package/src/integrations/journal.ts +43 -1
- package/src/integrations/omp-yaml-source.ts +123 -4
- package/src/integrations/ownership-policy.ts +7 -0
- package/src/integrations/ownership.ts +36 -0
- package/src/integrations/registry.ts +27 -0
- package/src/integrations/state.ts +5 -2
- package/src/integrations/store.ts +6 -0
- package/src/integrations/writer.ts +53 -9
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/abort.ts +36 -0
- package/src/lib/local-destinations.ts +1 -1
- package/src/lib/upstream-retry.ts +36 -1
- package/src/oauth/account-quota-rank.ts +11 -0
- package/src/oauth/callback-server.ts +10 -5
- package/src/oauth/devin/api-base.ts +63 -0
- package/src/oauth/devin/login.ts +1 -0
- package/src/oauth/devin/register-user.ts +186 -0
- package/src/oauth/devin/types.ts +71 -0
- package/src/oauth/devin-cli.ts +149 -0
- package/src/oauth/devin.ts +170 -0
- package/src/oauth/generic-account-failover.ts +169 -1
- package/src/oauth/index.ts +20 -1
- package/src/oauth/login-cli.ts +19 -5
- package/src/oauth/pool-kernel.ts +321 -0
- package/src/oauth/pool-settings-capability.ts +127 -9
- package/src/oauth/store.ts +7 -3
- package/src/oauth/token-guardian.ts +1 -1
- package/src/providers/codebuddy-models.ts +0 -3
- package/src/providers/command-code-efforts.ts +23 -4
- package/src/providers/default-aliases.ts +4 -0
- package/src/providers/derive.ts +8 -0
- package/src/providers/devin-cli-authmode-migration.ts +57 -0
- package/src/providers/key-failover.ts +175 -0
- package/src/providers/model-rename-startup.ts +21 -1
- package/src/providers/qoder-models.ts +0 -1
- package/src/providers/quota-key-accounts.ts +50 -0
- package/src/providers/quota-routing-cache.ts +65 -6
- package/src/providers/quota-types.ts +7 -0
- package/src/providers/quota.ts +113 -30
- package/src/providers/registry.ts +237 -77
- package/src/providers/stale-context-window-migration.ts +92 -0
- package/src/providers/zai-responses-migration.ts +45 -0
- package/src/quota/reset-observer.ts +2 -1
- package/src/quota/reset-seen-store.ts +9 -1
- package/src/remote-control/crypto.ts +442 -0
- package/src/remote-control/host.ts +175 -0
- package/src/remote-control/index.ts +100 -0
- package/src/remote-control/protocol.ts +200 -0
- package/src/remote-control/relay.ts +162 -0
- package/src/remote-control/workspace-agent-protocol.ts +246 -0
- package/src/remote-control/workspace-rpc-framing.ts +128 -0
- package/src/remote-control/workspace-tools.ts +237 -0
- package/src/remote-control/workspace-utf8.ts +24 -0
- package/src/responses/custom-tool-compat.ts +23 -0
- package/src/router.ts +6 -1
- package/src/routing/compatibility/behavior.ts +3 -0
- package/src/server/adapter-resolve.ts +6 -2
- package/src/server/auth-cors.ts +71 -6
- package/src/server/chat-completions.ts +5 -3
- package/src/server/chat-native.ts +9 -0
- package/src/server/claude-messages.ts +25 -30
- package/src/server/context-history.ts +207 -0
- package/src/server/images.ts +28 -1
- package/src/server/index.ts +42 -22
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/config-routes.ts +3 -3
- package/src/server/management/grok-coupon-routes.ts +287 -0
- package/src/server/management/integration-routes.ts +6 -1
- package/src/server/management/oauth-account-routes.ts +124 -7
- package/src/server/management/provider-routes.ts +77 -2
- package/src/server/management/route-registry.ts +6 -1
- package/src/server/management-api.ts +7 -0
- package/src/server/request-log.ts +4 -0
- package/src/server/responses/codex-ws-exchange.ts +90 -14
- package/src/server/responses/codex-ws-wire.ts +51 -1
- package/src/server/responses/compact.ts +8 -1
- package/src/server/responses/core.ts +188 -22
- package/src/server/responses/ws-upstream.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +261 -12
- package/src/server/zai-responses-startup.ts +21 -0
- package/src/types/config.ts +33 -3
- package/src/types/provider.ts +54 -4
- package/src/types/request.ts +1 -1
- package/src/types/tools.ts +36 -6
- package/src/update/job.ts +33 -11
- package/src/vision/plan.ts +40 -37
- package/src/web-search/index.ts +1 -0
- package/gui/dist/assets/index-BoBRSehJ.css +0 -1
- 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:
|
|
82
|
+
deps: ModelRenameStartupDeps = { project: projectStartupConfigRepairs },
|
|
63
83
|
): OcxConfig {
|
|
64
84
|
const projection = deps.project(structuredClone(config));
|
|
65
85
|
if (!projection.changed) {
|
|
@@ -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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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;
|