@bitkyc08/opencodex 2.58.0 → 2.60.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/README.md +28 -10
- package/gui/dist/assets/index-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/crusoe.svg +1 -0
- package/gui/dist/provider-icons/opper.svg +3 -0
- package/package.json +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +34 -1
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-request.ts +8 -5
- package/src/adapters/cursor/request-builder.ts +21 -4
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +45 -2
- package/src/adapters/devin/cloud-direct/chat.ts +14 -3
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
- package/src/adapters/devin.ts +125 -26
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +55 -4
- package/src/adapters/google-http.ts +57 -11
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +60 -10
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +10 -11
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +25 -4
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +28 -35
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +35 -4
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +26 -6
- package/src/cli/account-orca-import.ts +63 -0
- package/src/cli/account.ts +32 -4
- package/src/cli/capabilities.ts +40 -0
- package/src/cli/claude.ts +29 -1
- package/src/cli/codex-cli-update.ts +97 -2
- package/src/cli/dispatch.ts +57 -3
- package/src/cli/doctor.ts +218 -4
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +95 -22
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +11 -1
- package/src/cli/runtime-api.ts +44 -0
- package/src/cli/start-args.ts +94 -0
- package/src/cli/system-command.ts +2 -0
- package/src/client/machine-api.ts +6 -5
- package/src/client/machine-listener.ts +16 -3
- package/src/client/runtime.ts +26 -2
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +146 -5
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +44 -10
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +16 -12
- package/src/codex/catalog/build-entries.ts +25 -4
- package/src/codex/catalog/derive-entry.ts +8 -1
- package/src/codex/catalog/effort.ts +10 -6
- package/src/codex/catalog/gather-capture.ts +22 -2
- package/src/codex/catalog/model-hints.ts +66 -33
- package/src/codex/catalog/parsing.ts +90 -5
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +41 -26
- package/src/codex/catalog/routed-gather.ts +61 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +24 -167
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject.ts +3 -2
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +31 -0
- package/src/codex/main-account-hard-lock.ts +2 -1
- package/src/codex/main-account.ts +10 -3
- package/src/codex/main-device-reauth.ts +17 -9
- package/src/codex/model-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +80 -2
- package/src/codex/observed-model-denials.ts +230 -0
- package/src/codex/orca-auth-source.ts +94 -0
- package/src/codex/orca-import.ts +219 -0
- package/src/codex/prompt-text-probe.ts +289 -16
- package/src/codex/quota-401-recovery.ts +12 -0
- package/src/codex/quota-types.ts +65 -0
- package/src/codex/quota.ts +24 -19
- package/src/codex/routing/cooldown-math.ts +8 -47
- package/src/codex/routing/pin-drain.ts +57 -0
- package/src/codex/routing.ts +20 -16
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +114 -2
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +125 -5
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +43 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +33 -0
- package/src/config/pending-teardown.ts +8 -0
- package/src/config/process-state.ts +36 -3
- package/src/config/provider-relative-send-path.ts +16 -0
- package/src/config/proxy-env.ts +31 -7
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +25 -0
- package/src/config/schema/leaf-validators.ts +76 -17
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +410 -258
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +82 -15
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-outbound.ts +53 -5
- package/src/lib/proxy-env.ts +70 -3
- package/src/lib/request-execution-budget.ts +11 -3
- package/src/lib/response-body-inactivity.ts +193 -0
- package/src/lib/retry-delay.ts +69 -0
- package/src/lib/socks5-fetch.ts +741 -0
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +332 -35
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +41 -27
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/oauth/orcarouter.ts +15 -2
- package/src/oauth/store.ts +8 -0
- package/src/providers/codex-capacity.ts +9 -0
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +327 -25
- package/src/providers/model-rename-migration.ts +56 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +131 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +87 -21
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/responses/apply-patch-envelope.ts +32 -11
- package/src/responses/bridge-search-replay-cache.ts +152 -0
- package/src/responses/code-mode-helper-compat.ts +26 -16
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +9 -2
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +13 -5
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +30 -5
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +24 -25
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +48 -7
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +136 -34
- package/src/server/request-log.ts +80 -3
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +74 -30
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +63 -30
- package/src/server/responses/compact.ts +89 -18
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +90 -24
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +16 -15
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +41 -15
- package/src/server/responses/passthrough-dispatch.ts +145 -53
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +96 -17
- package/src/server/responses/request-send-budget.ts +89 -8
- package/src/server/responses/request-sidecar-auth.ts +17 -9
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +15 -12
- package/src/server/responses/run-turn-execution.ts +45 -8
- package/src/server/responses/sidecar-execution.ts +19 -2
- package/src/server/responses/ws-upstream.ts +16 -28
- package/src/server/responses-custom-tool-repair.ts +29 -56
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +37 -10
- package/src/server/system-env-shell.ts +5 -1
- package/src/server/system-env.ts +7 -1
- package/src/server/workflow-refusal.ts +56 -2
- package/src/service/cli.ts +16 -6
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +111 -6
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +9 -1
- package/src/types/tools.ts +14 -0
- package/src/types/wire.ts +9 -1
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
- package/gui/dist/assets/index-C5-RdDmD.css +0 -1
|
@@ -21,11 +21,47 @@
|
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
23
|
sharedSpendLedger,
|
|
24
|
+
spendCeilingsConfigured,
|
|
24
25
|
type SpendReservationLedger,
|
|
25
26
|
type SpendScope,
|
|
26
27
|
type SpendUsage,
|
|
27
28
|
} from "./spend-reservation-ledger";
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* What a token-ceiling refusal has to be able to say.
|
|
32
|
+
*
|
|
33
|
+
* "Budget exhausted" on its own is the failure this repository keeps re-learning: a policy
|
|
34
|
+
* rejection wearing another error's clothing sends an operator to look at the provider. The
|
|
35
|
+
* scope says WHICH ceiling fired -- one task, one account, or the whole pool -- and the limit
|
|
36
|
+
* is the number they would otherwise have to read the journal to recover. The scope ID is
|
|
37
|
+
* deliberately not here: root ids are client thread headers and identity ids are credentials,
|
|
38
|
+
* and the ledger's rule is that neither is written down in the clear.
|
|
39
|
+
*/
|
|
40
|
+
export interface WorkflowSpendDenialDetail {
|
|
41
|
+
readonly scope: SpendScope;
|
|
42
|
+
readonly limit: number;
|
|
43
|
+
/** Tokens the refused reservation would have taken the scope to, where that is known. */
|
|
44
|
+
readonly projected?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Operator-facing name for each scope. What an operator calls it, not what the type calls it. */
|
|
48
|
+
const SPEND_SCOPE_LABEL: Record<SpendScope, string> = {
|
|
49
|
+
root: "task",
|
|
50
|
+
identity: "account",
|
|
51
|
+
pool: "provider pool",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Thousands separators, done here rather than by `toLocaleString`.
|
|
56
|
+
*
|
|
57
|
+
* A ceiling is an eight- or nine-digit number and an unseparated one is genuinely hard to read
|
|
58
|
+
* against the figure beside it. `toLocaleString` would do this too, but its output depends on
|
|
59
|
+
* the ICU data the runtime happens to carry, and a message a test pins must not differ between
|
|
60
|
+
* a developer's machine and a CI image.
|
|
61
|
+
*/
|
|
62
|
+
const formatTokenCount = (tokens: number): string =>
|
|
63
|
+
Math.trunc(tokens).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
64
|
+
|
|
29
65
|
export interface WorkflowBudgetPolicy {
|
|
30
66
|
/** Children admitted concurrently under one root. */
|
|
31
67
|
readonly maxConcurrentChildren: number;
|
|
@@ -172,7 +208,10 @@ export type WorkflowDenial =
|
|
|
172
208
|
* therefore says which ceiling fired AND that no provider was contacted, because that is the
|
|
173
209
|
* first thing an operator needs and the only place left to put it.
|
|
174
210
|
*/
|
|
175
|
-
export function workflowDenialSummary(
|
|
211
|
+
export function workflowDenialSummary(
|
|
212
|
+
reason: WorkflowDenial,
|
|
213
|
+
spend?: WorkflowSpendDenialDetail,
|
|
214
|
+
): { code: string; message: string } {
|
|
176
215
|
switch (reason) {
|
|
177
216
|
case "workflow-sends-exhausted":
|
|
178
217
|
return {
|
|
@@ -197,8 +236,21 @@ export function workflowDenialSummary(reason: WorkflowDenial): { code: string; m
|
|
|
197
236
|
case "workflow-spend-exhausted":
|
|
198
237
|
return {
|
|
199
238
|
code: "workflow_spend_exhausted",
|
|
200
|
-
|
|
201
|
-
|
|
239
|
+
// With the denial in hand the sentence names the ceiling that fired and its number,
|
|
240
|
+
// because the alternative is an operator who can see that something refused and has
|
|
241
|
+
// no way to find out what. Without one -- a caller that knows only the reason -- the
|
|
242
|
+
// original sentence is kept unchanged.
|
|
243
|
+
message: spend
|
|
244
|
+
? "This proxy refused the request locally: the configured " + SPEND_SCOPE_LABEL[spend.scope]
|
|
245
|
+
+ " token ceiling of " + formatTokenCount(spend.limit) + " is spent"
|
|
246
|
+
+ (spend.projected !== undefined
|
|
247
|
+
? " (this send would have taken it to " + formatTokenCount(spend.projected) + ")"
|
|
248
|
+
: "")
|
|
249
|
+
+ ", so no provider was contacted. Spend is durable, so it does not roll forward"
|
|
250
|
+
+ " with the send window: raise or remove spend." + spend.scope
|
|
251
|
+
+ ".maxTokens in config.json to grant more."
|
|
252
|
+
: "This proxy refused the request locally: the task reached a configured token"
|
|
253
|
+
+ " ceiling, so no provider was contacted.",
|
|
202
254
|
};
|
|
203
255
|
case "workflow-tracking-exhausted":
|
|
204
256
|
return {
|
|
@@ -241,6 +293,10 @@ export interface WorkflowBudgetEvent {
|
|
|
241
293
|
readonly rootId: string;
|
|
242
294
|
/** The ceiling that fired. Present for `refused`, absent for `cleared`. */
|
|
243
295
|
readonly reason?: WorkflowDenial;
|
|
296
|
+
/** Which token scope refused, on a spend denial. Absent on every count denial. */
|
|
297
|
+
readonly spendScope?: SpendScope;
|
|
298
|
+
/** That scope's ceiling, so the event is readable without the config open beside it. */
|
|
299
|
+
readonly spendLimit?: number;
|
|
244
300
|
/** Windowed sends at the moment of the event. */
|
|
245
301
|
readonly sends: number;
|
|
246
302
|
/** Windowed distinct children at the moment of the event. */
|
|
@@ -289,6 +345,7 @@ export function recordWorkflowRefusalEvent(
|
|
|
289
345
|
rootId: string | undefined,
|
|
290
346
|
reason: WorkflowDenial,
|
|
291
347
|
now: number = Date.now(),
|
|
348
|
+
spend?: WorkflowSpendDenialDetail,
|
|
292
349
|
): void {
|
|
293
350
|
if (!rootId) return;
|
|
294
351
|
const state = roots.get(rootId);
|
|
@@ -297,6 +354,7 @@ export function recordWorkflowRefusalEvent(
|
|
|
297
354
|
kind: "refused",
|
|
298
355
|
rootId,
|
|
299
356
|
reason,
|
|
357
|
+
...(spend ? { spendScope: spend.scope, spendLimit: spend.limit } : {}),
|
|
300
358
|
sends: state ? windowedSends(state, now) : 0,
|
|
301
359
|
children: state ? windowedChildren(state, now) : 0,
|
|
302
360
|
});
|
|
@@ -323,6 +381,10 @@ export type WorkflowDecision =
|
|
|
323
381
|
rootId: string;
|
|
324
382
|
/** Which spend scope refused, when the denial came from the token ledger. */
|
|
325
383
|
spendScope?: SpendScope;
|
|
384
|
+
/** That scope's configured ceiling, so a caller can say what it was. */
|
|
385
|
+
spendLimit?: number;
|
|
386
|
+
/** Tokens the refused reservation would have taken the scope to, where known. */
|
|
387
|
+
spendProjected?: number;
|
|
326
388
|
};
|
|
327
389
|
|
|
328
390
|
/**
|
|
@@ -424,24 +486,40 @@ export function admitWorkflowTurn(
|
|
|
424
486
|
): WorkflowDecision | undefined {
|
|
425
487
|
if (!rootId) return undefined;
|
|
426
488
|
// An explicit ledger is consulted even without a spend request, so root eviction can
|
|
427
|
-
// still see spend-exhausted entries.
|
|
428
|
-
|
|
489
|
+
// still see spend-exhausted entries. The shared one is resolved whenever a ceiling is
|
|
490
|
+
// CONFIGURED, which is what lets admission refuse an already-spent scope before a body is
|
|
491
|
+
// parsed. An install that configured nothing resolves no ledger, opens no journal, and runs
|
|
492
|
+
// this function exactly as it did before -- the unconfigured path has to stay byte-identical
|
|
493
|
+
// because the ledger is on and journalling by default.
|
|
494
|
+
const ledger = spendLedger ?? (spend || spendCeilingsConfigured() ? sharedSpendLedger() : undefined);
|
|
429
495
|
let state = roots.get(rootId);
|
|
430
496
|
// Every refusal below goes on the record through this one seam. Recording at each return
|
|
431
497
|
// site instead of at the HTTP caller is what makes the record complete: the spend denials
|
|
432
498
|
// are decided inside the ledger branch and never surface as a distinct reason to the caller
|
|
433
499
|
// that formats the response.
|
|
434
|
-
const refuse = (reason: WorkflowDenial,
|
|
500
|
+
const refuse = (reason: WorkflowDenial, denial?: WorkflowSpendDenialDetail): WorkflowDecision => {
|
|
435
501
|
const current = roots.get(rootId);
|
|
436
502
|
recordBudgetEvent({
|
|
437
503
|
at: now,
|
|
438
504
|
kind: "refused",
|
|
439
505
|
rootId,
|
|
440
506
|
reason,
|
|
507
|
+
...(denial ? { spendScope: denial.scope, spendLimit: denial.limit } : {}),
|
|
441
508
|
sends: current ? windowedSends(current, now) : 0,
|
|
442
509
|
children: current ? windowedChildren(current, now) : 0,
|
|
443
510
|
});
|
|
444
|
-
return {
|
|
511
|
+
return {
|
|
512
|
+
admitted: false,
|
|
513
|
+
reason,
|
|
514
|
+
rootId,
|
|
515
|
+
...(denial
|
|
516
|
+
? {
|
|
517
|
+
spendScope: denial.scope,
|
|
518
|
+
spendLimit: denial.limit,
|
|
519
|
+
...(denial.projected !== undefined ? { spendProjected: denial.projected } : {}),
|
|
520
|
+
}
|
|
521
|
+
: {}),
|
|
522
|
+
};
|
|
445
523
|
};
|
|
446
524
|
if (!state) {
|
|
447
525
|
if (roots.size >= policy.maxTrackedRoots && !evictOneRoot(policy, ledger, now)) {
|
|
@@ -469,6 +547,26 @@ export function admitWorkflowTurn(
|
|
|
469
547
|
return refuse("workflow-concurrency-exhausted");
|
|
470
548
|
}
|
|
471
549
|
|
|
550
|
+
// The counts are checked first and the token ceiling second, and the order is deliberate
|
|
551
|
+
// rather than emergent. A count check reads two integers this process already holds; a token
|
|
552
|
+
// check may have to build the ledger and replay its journal. Checking the cheap bound first
|
|
553
|
+
// means the expensive one is never reached for a request the cheap one already refused.
|
|
554
|
+
//
|
|
555
|
+
// The two therefore CAN disagree, and the intersection is what is enforced: a request passes
|
|
556
|
+
// only when every count cap and every token ceiling admits it. A token denial happens before
|
|
557
|
+
// any count is charged, and a count denial happens before any reservation is booked, so
|
|
558
|
+
// neither leaves the other's accounting to unwind. Whichever refuses first is reported as
|
|
559
|
+
// itself -- one refusal is never relabelled as the other, because "sends exhausted" and
|
|
560
|
+
// "spend exhausted" send an operator to two different remedies.
|
|
561
|
+
//
|
|
562
|
+
// A scope whose ceiling is ALREADY spent is refused here rather than at the reservation. The
|
|
563
|
+
// reservation needs a token count, which is not known until the body is parsed and a route
|
|
564
|
+
// resolved; an exhausted scope needs neither and is the cheapest refusal available.
|
|
565
|
+
if (!spend && ledger) {
|
|
566
|
+
const reached = spentRootCeiling(rootId, ledger);
|
|
567
|
+
if (reached) return refuse("workflow-spend-exhausted", reached);
|
|
568
|
+
}
|
|
569
|
+
|
|
472
570
|
if (spend && ledger) {
|
|
473
571
|
const decision = ledger.reserve({
|
|
474
572
|
sendId: spend.sendId,
|
|
@@ -491,7 +589,9 @@ export function admitWorkflowTurn(
|
|
|
491
589
|
: "workflow-spend-exhausted";
|
|
492
590
|
return refuse(
|
|
493
591
|
reason,
|
|
494
|
-
denial.reason === "spend-limit-exceeded"
|
|
592
|
+
denial.reason === "spend-limit-exceeded"
|
|
593
|
+
? { scope: denial.scope, limit: denial.limit, projected: denial.projected }
|
|
594
|
+
: undefined,
|
|
495
595
|
);
|
|
496
596
|
}
|
|
497
597
|
}
|
|
@@ -600,6 +700,43 @@ export function workflowSendCeilingReached(
|
|
|
600
700
|
return state !== undefined && windowedSends(state, now) >= policy.maxPhysicalSends;
|
|
601
701
|
}
|
|
602
702
|
|
|
703
|
+
/**
|
|
704
|
+
* The root scope's ceiling when that scope is already spent, or undefined.
|
|
705
|
+
*
|
|
706
|
+
* Root only: identity and pool are not known until routing has picked an account, so those two
|
|
707
|
+
* refuse at the reservation itself. `exhausted` sums settled spend, open reservations and
|
|
708
|
+
* unresolved spend, which is the same total the reservation compares, so this answers the same
|
|
709
|
+
* question the reservation would -- just without needing the request's token count.
|
|
710
|
+
*/
|
|
711
|
+
function spentRootCeiling(
|
|
712
|
+
rootId: string,
|
|
713
|
+
ledger: SpendReservationLedger,
|
|
714
|
+
): WorkflowSpendDenialDetail | undefined {
|
|
715
|
+
const limit = ledger.policy.root.maxTokens;
|
|
716
|
+
if (limit === undefined) return undefined;
|
|
717
|
+
return ledger.exhausted("root", rootId) ? { scope: "root", limit } : undefined;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* The token ceiling a root has already spent, or undefined when it has room or has none.
|
|
722
|
+
*
|
|
723
|
+
* The count-side twin of {@link workflowSendCeilingReached}, and the responses path calls both
|
|
724
|
+
* at the same seam for the same reason: a refusal decided before dispatch can be reported as
|
|
725
|
+
* ITSELF -- a named ceiling, a synthetic log row, a machine-readable header -- instead of
|
|
726
|
+
* surfacing later as a generic send-budget error from whichever leg happened to run out first.
|
|
727
|
+
*
|
|
728
|
+
* Returns undefined when no ceiling is configured, without resolving a ledger, so an install
|
|
729
|
+
* that never opted in neither pays for this check nor opens a journal because of it.
|
|
730
|
+
*/
|
|
731
|
+
export function workflowSpendCeilingReached(
|
|
732
|
+
rootId: string | undefined,
|
|
733
|
+
spendLedger?: SpendReservationLedger,
|
|
734
|
+
): WorkflowSpendDenialDetail | undefined {
|
|
735
|
+
if (!rootId) return undefined;
|
|
736
|
+
const ledger = spendLedger ?? (spendCeilingsConfigured() ? sharedSpendLedger() : undefined);
|
|
737
|
+
return ledger ? spentRootCeiling(rootId, ledger) : undefined;
|
|
738
|
+
}
|
|
739
|
+
|
|
603
740
|
export interface WorkflowBudgetSnapshot {
|
|
604
741
|
active: number;
|
|
605
742
|
/** Sends inside the window. This is the number the ceiling compares. */
|
|
@@ -13,6 +13,38 @@
|
|
|
13
13
|
import { getCachedProviderAccountQuota, hasPassiveAccountQuota } from "../providers/quota";
|
|
14
14
|
import { getKiroAccountExhaustion } from "../providers/kiro-usage";
|
|
15
15
|
|
|
16
|
+
/** Antigravity hosts Gemini and Claude windows on one account; ranking must not mix them. */
|
|
17
|
+
export type QuotaModelFamily = "gem" | "cla";
|
|
18
|
+
|
|
19
|
+
export function classifyModelFamilyForQuota(
|
|
20
|
+
provider: string,
|
|
21
|
+
modelId?: string | null,
|
|
22
|
+
): QuotaModelFamily | undefined {
|
|
23
|
+
if (provider !== "google-antigravity" || typeof modelId !== "string" || !modelId.trim()) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const id = modelId.toLowerCase();
|
|
27
|
+
// Gemma is not Gemini: a substring/prefix match would poison Gemini ranking.
|
|
28
|
+
if (/(?:^|[^a-z])gemma(?:[^a-z]|$)/.test(id)) return undefined;
|
|
29
|
+
// Catalog ids are gemini-*, never a bare gem- token. Window labels still match Gem via
|
|
30
|
+
// windowMatchesFamily; this classifier is only for request model ids.
|
|
31
|
+
if (/(?:^|[^a-z])gemini(?:[^a-z]|$)/.test(id)) return "gem";
|
|
32
|
+
if (
|
|
33
|
+
/(?:^|[^a-z])claude(?:[^a-z]|$)/.test(id)
|
|
34
|
+
|| /(?:^|[^a-z])opus(?:[^a-z]|$)/.test(id)
|
|
35
|
+
|| /(?:^|[^a-z])sonnet(?:[^a-z]|$)/.test(id)
|
|
36
|
+
|| /(?:^|[^a-z])haiku(?:[^a-z]|$)/.test(id)
|
|
37
|
+
|| /(?:^|[^a-z])gpt[-_]oss(?:[^a-z]|$)/.test(id)
|
|
38
|
+
) return "cla";
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function windowMatchesFamily(label: string, family: QuotaModelFamily): boolean {
|
|
43
|
+
const token = label.trim().split(/[\s(/]+/)[0] ?? "";
|
|
44
|
+
if (family === "gem") return /^gem(?:ini)?$/i.test(token);
|
|
45
|
+
return /^cla(?:ude)?$/i.test(token);
|
|
46
|
+
}
|
|
47
|
+
|
|
16
48
|
/** Lower sorts earlier. Unknown sits between measured-healthy and measured-empty. */
|
|
17
49
|
const RANK_HEALTHY = 0;
|
|
18
50
|
const RANK_UNKNOWN = 1;
|
|
@@ -48,15 +80,24 @@ const PASSIVE_HEADROOM_MAX_AGE_MS = 60 * 60_000;
|
|
|
48
80
|
/**
|
|
49
81
|
* Remaining headroom across every window the provider reports.
|
|
50
82
|
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
function headroomOf(provider: string, accountId: string): number | null {
|
|
83
|
+
* The minimum wins: an account at 5% of its five-hour window is unusable right now even if
|
|
84
|
+
* its monthly allowance is barely touched.
|
|
85
|
+
*/
|
|
86
|
+
function headroomOf(provider: string, accountId: string, requestedModelId?: string | null): number | null {
|
|
55
87
|
const quota = getCachedProviderAccountQuota(provider, accountId);
|
|
56
88
|
if (!quota) return null;
|
|
57
89
|
// Null, not a low rank: this must reproduce "no evidence" so a stale roster degrades to
|
|
58
90
|
// the unranked ring rather than to a differently wrong answer.
|
|
59
91
|
if (hasPassiveAccountQuota(provider) && Date.now() - quota.updatedAt > PASSIVE_HEADROOM_MAX_AGE_MS) return null;
|
|
92
|
+
const family = classifyModelFamilyForQuota(provider, requestedModelId);
|
|
93
|
+
if (family) {
|
|
94
|
+
const percents = (quota.customWindows ?? [])
|
|
95
|
+
.filter(window => windowMatchesFamily(window.label, family))
|
|
96
|
+
.map(window => window.percent)
|
|
97
|
+
.filter((value): value is number => typeof value === "number");
|
|
98
|
+
if (percents.length === 0) return null;
|
|
99
|
+
return 100 - Math.max(...percents);
|
|
100
|
+
}
|
|
60
101
|
const percents = [
|
|
61
102
|
quota.fiveHourPercent,
|
|
62
103
|
quota.weeklyPercent,
|
|
@@ -74,15 +115,23 @@ function headroomOf(provider: string, accountId: string): number | null {
|
|
|
74
115
|
* than an ordering. Null stays null all the way out: a caller must decide what "unmeasured"
|
|
75
116
|
* means for its own rule instead of being handed a fabricated 0 or 100.
|
|
76
117
|
*/
|
|
77
|
-
export function accountHeadroomPercent(
|
|
78
|
-
|
|
118
|
+
export function accountHeadroomPercent(
|
|
119
|
+
provider: string,
|
|
120
|
+
accountId: string,
|
|
121
|
+
requestedModelId?: string | null,
|
|
122
|
+
): number | null {
|
|
123
|
+
return headroomOf(provider, accountId, requestedModelId);
|
|
79
124
|
}
|
|
80
125
|
|
|
81
126
|
/** Unknown usage is not exhaustion; Kiro's explicit overage verdict is authoritative. */
|
|
82
|
-
export function isAccountQuotaExhausted(
|
|
127
|
+
export function isAccountQuotaExhausted(
|
|
128
|
+
provider: string,
|
|
129
|
+
accountId: string,
|
|
130
|
+
requestedModelId?: string | null,
|
|
131
|
+
): boolean {
|
|
83
132
|
const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${accountId}`) : null;
|
|
84
133
|
if (exhaustion !== null) return exhaustion.exhausted;
|
|
85
|
-
const headroom = headroomOf(provider, accountId);
|
|
134
|
+
const headroom = headroomOf(provider, accountId, requestedModelId);
|
|
86
135
|
return headroom !== null && headroom <= 0;
|
|
87
136
|
}
|
|
88
137
|
|
|
@@ -92,24 +141,28 @@ export function isAccountQuotaExhausted(provider: string, accountId: string): bo
|
|
|
92
141
|
* Returns the input untouched when no candidate has quota evidence, which keeps every
|
|
93
142
|
* provider without per-account quota on exactly the behaviour it has today.
|
|
94
143
|
*/
|
|
95
|
-
export function rankAccountsByHeadroom(
|
|
144
|
+
export function rankAccountsByHeadroom(
|
|
145
|
+
provider: string,
|
|
146
|
+
ring: readonly string[],
|
|
147
|
+
requestedModelId?: string | null,
|
|
148
|
+
): string[] {
|
|
96
149
|
if (ring.length < 2) return [...ring];
|
|
97
150
|
|
|
98
151
|
let sawEvidence = false;
|
|
99
152
|
// Same rule as hasHeadroomEvidence: a passive provider's partial roster must not rank
|
|
100
153
|
// at all. The failover path calls this directly (selectFailoverAccount), so the guard
|
|
101
154
|
// cannot live only in the pre-dispatch predicate.
|
|
102
|
-
if (hasPassiveAccountQuota(provider) && !ring.every(id => headroomOf(provider, id) !== null)) {
|
|
155
|
+
if (hasPassiveAccountQuota(provider) && !ring.every(id => headroomOf(provider, id, requestedModelId) !== null)) {
|
|
103
156
|
return [...ring];
|
|
104
157
|
}
|
|
105
158
|
const ranked: Ranked[] = ring.map((id, index) => {
|
|
106
159
|
// A provider-declared exhaustion verdict outranks the percentage: an account may sit at
|
|
107
160
|
// 100% and still be servable when overage is enabled, and the verdict knows that.
|
|
108
161
|
const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${id}`) : null;
|
|
109
|
-
const headroom = headroomOf(provider, id);
|
|
162
|
+
const headroom = headroomOf(provider, id, requestedModelId);
|
|
110
163
|
if (exhaustion !== null || headroom !== null) sawEvidence = true;
|
|
111
164
|
|
|
112
|
-
if (isAccountQuotaExhausted(provider, id)) return { id, bucket: RANK_EXHAUSTED, headroom: 0, index };
|
|
165
|
+
if (isAccountQuotaExhausted(provider, id, requestedModelId)) return { id, bucket: RANK_EXHAUSTED, headroom: 0, index };
|
|
113
166
|
if (headroom === null) return { id, bucket: RANK_UNKNOWN, headroom: 0, index };
|
|
114
167
|
return { id, bucket: RANK_HEALTHY, headroom, index };
|
|
115
168
|
});
|
|
@@ -129,7 +182,11 @@ export function rankAccountsByHeadroom(provider: string, ring: readonly string[]
|
|
|
129
182
|
* told "ranked" when nothing was measured. Pre-dispatch selection asks this first so it
|
|
130
183
|
* can decline to act on a roster it knows nothing about.
|
|
131
184
|
*/
|
|
132
|
-
export function hasHeadroomEvidence(
|
|
185
|
+
export function hasHeadroomEvidence(
|
|
186
|
+
provider: string,
|
|
187
|
+
ids: readonly string[],
|
|
188
|
+
requestedModelId?: string | null,
|
|
189
|
+
): boolean {
|
|
133
190
|
// A PASSIVE provider needs evidence for EVERY candidate, not any one of them.
|
|
134
191
|
//
|
|
135
192
|
// A probe fills the whole roster in one pass (fetchProviderAccountQuotas), so "any"
|
|
@@ -140,10 +197,10 @@ export function hasHeadroomEvidence(provider: string, ids: readonly string[]): b
|
|
|
140
197
|
// AWAY from an unmeasured account and TOWARD the one account known to be spent, which
|
|
141
198
|
// is the exact inversion of what ranking is for.
|
|
142
199
|
if (hasPassiveAccountQuota(provider)) {
|
|
143
|
-
return ids.length > 0 && ids.every(id => headroomOf(provider, id) !== null);
|
|
200
|
+
return ids.length > 0 && ids.every(id => headroomOf(provider, id, requestedModelId) !== null);
|
|
144
201
|
}
|
|
145
202
|
return ids.some(id =>
|
|
146
|
-
headroomOf(provider, id) !== null
|
|
203
|
+
headroomOf(provider, id, requestedModelId) !== null
|
|
147
204
|
|| (provider === "kiro" && getKiroAccountExhaustion(`${provider}\u0000${id}`) !== null));
|
|
148
205
|
}
|
|
149
206
|
/**
|
|
@@ -252,9 +252,10 @@ export abstract class OAuthCallbackFlow {
|
|
|
252
252
|
if (!parsed.code) return null;
|
|
253
253
|
// Kind-aware state enforcement: url/query-shaped input is an authorization
|
|
254
254
|
// RESPONSE and must carry a matching state — missing state is rejected, not
|
|
255
|
-
// downgraded to raw.
|
|
256
|
-
//
|
|
257
|
-
|
|
255
|
+
// downgraded to raw. A raw paste with an explicit code#state suffix is
|
|
256
|
+
// state-bearing too. Only a syntactically raw code WITHOUT a state suffix
|
|
257
|
+
// (same PKCE session) is exempt, so the CLI/GUI paste fallback still works.
|
|
258
|
+
if ((parsed.kind !== "raw" || parsed.state !== undefined) && expectedState && parsed.state !== expectedState) return null;
|
|
258
259
|
return { code: parsed.code, state: parsed.state ?? expectedState };
|
|
259
260
|
})
|
|
260
261
|
.catch((): CallbackResult | null => null),
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
hasHeadroomEvidence,
|
|
23
23
|
isAccountQuotaExhausted,
|
|
24
24
|
rankAccountsByHeadroom,
|
|
25
|
+
classifyModelFamilyForQuota,
|
|
26
|
+
type QuotaModelFamily,
|
|
25
27
|
} from "./account-quota-rank";
|
|
26
28
|
import {
|
|
27
29
|
genericPoolKey,
|
|
@@ -81,13 +83,14 @@ const health = new Map<string, AccountHealth>();
|
|
|
81
83
|
/** Provider -> recent eligible-account count. TTL-bounded; never holds credential material. */
|
|
82
84
|
const presence = new Map<string, PresenceEntry>();
|
|
83
85
|
|
|
84
|
-
const healthKey = (provider: string, accountId: string) =>
|
|
86
|
+
const healthKey = (provider: string, accountId: string, family?: QuotaModelFamily) =>
|
|
87
|
+
family ? `${provider}\u0000${accountId}\u0000${family}` : `${provider}\u0000${accountId}`;
|
|
85
88
|
|
|
86
|
-
function isCooled(provider: string, accountId: string, now: number): boolean {
|
|
87
|
-
const entry = health.get(healthKey(provider, accountId));
|
|
89
|
+
function isCooled(provider: string, accountId: string, now: number, family?: QuotaModelFamily): boolean {
|
|
90
|
+
const entry = health.get(healthKey(provider, accountId, family));
|
|
88
91
|
if (!entry) return false;
|
|
89
92
|
if (entry.cooldownUntil <= now) {
|
|
90
|
-
health.delete(healthKey(provider, accountId));
|
|
93
|
+
health.delete(healthKey(provider, accountId, family));
|
|
91
94
|
return false;
|
|
92
95
|
}
|
|
93
96
|
return true;
|
|
@@ -175,11 +178,11 @@ function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, n
|
|
|
175
178
|
}
|
|
176
179
|
|
|
177
180
|
/** Accounts that may serve traffic right now: not cooled, not flagged for reauth. */
|
|
178
|
-
export function eligibleFailoverAccounts(providerName: string, now = Date.now()): string[] {
|
|
181
|
+
export function eligibleFailoverAccounts(providerName: string, now = Date.now(), family?: QuotaModelFamily): string[] {
|
|
179
182
|
const set = getAccountSet(providerName);
|
|
180
183
|
if (!set) return [];
|
|
181
184
|
return set.accounts
|
|
182
|
-
.filter(account => account.needsReauth !== true && !isCooled(providerName, account.id, now))
|
|
185
|
+
.filter(account => account.needsReauth !== true && !isCooled(providerName, account.id, now, family))
|
|
183
186
|
.map(account => account.id);
|
|
184
187
|
}
|
|
185
188
|
|
|
@@ -230,8 +233,9 @@ function stableGenericRoster(providerName: string): string[] {
|
|
|
230
233
|
* statement about observed usage, and treating "no observation" as "spent" would evacuate every
|
|
231
234
|
* quota-less provider off its active account on the very first request.
|
|
232
235
|
*/
|
|
233
|
-
function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number): boolean {
|
|
234
|
-
|
|
236
|
+
function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number, requestedModelId?: string | null): boolean {
|
|
237
|
+
if (threshold <= 0) return false;
|
|
238
|
+
const headroom = accountHeadroomPercent(providerName, accountId, requestedModelId);
|
|
235
239
|
if (headroom === null) return false;
|
|
236
240
|
return 100 - headroom >= threshold;
|
|
237
241
|
}
|
|
@@ -245,15 +249,17 @@ function pickFillFirstGenericAccount(
|
|
|
245
249
|
providerName: string,
|
|
246
250
|
activeId: string | undefined,
|
|
247
251
|
now: number,
|
|
252
|
+
requestedModelId?: string | null,
|
|
248
253
|
): string | null {
|
|
249
254
|
const stableAll = stableGenericRoster(providerName);
|
|
250
255
|
if (stableAll.length < 2) return null;
|
|
251
|
-
const
|
|
256
|
+
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
257
|
+
const eligible = new Set(eligibleFailoverAccounts(providerName, now, family));
|
|
252
258
|
const stored = config.providers?.[providerName]?.oauthAccountFailover?.autoSwitchThreshold;
|
|
253
259
|
const threshold = typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored <= 100
|
|
254
260
|
? stored
|
|
255
261
|
: DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD;
|
|
256
|
-
if (activeId && eligible.has(activeId) && !isOverAutoSwitchThreshold(providerName, activeId, threshold)) {
|
|
262
|
+
if (activeId && eligible.has(activeId) && !isOverAutoSwitchThreshold(providerName, activeId, threshold, requestedModelId)) {
|
|
257
263
|
return null;
|
|
258
264
|
}
|
|
259
265
|
const start = activeId ? stableAll.indexOf(activeId) : -1;
|
|
@@ -277,11 +283,17 @@ function pickFillFirstGenericAccount(
|
|
|
277
283
|
* advance and round-robin would propose the same account forever. This is the same shape
|
|
278
284
|
* `commitAnthropicSelectionRouting` already commits with.
|
|
279
285
|
*/
|
|
280
|
-
export function noteGenericPoolSelection(
|
|
286
|
+
export function noteGenericPoolSelection(
|
|
287
|
+
config: OcxConfig,
|
|
288
|
+
providerName: string,
|
|
289
|
+
accountId: string,
|
|
290
|
+
requestedModelId?: string | null,
|
|
291
|
+
): void {
|
|
281
292
|
if (activeGenericStrategy(config, providerName) !== "round-robin") return;
|
|
282
293
|
const poolKey = genericPoolKey(providerName);
|
|
283
294
|
const limit = genericStickyLimit(config, providerName);
|
|
284
|
-
const
|
|
295
|
+
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
296
|
+
const picked = pickRoundRobinAccount(poolKey, eligibleFailoverAccounts(providerName, Date.now(), family), limit);
|
|
285
297
|
// The resolver may have admitted a different account than the ring proposed: a removal, a
|
|
286
298
|
// reauth verdict or a manual selection can land during credential resolution. Realign the
|
|
287
299
|
// cursor onto what actually served rather than leaving it on a road not taken.
|
|
@@ -302,6 +314,7 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
302
314
|
failedAccountId: string,
|
|
303
315
|
retryAfterHeader: string | null | undefined,
|
|
304
316
|
now = Date.now(),
|
|
317
|
+
requestedModelId?: string | null,
|
|
305
318
|
): string | null {
|
|
306
319
|
if (!isGenericOAuthFailoverEnabled(config, providerName)) return null;
|
|
307
320
|
const set = getAccountSet(providerName);
|
|
@@ -314,13 +327,14 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
314
327
|
// A Retry-After from upstream still wins — it is the server's own instruction.
|
|
315
328
|
const exhausted = parsed === undefined ? exhaustedCooldownMs(providerName, failedAccountId, now) : null;
|
|
316
329
|
const cooldownMs = exhausted ?? Math.min(parsed ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS);
|
|
317
|
-
|
|
330
|
+
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
331
|
+
health.set(healthKey(providerName, failedAccountId, family), {
|
|
318
332
|
cooldownUntil: now + cooldownMs,
|
|
319
333
|
cooldownSource: parsed ? "retry-after" : "default",
|
|
320
334
|
});
|
|
321
335
|
sweepExpiredOnWrite(now);
|
|
322
336
|
|
|
323
|
-
const eligible = eligibleFailoverAccounts(providerName, now).filter(id => id !== failedAccountId);
|
|
337
|
+
const eligible = eligibleFailoverAccounts(providerName, now, family).filter(id => id !== failedAccountId);
|
|
324
338
|
if (eligible.length === 0) return null;
|
|
325
339
|
// A rotation means the roster in use just changed; do not answer the next activation question
|
|
326
340
|
// from a count read before the failure.
|
|
@@ -359,7 +373,7 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
359
373
|
}
|
|
360
374
|
// With no quota evidence this returns the ring untouched, so providers without
|
|
361
375
|
// per-account quota keep exactly the traversal they have today.
|
|
362
|
-
return rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
|
|
376
|
+
return rankAccountsByHeadroom(providerName, candidates, requestedModelId)[0] ?? null;
|
|
363
377
|
}
|
|
364
378
|
|
|
365
379
|
/**
|
|
@@ -392,6 +406,7 @@ export function preferredInitialAccount(
|
|
|
392
406
|
config: OcxConfig,
|
|
393
407
|
providerName: string,
|
|
394
408
|
now = Date.now(),
|
|
409
|
+
requestedModelId?: string | null,
|
|
395
410
|
): string | null {
|
|
396
411
|
// The PROACTIVE predicate, not the reactive one: this steers a request upstream has not
|
|
397
412
|
// refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it.
|
|
@@ -411,7 +426,8 @@ export function preferredInitialAccount(
|
|
|
411
426
|
// would never reach its own test. Cooldowns and reauth are still honoured inside each pick.
|
|
412
427
|
const strategy = activeGenericStrategy(config, providerName);
|
|
413
428
|
if (strategy === "round-robin") {
|
|
414
|
-
const
|
|
429
|
+
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
430
|
+
const eligibleNow = eligibleFailoverAccounts(providerName, now, family);
|
|
415
431
|
if (eligibleNow.length === 0) return null;
|
|
416
432
|
// PEEK, not pick: this proposal is discardable, and advancing the ring for an account the
|
|
417
433
|
// resolver then rejects would skip a turn for nothing. noteGenericPoolSelection commits.
|
|
@@ -423,26 +439,26 @@ export function preferredInitialAccount(
|
|
|
423
439
|
return picked && picked !== active ? picked : null;
|
|
424
440
|
}
|
|
425
441
|
if (strategy === "fill-first") {
|
|
426
|
-
const picked = pickFillFirstGenericAccount(config, providerName, active, now);
|
|
442
|
+
const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId);
|
|
427
443
|
return picked && picked !== active ? picked : null;
|
|
428
444
|
}
|
|
429
445
|
|
|
430
446
|
const activeRow = selected.accounts.find(account => account.id === active);
|
|
431
447
|
if (activeRow && activeRow.needsReauth !== true
|
|
432
|
-
&& !isCooled(providerName, activeRow.id, now)
|
|
433
|
-
&& !isAccountQuotaExhausted(providerName, activeRow.id)) return null;
|
|
448
|
+
&& !isCooled(providerName, activeRow.id, now, classifyModelFamilyForQuota(providerName, requestedModelId))
|
|
449
|
+
&& !isAccountQuotaExhausted(providerName, activeRow.id, requestedModelId)) return null;
|
|
434
450
|
|
|
435
451
|
// Evidence is required BEFORE eligibility narrows the field. Without this, a provider
|
|
436
452
|
// with no quota data at all could still be redirected: cool the active account with a
|
|
437
453
|
// 429 and the eligible list collapses to one candidate, which any ranking returns
|
|
438
454
|
// unchanged — an answer that looks ranked but was never measured. The no-op guarantee
|
|
439
455
|
// for quota-less providers has to be checked on the full roster.
|
|
440
|
-
if (!hasHeadroomEvidence(providerName, order)) return null;
|
|
456
|
+
if (!hasHeadroomEvidence(providerName, order, requestedModelId)) return null;
|
|
441
457
|
|
|
442
458
|
// Cooldowns are respected here, unlike in the presence count: this picks the account to
|
|
443
459
|
// send to right now, and one inside its 429 window is the single candidate we hold
|
|
444
460
|
// positive evidence against.
|
|
445
|
-
const eligible = order.filter(id => !isCooled(providerName, id, now));
|
|
461
|
+
const eligible = order.filter(id => !isCooled(providerName, id, now, classifyModelFamilyForQuota(providerName, requestedModelId)));
|
|
446
462
|
if (eligible.length === 0) return null;
|
|
447
463
|
|
|
448
464
|
// Start the ring at the active account so an unranked outcome reproduces today's choice.
|
|
@@ -451,7 +467,7 @@ export function preferredInitialAccount(
|
|
|
451
467
|
const candidates = ring.filter(id => eligible.includes(id));
|
|
452
468
|
if (candidates.length === 0) return null;
|
|
453
469
|
|
|
454
|
-
const best = rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
|
|
470
|
+
const best = rankAccountsByHeadroom(providerName, candidates, requestedModelId)[0] ?? null;
|
|
455
471
|
// Nothing to do when the ranking agrees with the account we would have used anyway.
|
|
456
472
|
//
|
|
457
473
|
// A proposal still needs guarded selection commit after credential resolution: a
|
|
@@ -461,12 +477,10 @@ export function preferredInitialAccount(
|
|
|
461
477
|
|
|
462
478
|
/** Earliest remaining cooldown, for a client-facing Retry-After when every account is cooled. */
|
|
463
479
|
export function genericFailoverRetryAfterSeconds(providerName: string, now = Date.now()): number | null {
|
|
464
|
-
const
|
|
465
|
-
if (!set) return null;
|
|
480
|
+
const prefix = `${providerName}\u0000`;
|
|
466
481
|
let earliest: number | null = null;
|
|
467
|
-
for (const
|
|
468
|
-
|
|
469
|
-
if (!entry || entry.cooldownUntil <= now) continue;
|
|
482
|
+
for (const [key, entry] of health) {
|
|
483
|
+
if (!key.startsWith(prefix) || entry.cooldownUntil <= now) continue;
|
|
470
484
|
if (earliest === null || entry.cooldownUntil < earliest) earliest = entry.cooldownUntil;
|
|
471
485
|
}
|
|
472
486
|
return earliest === null ? null : Math.max(1, Math.ceil((earliest - now) / 1000));
|
package/src/oauth/health.ts
CHANGED
|
@@ -202,6 +202,7 @@ export function projectStoredOAuthAccountHealth(
|
|
|
202
202
|
export function projectCodexAccountHealth(input: {
|
|
203
203
|
accountId: string;
|
|
204
204
|
needsReauth: boolean;
|
|
205
|
+
reauthReason?: "unauthorized" | "forbidden" | "refresh_failed";
|
|
205
206
|
now?: number;
|
|
206
207
|
}): OAuthAccountHealth {
|
|
207
208
|
// One read serves every verdict below. Each lookup re-reads and re-hardens the whole store
|
|
@@ -230,6 +231,16 @@ export function projectCodexAccountHealth(input: {
|
|
|
230
231
|
|
|
231
232
|
const needsReauth = input.needsReauth || validationAuthFailed || terminalGrantFailure;
|
|
232
233
|
|
|
234
|
+
// A stored verification failure carries its own cause. When the caller did not name a
|
|
235
|
+
// reason, surface the persisted one instead of flattening every verdict into
|
|
236
|
+
// `refresh_failed`.
|
|
237
|
+
const storedVerificationFailed = validationAuthFailed || terminalGrantFailure;
|
|
238
|
+
const storedFailureReason: "unauthorized" | "forbidden" | undefined = !storedVerificationFailed
|
|
239
|
+
? undefined
|
|
240
|
+
: record?.lastCodexValidationError === "http_status:403" ? "forbidden"
|
|
241
|
+
: record?.lastCodexValidationError === "http_status:401" ? "unauthorized"
|
|
242
|
+
: undefined;
|
|
243
|
+
|
|
233
244
|
// Deferred validation is only worth reporting while the credential itself is still viable. A
|
|
234
245
|
// revoked grant needs a re-login, not a "Refresh quotas" click, so reauth is resolved first.
|
|
235
246
|
if (!needsReauth && record?.codexValidationPending) {
|
|
@@ -239,7 +250,7 @@ export function projectCodexAccountHealth(input: {
|
|
|
239
250
|
const snap = getCodexAccountHealthSnapshot(input.accountId, now);
|
|
240
251
|
return projectOAuthAccountHealth({
|
|
241
252
|
needsReauth,
|
|
242
|
-
reauthReason: needsReauth ? "refresh_failed" : undefined,
|
|
253
|
+
reauthReason: needsReauth ? (input.reauthReason ?? storedFailureReason ?? "refresh_failed") : undefined,
|
|
243
254
|
cooldownUntilMs: snap?.cooldownUntil,
|
|
244
255
|
cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
|
|
245
256
|
now,
|