@bitkyc08/opencodex 2.88.0 → 2.90.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 +11 -1
- package/bin/ocx.mjs +15 -4
- package/gui/dist/assets/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic/cache-retention.ts +117 -0
- package/src/adapters/anthropic/passthrough.ts +7 -4
- package/src/adapters/anthropic.ts +10 -3
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +62 -37
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +22 -13
- package/src/adapters/openai-responses/reasoning.ts +68 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/adapters/registry.ts +1 -1
- package/src/adapters/upstream-http-error.ts +3 -2
- package/src/adapters/zed.ts +7 -4
- package/src/bridge/internal.ts +11 -1
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +10 -4
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +5 -0
- package/src/cli/account-api.ts +13 -1
- package/src/cli/account-extended.ts +17 -5
- package/src/cli/account-key-quota.ts +4 -1
- package/src/cli/account.ts +1 -1
- package/src/cli/capabilities-agents-routing.ts +45 -8
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-provider-models.ts +5 -4
- package/src/cli/combo-result.ts +8 -2
- package/src/cli/combo.ts +17 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/help.ts +1 -1
- package/src/cli/message-command.ts +4 -1
- package/src/cli/message-remote-args.ts +69 -0
- package/src/cli/message-remote-command.ts +77 -0
- package/src/cli/provider-runtime.ts +69 -1
- package/src/cli/registry.ts +3 -3
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +55 -9
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +13 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +57 -10
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/auto-review.ts +2 -1
- package/src/codex/catalog/build-entries.ts +17 -7
- package/src/codex/catalog/derive-entry.ts +3 -1
- package/src/codex/catalog/metadata.ts +2 -0
- package/src/codex/catalog/model-visibility.ts +0 -2
- package/src/codex/catalog/ownership.ts +71 -0
- package/src/codex/catalog/parsing.ts +12 -3
- package/src/codex/catalog/retained-sync.ts +2 -1
- package/src/codex/catalog/routed-removal.ts +4 -4
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/low-quota-desktop-notifier.ts +125 -0
- package/src/codex/low-quota-protection.ts +57 -31
- package/src/codex/main-account-cache.ts +77 -3
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/main-account.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +10 -2
- package/src/combos/jev-model-backend.ts +30 -7
- package/src/combos/jev-model-request.ts +20 -0
- package/src/combos/jev-quota-activation.ts +153 -0
- package/src/combos/jev-quota-config.ts +62 -0
- package/src/combos/jev-quota-route.ts +26 -0
- package/src/combos/jev-quota.ts +88 -0
- package/src/combos/jev.ts +25 -12
- package/src/combos/types.ts +27 -0
- package/src/compatibility/index.ts +3 -0
- package/src/compatibility/openai-responses.ts +8 -1
- package/src/compatibility/opengateway-responses.ts +61 -0
- package/src/config/account-auto-pause-revisions.ts +136 -0
- package/src/config/diagnostics.ts +13 -0
- package/src/config/live-reconcile.ts +9 -0
- package/src/config/persist-unlocked.ts +10 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/config/removed-opencode-free.ts +135 -0
- package/src/config/schema/account-auto-pause.ts +14 -0
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +7 -0
- package/src/config.ts +10 -3
- package/src/generated/compatibility-version.json +476 -216
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +34 -11
- package/src/lib/account-auto-pause-response.ts +16 -0
- package/src/lib/bun-runtime.ts +18 -1
- package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/state-store-registrations.ts +25 -0
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/ssh-argv.ts +3 -0
- package/src/messaging/envelope.ts +6 -3
- package/src/messaging/remote-auth.ts +83 -0
- package/src/messaging/remote-bridge.ts +270 -0
- package/src/messaging/remote-contract.ts +108 -0
- package/src/messaging/remote-enrollment-recovery.ts +59 -0
- package/src/messaging/remote-enrollment.ts +244 -0
- package/src/messaging/remote-envelope.ts +40 -0
- package/src/messaging/remote-files.ts +117 -0
- package/src/messaging/remote-owner.ts +169 -0
- package/src/messaging/remote-ports.ts +56 -0
- package/src/messaging/remote-process.ts +131 -0
- package/src/messaging/remote-replies.ts +98 -0
- package/src/messaging/remote-rpc-admission.ts +32 -0
- package/src/messaging/remote-send.ts +72 -0
- package/src/messaging/remote-store.ts +111 -0
- package/src/messaging/remote-tunnels.ts +65 -0
- package/src/messaging/rpc.ts +13 -1
- package/src/oauth/account-auto-pause-policy.ts +66 -0
- package/src/oauth/account-auto-pause.ts +171 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/account-quota-rank.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +6 -0
- package/src/oauth/anthropic-model-family.ts +8 -0
- package/src/oauth/anthropic-model-quota.ts +2 -6
- package/src/oauth/api-key-pool-preserve.ts +37 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +5 -35
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/store.ts +35 -8
- package/src/oauth/types.ts +2 -0
- package/src/plugins/upstream-hooks.ts +14 -1
- package/src/providers/api-key-pause.ts +32 -0
- package/src/providers/api-key-resolve.ts +10 -4
- package/src/providers/api-key-selection.ts +6 -1
- package/src/providers/api-keys.ts +8 -4
- package/src/providers/claude-cli-usage.ts +25 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +21 -13
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/opencode-zen-rate-limit.ts +6 -72
- package/src/providers/quota/account-cache.ts +15 -1
- package/src/providers/quota/anthropic-account-quota.ts +20 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
- package/src/providers/quota/anthropic-decision-windows.ts +47 -0
- package/src/providers/quota/antigravity.ts +45 -8
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +15 -6
- package/src/providers/quota-decision-publication.ts +77 -0
- package/src/providers/quota-decision-snapshot.ts +145 -0
- package/src/providers/quota-routing-cache.ts +4 -1
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +8 -3
- package/src/providers/registry/entries-extended.ts +14 -53
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +18 -10
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +147 -0
- package/src/routing/compatibility/behavior.ts +4 -1
- package/src/server/auth-cors.ts +7 -0
- package/src/server/background-lifecycle.ts +2 -1
- package/src/server/chat-native.ts +36 -20
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +12 -2
- package/src/server/management/provider-completion-test.ts +289 -0
- package/src/server/management/provider-routes.ts +9 -1
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/messages-native.ts +29 -4
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +23 -9
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +79 -30
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +10 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +10 -5
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +28 -15
- package/src/server/responses/core-combo.ts +34 -5
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +10 -0
- package/src/server/responses/fetch-helpers.ts +65 -14
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +5 -2
- package/src/server/responses/passthrough-dispatch.ts +42 -45
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +92 -5
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +16 -3
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/startup-action-control.ts +2 -2
- package/src/server/startup-health-cache.ts +2 -2
- package/src/server/windows-tray-control.ts +2 -2
- package/src/service/definition.ts +9 -1
- package/src/service/orchestration.ts +17 -0
- package/src/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/provider.ts +11 -5
- package/src/types/request.ts +36 -3
- package/src/types.ts +2 -0
- package/src/usage/cost.ts +17 -1
- package/src/usage/expected-prices.ts +3 -1
- package/src/usage/jev-stats.ts +4 -0
- package/src/usage/log.ts +2 -0
- package/src/usage/summary.ts +7 -0
- package/src/web-search/loop.ts +39 -13
- package/src/web-search/progress-stream.ts +4 -1
- package/src/web-search/run-turn-loop.ts +9 -3
- package/src/web-search/sidecar-search-replay.ts +96 -0
- package/gui/dist/assets/App-CHC89EXQ.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
- package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
package/src/combos/failover.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { parseResetCooldownMs } from "../codex/routing";
|
|
|
2
2
|
import { classifyError, isCyberPolicyCode } from "../lib/errors";
|
|
3
3
|
import { isNonReplayableUpstreamCode } from "../lib/upstream-retry";
|
|
4
4
|
import { isAnthropicBillingFailure } from "../providers/anthropic-billing-refusal";
|
|
5
|
+
import { isKiroAccountProfileRefusal } from "../adapters/kiro-refusal";
|
|
5
6
|
import type { OcxComboTarget } from "../types";
|
|
6
7
|
import { targetKey } from "./types";
|
|
7
8
|
import {
|
|
@@ -666,8 +667,9 @@ function isDefiniteContextOverflowMessage(text: string): boolean {
|
|
|
666
667
|
* arbitrary field that happens to sit in the first 500 bytes authorize a hop, so it fails closed.
|
|
667
668
|
*
|
|
668
669
|
* Only the exact proxy wrapper is unwrapped, within a fixed envelope budget and 16,384 characters.
|
|
670
|
+
* Exported for the sidecar loops, which classify their own provider failures with the same rule.
|
|
669
671
|
*/
|
|
670
|
-
function isDefiniteContextOverflow(status: number, message: string): boolean {
|
|
672
|
+
export function isDefiniteContextOverflow(status: number, message: string): boolean {
|
|
671
673
|
if (!CONTEXT_VERDICT_STATUSES.has(status) && status < 500) return false;
|
|
672
674
|
if (message.length > 16_384) return false;
|
|
673
675
|
let text = message.trim();
|
|
@@ -771,7 +773,7 @@ export function codexAccountModelRefusal(
|
|
|
771
773
|
export function comboFailureDecision(
|
|
772
774
|
status: number,
|
|
773
775
|
message: string,
|
|
774
|
-
options?: { code?: string | null; codexModelRefusal?: CodexAccountModelRefusal },
|
|
776
|
+
options?: { code?: string | null; codexModelRefusal?: CodexAccountModelRefusal; failedAdapter?: string },
|
|
775
777
|
): ComboFailureDecision {
|
|
776
778
|
if (status === 499) return "stop";
|
|
777
779
|
if (message.toLowerCase().includes("origin_rejected")) return "stop";
|
|
@@ -855,6 +857,12 @@ export function comboFailureDecision(
|
|
|
855
857
|
// could have served the request (#4903).
|
|
856
858
|
if (isResponseFormatCapabilityRefusal(status, message, options?.code)) return "hop";
|
|
857
859
|
if (isAnthropicBillingFailure(status, message, options?.code)) return "hop";
|
|
860
|
+
// Kiro's body validator rejects the request for the identity it was routed under. The account
|
|
861
|
+
// layer rotates logins first; this is the no-alternate case, so the next combo target is the
|
|
862
|
+
// only remaining chance to serve the turn. Only a caller that knows the failed target is a Kiro
|
|
863
|
+
// adapter grants it (the words alone prove nothing for another provider); it must precede the
|
|
864
|
+
// generic invalid_request_error stop.
|
|
865
|
+
if (options?.failedAdapter === "kiro" && isKiroAccountProfileRefusal(status, message)) return "hop";
|
|
858
866
|
if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
|
|
859
867
|
return "stop";
|
|
860
868
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { boundedJevQuotaPayload, jevQuotaCandidates, withJevQuotaSummary } from "./jev-quota-route";
|
|
2
|
+
import { JEV_QUOTA_INSTRUCTION } from "./jev-quota";
|
|
3
|
+
import { serializeJevModelRequest, type JevModelRequestBody } from "./jev-model-request";
|
|
1
4
|
import {
|
|
2
5
|
buildJevState,
|
|
3
6
|
candidatesFitRequestBounds,
|
|
@@ -25,11 +28,14 @@ export interface JevModelInvokeRequest {
|
|
|
25
28
|
instructions: string;
|
|
26
29
|
input: string;
|
|
27
30
|
signal: AbortSignal;
|
|
31
|
+
/** Budget-only fallback, selected by the detached invoker before admission or dispatch. */
|
|
32
|
+
withoutQuota?: Pick<JevModelRequestBody, "instructions" | "input">;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export interface JevModelInvokeResult {
|
|
31
36
|
text: string;
|
|
32
37
|
usage?: Record<string, number>;
|
|
38
|
+
quotaOmitted?: true;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export type JevModelInvoke = (request: JevModelInvokeRequest) => Promise<JevModelInvokeResult>;
|
|
@@ -69,29 +75,45 @@ export function parseJevModelChoice(text: string, allowed: ReadonlySet<string>):
|
|
|
69
75
|
return choice;
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
/** Resolve a JEV decision through the decision model, falling back with a gate reason on any failure; quota tiers are advisory input only and never widen the allowlist. */
|
|
72
79
|
export async function resolveJevModelDecision(
|
|
73
80
|
options: ResolveJevDecisionOptions & { decisionModel: string; invokeModel: JevModelInvoke },
|
|
74
81
|
): Promise<JevDecision> {
|
|
75
82
|
const now = options.now ?? Date.now;
|
|
76
83
|
const startedAt = now();
|
|
84
|
+
let candidates = options.candidates;
|
|
85
|
+
/** Build the fallback decision for a failure gate, attaching the quota summary for the candidates in scope. */
|
|
77
86
|
const failed = (gate: Exclude<JevDecision["gate"], "apply">): JevDecision =>
|
|
78
|
-
fallbackDecision(options.fallback, gate, Math.max(0, now() - startedAt), "model");
|
|
87
|
+
withJevQuotaSummary(fallbackDecision(options.fallback, gate, Math.max(0, now() - startedAt), "model"), candidates);
|
|
79
88
|
|
|
80
89
|
if (options.signal?.aborted) throw options.signal.reason;
|
|
81
90
|
if (options.candidates.length === 0) return failed("no_choices");
|
|
82
91
|
if (!candidatesFitRequestBounds(options.candidates)) return failed("invalid");
|
|
83
92
|
|
|
84
93
|
let routeOptions: JevRouteOptionDescriptor[];
|
|
85
|
-
let input
|
|
94
|
+
let input = "";
|
|
95
|
+
let instructions = JEV_MODEL_INSTRUCTIONS;
|
|
96
|
+
let withoutQuota: JevModelInvokeRequest["withoutQuota"];
|
|
86
97
|
try {
|
|
87
98
|
routeOptions = jevRouteOptions(options.candidates);
|
|
88
99
|
if (routeOptions.length > JEV_MODEL_MAX_OPTIONS) return failed("invalid");
|
|
89
100
|
const state = buildJevState(options.body, options.candidates);
|
|
90
101
|
if (!hasJevDecisionState(state)) return failed("no_state");
|
|
91
|
-
|
|
92
|
-
|
|
102
|
+
const payload = boundedJevQuotaPayload(jevQuotaCandidates(options), rows => {
|
|
103
|
+
instructions = JEV_MODEL_INSTRUCTIONS + (rows.some(row => row.quota) ? " " + JEV_QUOTA_INSTRUCTION : "");
|
|
104
|
+
input = buildJevModelPrompt(state, rows);
|
|
105
|
+
if (options.decisionQuotaSignals !== true) return instructions + input;
|
|
106
|
+
const request = { model: options.decisionModel, instructions, input };
|
|
107
|
+
return serializeJevModelRequest(request);
|
|
108
|
+
}, JEV_MAX_REQUEST_BYTES);
|
|
109
|
+
candidates = payload.candidates;
|
|
110
|
+
if (new TextEncoder().encode(payload.body).byteLength > JEV_MAX_REQUEST_BYTES) {
|
|
93
111
|
return failed("invalid");
|
|
94
112
|
}
|
|
113
|
+
if (candidates.some(row => row.quota)) {
|
|
114
|
+
withoutQuota = { instructions: JEV_MODEL_INSTRUCTIONS,
|
|
115
|
+
input: buildJevModelPrompt(state, candidates.map(({ quota: _quota, ...row }) => row)) };
|
|
116
|
+
}
|
|
95
117
|
} catch {
|
|
96
118
|
return failed("invalid");
|
|
97
119
|
}
|
|
@@ -99,7 +121,8 @@ export async function resolveJevModelDecision(
|
|
|
99
121
|
const timeoutSignal = AbortSignal.timeout(jevDecisionTimeoutMs(options.timeoutMs));
|
|
100
122
|
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
101
123
|
try {
|
|
102
|
-
const result = await options.invokeModel({ model: options.decisionModel, instructions
|
|
124
|
+
const result = await options.invokeModel({ model: options.decisionModel, instructions, input, signal, ...(withoutQuota ? { withoutQuota } : {}) });
|
|
125
|
+
if (result.quotaOmitted) candidates = candidates.map(({ quota: _quota, ...row }) => row);
|
|
103
126
|
if (options.signal?.aborted) throw options.signal.reason;
|
|
104
127
|
if (timeoutSignal.aborted) return failed("timeout");
|
|
105
128
|
let choice: string;
|
|
@@ -111,14 +134,14 @@ export async function resolveJevModelDecision(
|
|
|
111
134
|
const selected = routeOptions.find(option => option.key === choice)!;
|
|
112
135
|
const usage = jevUsage({ usage: result.usage });
|
|
113
136
|
if (options.signal?.aborted) throw options.signal.reason;
|
|
114
|
-
return {
|
|
137
|
+
return withJevQuotaSummary({
|
|
115
138
|
backend: "model",
|
|
116
139
|
targetKey: selected.targetKey,
|
|
117
140
|
effort: selected.effort,
|
|
118
141
|
gate: "apply",
|
|
119
142
|
latencyMs: Math.max(0, now() - startedAt),
|
|
120
143
|
...(usage ? { usage } : {}),
|
|
121
|
-
};
|
|
144
|
+
}, candidates);
|
|
122
145
|
} catch (error) {
|
|
123
146
|
if (options.signal?.aborted) throw options.signal.reason;
|
|
124
147
|
if (timeoutSignal.aborted || (error instanceof Error && error.name === "TimeoutError")) return failed("timeout");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The exact detached Responses envelope, shared by quota sizing and the invoker. */
|
|
2
|
+
export const JEV_MODEL_MAX_OUTPUT_TOKENS = 1024;
|
|
3
|
+
export interface JevModelRequestBody {
|
|
4
|
+
model: string;
|
|
5
|
+
instructions: string;
|
|
6
|
+
input: string;
|
|
7
|
+
}
|
|
8
|
+
/** Serialize the streaming decision-model request body, adding the reasoning effort only when one is given. */
|
|
9
|
+
export function serializeJevModelRequest(request: JevModelRequestBody, effort?: string): string {
|
|
10
|
+
return JSON.stringify({
|
|
11
|
+
model: request.model,
|
|
12
|
+
stream: true,
|
|
13
|
+
store: false,
|
|
14
|
+
instructions: request.instructions,
|
|
15
|
+
input: [{ role: "user", content: [{ type: "input_text", text: request.input }] }],
|
|
16
|
+
tools: [],
|
|
17
|
+
max_output_tokens: JEV_MODEL_MAX_OUTPUT_TOKENS,
|
|
18
|
+
...(effort ? { reasoning: { effort } } : {}),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activation owner for JEV advisory quota evidence.
|
|
3
|
+
*
|
|
4
|
+
* The decision snapshot (`src/providers/quota-decision-snapshot.ts`) is imported here and by
|
|
5
|
+
* nothing on the core path. Core credential stores and quota producers call the slot in
|
|
6
|
+
* `src/lib/decision-quota-sink.ts`; this module registers the snapshot-backed sink only while the
|
|
7
|
+
* live config has a JEV combo with `decisionQuotaSignals: true`, and withdraws everything when the
|
|
8
|
+
* last such combo is turned off, deleted or the config root changes.
|
|
9
|
+
*
|
|
10
|
+
* Activation replays the current stores through observe-only reads (`peekAuthStore`,
|
|
11
|
+
* `peekCodexAccountRecordStore`, `peekMainDecisionRow`): no hardening, backup, write, credential
|
|
12
|
+
* resolution or upstream probe, and never on a request's decision path. Stores keep no in-memory
|
|
13
|
+
* cache, so this one bounded read at activation is what "publish once from the loaded stores" costs,
|
|
14
|
+
* paid only by installs that opted in.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
clearDecisionAccountQuotas, invalidateDecisionKeyQuotas, observeDecisionKeyCredential, publishDecisionAccountQuota,
|
|
18
|
+
publishDecisionKeyQuota, publishDecisionQuotaRoster, readLoadedDecisionKeyQuota, readLoadedDecisionQuotaPool,
|
|
19
|
+
setDecisionAccountUsable, withdrawDecisionQuotaRoster,
|
|
20
|
+
} from "../providers/quota-decision-snapshot";
|
|
21
|
+
import { registerDecisionQuotaSink, type DecisionQuotaSink, type MainDecisionRow } from "../lib/decision-quota-sink";
|
|
22
|
+
import { registerLiveConfigObserver } from "../lib/state-store-registrations";
|
|
23
|
+
import { getConfigDir } from "../config/paths";
|
|
24
|
+
import { peekAuthStore } from "../oauth/store";
|
|
25
|
+
import { peekCodexAccountRecordStore } from "../codex/account-store";
|
|
26
|
+
import { peekMainDecisionRow } from "../codex/main-account-cache";
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import type { AuthStore } from "../oauth/store";
|
|
29
|
+
import type { CodexAccountCredentialRecord, OcxConfig } from "../types";
|
|
30
|
+
|
|
31
|
+
const POOL_PROVIDERS = ["anthropic", "codex", "codex-main"] as const;
|
|
32
|
+
|
|
33
|
+
/** Project an auth store the owner already read into the secret-free Anthropic roster (primary pool only); a failure withdraws so the pool reads as unknown. The generation is a one-way digest of the credential, never the credential itself. */
|
|
34
|
+
function publishAuthDecisionQuotaRoster(store: AuthStore): void {
|
|
35
|
+
try {
|
|
36
|
+
publishDecisionQuotaRoster("anthropic", (store.anthropic?.accounts ?? []).map(row => ({
|
|
37
|
+
id: row.id,
|
|
38
|
+
generation: createHash("sha256").update(JSON.stringify([row.credential.refresh, row.credential.access, row.credential.expires])).digest("hex"),
|
|
39
|
+
usable: row.paused !== true && row.needsReauth !== true && Boolean(row.credential.access || row.credential.refresh),
|
|
40
|
+
})));
|
|
41
|
+
} catch {
|
|
42
|
+
try { withdrawDecisionQuotaRoster("anthropic"); } catch { /* unknown */ }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Project a Codex account store the owner already read into the secret-free roster (id, credential generation, usability); a failure withdraws. */
|
|
47
|
+
function publishCodexDecisionQuotaRoster(store: Readonly<Record<string, CodexAccountCredentialRecord>>): void {
|
|
48
|
+
try {
|
|
49
|
+
publishDecisionQuotaRoster("codex", Object.entries(store).map(([id, row]) => ({
|
|
50
|
+
id, generation: row.generation,
|
|
51
|
+
usable: !!row.credential && row.deletedAt == null && !row.codexValidationPending && !row.lastCodexValidationTerminal,
|
|
52
|
+
})));
|
|
53
|
+
} catch {
|
|
54
|
+
try { withdrawDecisionQuotaRoster("codex"); } catch { /* unknown */ }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether a config enables advisory quota signals on at least one JEV combo. */
|
|
59
|
+
export function jevQuotaSignalsWanted(config: Pick<OcxConfig, "combos"> | null | undefined): boolean {
|
|
60
|
+
return Object.values(config?.combos ?? {}).some(combo => combo?.strategy === "jev" && combo.decisionQuotaSignals === true);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Publish the native-main row, or withdraw it; a failed publication withdraws so a previous row cannot linger (same guard as the auth/Codex projections). */
|
|
64
|
+
function publishMainRow(row: MainDecisionRow | undefined): void {
|
|
65
|
+
try {
|
|
66
|
+
if (row) publishDecisionQuotaRoster("codex-main", [{ id: "__main__", generation: row.generation, usable: row.usable }]);
|
|
67
|
+
else withdrawDecisionQuotaRoster("codex-main");
|
|
68
|
+
} catch {
|
|
69
|
+
try { withdrawDecisionQuotaRoster("codex-main"); } catch { /* best-effort; the pool reads as unknown */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const snapshotSink: DecisionQuotaSink = {
|
|
74
|
+
observeKeyCredential: observeDecisionKeyCredential,
|
|
75
|
+
invalidateKeyQuotas: invalidateDecisionKeyQuotas,
|
|
76
|
+
publishKeyQuota: publishDecisionKeyQuota,
|
|
77
|
+
publishAccountQuota: (provider, id, generation, windows, partial) => publishDecisionAccountQuota(provider, id, generation, windows, partial),
|
|
78
|
+
setAccountUsable: setDecisionAccountUsable,
|
|
79
|
+
clearAccountQuotas: clearDecisionAccountQuotas,
|
|
80
|
+
authStoreLoaded: publishAuthDecisionQuotaRoster,
|
|
81
|
+
codexStoreLoaded: publishCodexDecisionQuotaRoster,
|
|
82
|
+
mainRosterChanged: row => publishMainRow(row),
|
|
83
|
+
readKeyQuota: readLoadedDecisionKeyQuota,
|
|
84
|
+
readPool: readLoadedDecisionQuotaPool,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
let active: { detach: () => void; root: string } | undefined;
|
|
88
|
+
|
|
89
|
+
function withdrawAll(): void {
|
|
90
|
+
for (const provider of POOL_PROVIDERS) withdrawDecisionQuotaRoster(provider);
|
|
91
|
+
clearDecisionAccountQuotas();
|
|
92
|
+
invalidateDecisionKeyQuotas();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Replay the current stores into the snapshot through observe-only reads. Each pool fails closed to
|
|
97
|
+
* unknown. Ownership is rechecked after every read: a reentrant notification that deactivates (or
|
|
98
|
+
* restarts) during a read must not be followed by a publication from this stale replay.
|
|
99
|
+
*/
|
|
100
|
+
function replayCurrentStores(owner: object): void {
|
|
101
|
+
const owns = () => active === owner;
|
|
102
|
+
try { const store = peekAuthStore(); if (owns()) publishAuthDecisionQuotaRoster(store); } catch { if (owns()) withdrawDecisionQuotaRoster("anthropic"); }
|
|
103
|
+
try { const store = peekCodexAccountRecordStore(); if (owns()) publishCodexDecisionQuotaRoster(store); } catch { if (owns()) withdrawDecisionQuotaRoster("codex"); }
|
|
104
|
+
try { const row = peekMainDecisionRow(); if (owns()) publishMainRow(row); } catch { if (owns()) { try { withdrawDecisionQuotaRoster("codex-main"); } catch { /* unknown */ } } }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Detach the sink first, so no producer can write while the snapshot is withdrawn. */
|
|
108
|
+
export function deactivateJevQuotaActivation(): void {
|
|
109
|
+
const current = active;
|
|
110
|
+
if (!current) return;
|
|
111
|
+
active = undefined;
|
|
112
|
+
current.detach();
|
|
113
|
+
try { withdrawAll(); } catch { /* the snapshot is unreachable once detached */ }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Converge activation with a live config: register on the first enabling combo, withdraw after the last, and restart on a config root change. Never throws. */
|
|
117
|
+
export function reconcileJevQuotaActivation(config: OcxConfig): void {
|
|
118
|
+
try {
|
|
119
|
+
const wanted = jevQuotaSignalsWanted(config);
|
|
120
|
+
const root = getConfigDir();
|
|
121
|
+
if (active && (!wanted || active.root !== root)) deactivateJevQuotaActivation();
|
|
122
|
+
if (!wanted || active) return;
|
|
123
|
+
const owner = { detach: registerDecisionQuotaSink(snapshotSink), root };
|
|
124
|
+
active = owner;
|
|
125
|
+
replayCurrentStores(owner);
|
|
126
|
+
} catch {
|
|
127
|
+
deactivateJevQuotaActivation();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Whether the snapshot-backed sink is currently registered by this owner. */
|
|
132
|
+
export function jevQuotaActivationActive(): boolean { return active !== undefined; }
|
|
133
|
+
|
|
134
|
+
let stopCurrent: (() => void) | undefined;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Live-owner lifecycle, started by the server's optional-subsystem set once it is serving.
|
|
138
|
+
* Registers the live-config observer and reconciles once with the start-time config; the returned
|
|
139
|
+
* stop unregisters and deactivates. A second start replaces the first.
|
|
140
|
+
*/
|
|
141
|
+
export function startJevQuotaActivation(config: OcxConfig): () => void {
|
|
142
|
+
stopCurrent?.();
|
|
143
|
+
const unregister = registerLiveConfigObserver("jev-quota-activation", reconcileJevQuotaActivation);
|
|
144
|
+
const stop = () => {
|
|
145
|
+
if (stopCurrent !== stop) return;
|
|
146
|
+
stopCurrent = undefined;
|
|
147
|
+
unregister();
|
|
148
|
+
deactivateJevQuotaActivation();
|
|
149
|
+
};
|
|
150
|
+
stopCurrent = stop;
|
|
151
|
+
reconcileJevQuotaActivation(config);
|
|
152
|
+
return stop;
|
|
153
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Import-free quota policy shared by config, CLI and telemetry. */
|
|
2
|
+
export interface JevQuotaTiers {
|
|
3
|
+
moderate?: number;
|
|
4
|
+
limited?: number;
|
|
5
|
+
nearlyExhausted?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ResolvedJevQuotaTiers {
|
|
8
|
+
moderate?: number;
|
|
9
|
+
limited: number;
|
|
10
|
+
nearlyExhausted: number;
|
|
11
|
+
}
|
|
12
|
+
export const JEV_QUOTA_TIERS = ["unknown", "healthy", "moderate", "limited", "nearly_exhausted"] as const;
|
|
13
|
+
export type JevQuotaTier = typeof JEV_QUOTA_TIERS[number];
|
|
14
|
+
export const JEV_QUOTA_DEFAULT_TIERS: ResolvedJevQuotaTiers = { limited: 70, nearlyExhausted: 90 };
|
|
15
|
+
/** Validate quota tier overrides and merge them with the defaults; return undefined for unknown keys, non-finite or out-of-range values, or thresholds that are not strictly ascending. */
|
|
16
|
+
export function normalizeJevQuotaTiers(value: unknown): ResolvedJevQuotaTiers | undefined {
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
18
|
+
const raw = value as Record<string, unknown>;
|
|
19
|
+
if (Object.keys(raw).some(key => !["moderate", "limited", "nearlyExhausted"].includes(key))) return undefined;
|
|
20
|
+
if (Object.keys(raw).some(key => raw[key] === undefined)) return undefined;
|
|
21
|
+
const result = { ...JEV_QUOTA_DEFAULT_TIERS, ...raw };
|
|
22
|
+
const values = [result.moderate, result.limited, result.nearlyExhausted].filter(value => value !== undefined);
|
|
23
|
+
if (values.some(value => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100)) return undefined;
|
|
24
|
+
if (Object.hasOwn(raw, "moderate") && raw.moderate === undefined) return undefined;
|
|
25
|
+
if (values.some((value, i) => i > 0 && value! <= values[i - 1]!)) return undefined;
|
|
26
|
+
return result as ResolvedJevQuotaTiers;
|
|
27
|
+
}
|
|
28
|
+
/** Map a used percentage to a quota tier using the thresholds; a non-finite or out-of-range percentage is unknown. */
|
|
29
|
+
export function jevQuotaTier(percent: number, tiers: ResolvedJevQuotaTiers = JEV_QUOTA_DEFAULT_TIERS): JevQuotaTier {
|
|
30
|
+
if (!Number.isFinite(percent) || percent < 0 || percent > 100) return "unknown";
|
|
31
|
+
if (percent >= tiers.nearlyExhausted) return "nearly_exhausted";
|
|
32
|
+
if (percent >= tiers.limited) return "limited";
|
|
33
|
+
if (tiers.moderate !== undefined && percent >= tiers.moderate) return "moderate";
|
|
34
|
+
return "healthy";
|
|
35
|
+
}
|
|
36
|
+
/** Rank a quota tier for ordering; unknown ties healthy so missing evidence is never treated as exhausted. */
|
|
37
|
+
export function jevQuotaRank(tier: JevQuotaTier): number {
|
|
38
|
+
return tier === "unknown" || tier === "healthy" ? 0 : tier === "moderate" ? 1 : tier === "limited" ? 2 : 3;
|
|
39
|
+
}
|
|
40
|
+
export interface JevQuotaDecisionSummary {
|
|
41
|
+
unknown: number;
|
|
42
|
+
healthy: number;
|
|
43
|
+
moderate: number;
|
|
44
|
+
limited: number;
|
|
45
|
+
nearly_exhausted: number;
|
|
46
|
+
selected?: JevQuotaTier;
|
|
47
|
+
}
|
|
48
|
+
/** Validate a persisted decision quota summary (bounded per-tier counts and an optional selected tier), returning undefined when it is malformed. */
|
|
49
|
+
export function normalizeJevQuotaSummary(value: unknown): JevQuotaDecisionSummary | undefined {
|
|
50
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
51
|
+
const raw = value as Record<string, unknown>;
|
|
52
|
+
const result = { unknown: 0, healthy: 0, moderate: 0, limited: 0, nearly_exhausted: 0 };
|
|
53
|
+
let total = 0;
|
|
54
|
+
for (const tier of JEV_QUOTA_TIERS) {
|
|
55
|
+
const count = raw[tier];
|
|
56
|
+
if (typeof count !== "number" || !Number.isInteger(count) || count < 0 || count > 64) return undefined;
|
|
57
|
+
result[tier] = count; total += count;
|
|
58
|
+
}
|
|
59
|
+
if (total > 64) return undefined;
|
|
60
|
+
const selected = JEV_QUOTA_TIERS.find(tier => tier === raw.selected);
|
|
61
|
+
return { ...result, ...(selected ? { selected } : {}) };
|
|
62
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jevQuotaDecisionSummary, jevQuotaSignalForTarget } from "./jev-quota";
|
|
2
|
+
import { JEV_QUOTA_DEFAULT_TIERS, normalizeJevQuotaTiers } from "./jev-quota-config";
|
|
3
|
+
import type { JevCandidate, JevDecision, ResolveJevDecisionOptions } from "./jev";
|
|
4
|
+
/** Attach a loaded-only quota signal to each candidate when the combo opted in; otherwise return the candidates unchanged. */
|
|
5
|
+
export function jevQuotaCandidates(options: ResolveJevDecisionOptions): readonly JevCandidate[] {
|
|
6
|
+
if (options.decisionQuotaSignals !== true) return options.candidates;
|
|
7
|
+
const tiers = normalizeJevQuotaTiers(options.decisionQuotaTiers) ?? JEV_QUOTA_DEFAULT_TIERS;
|
|
8
|
+
const now = (options.now ?? Date.now)();
|
|
9
|
+
return options.candidates.map(candidate => ({ ...candidate,
|
|
10
|
+
quota: jevQuotaSignalForTarget(options.config, candidate.provider, candidate.model, now, tiers) }));
|
|
11
|
+
}
|
|
12
|
+
/** Attach the per-tier quota summary to a decision when any candidate carried quota evidence. */
|
|
13
|
+
export function withJevQuotaSummary(decision: JevDecision, candidates: readonly JevCandidate[]): JevDecision {
|
|
14
|
+
const quota = jevQuotaDecisionSummary(candidates, decision.targetKey);
|
|
15
|
+
return quota ? { ...decision, quota } : decision;
|
|
16
|
+
}
|
|
17
|
+
/** Retries serialization without quota, never truncates ordinary evidence or the allowlist. */
|
|
18
|
+
export function boundedJevQuotaPayload(candidates: readonly JevCandidate[], build: (rows: readonly JevCandidate[]) => string, limit: number): { body: string; candidates: readonly JevCandidate[] } {
|
|
19
|
+
let rows = candidates;
|
|
20
|
+
let body = build(rows);
|
|
21
|
+
if (new TextEncoder().encode(body).byteLength > limit && rows.some(row => row.quota)) {
|
|
22
|
+
rows = rows.map(({ quota: _quota, ...row }) => row);
|
|
23
|
+
body = build(rows);
|
|
24
|
+
}
|
|
25
|
+
return { body, candidates: rows };
|
|
26
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { anthropicModelFamily } from "../oauth/anthropic-model-family";
|
|
2
|
+
/** Advisory quota only: loaded snapshots, never serving-account selection or hard eligibility. */
|
|
3
|
+
import { resolveAnthropicModelRoute, routeCandidates } from "../oauth/anthropic-model-routes";
|
|
4
|
+
import type { OcxConfig } from "../types";
|
|
5
|
+
import type { DecisionQuotaWindow } from "../providers/quota-decision-snapshot";
|
|
6
|
+
import { readLoadedDecisionKeyQuota, readLoadedDecisionQuotaPool } from "../lib/decision-quota-sink";
|
|
7
|
+
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination";
|
|
8
|
+
import { providerCodexAccountMode } from "../providers/registry";
|
|
9
|
+
import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models";
|
|
10
|
+
import { isSelectableCodexPoolAccount, MAIN_CODEX_ACCOUNT_ID } from "../codex/account-id";
|
|
11
|
+
import { JEV_QUOTA_DEFAULT_TIERS, jevQuotaRank, jevQuotaTier, type JevQuotaTier, type ResolvedJevQuotaTiers, type JevQuotaDecisionSummary } from "./jev-quota-config";
|
|
12
|
+
export const JEV_QUOTA_SIGNAL_MAX_AGE_MS = 30 * 60_000;
|
|
13
|
+
export interface JevQuotaSignal {
|
|
14
|
+
tier: JevQuotaTier;
|
|
15
|
+
usedPercent?: number;
|
|
16
|
+
window?: DecisionQuotaWindow["window"];
|
|
17
|
+
resetsInSeconds?: number;
|
|
18
|
+
}
|
|
19
|
+
/** Build the signal used when evidence is missing, stale or invalid; it ties healthy. */
|
|
20
|
+
const unknown = (): JevQuotaSignal => ({ tier: "unknown" });
|
|
21
|
+
/** Reduce decision quota windows to the worst applicable window for a model, ignoring cold, stale, future, invalid or reset-expired rows. */
|
|
22
|
+
export function jevQuotaSignalFromWindows(windows: readonly DecisionQuotaWindow[] | undefined, model: string, now: number, tiers = JEV_QUOTA_DEFAULT_TIERS): JevQuotaSignal {
|
|
23
|
+
let worst: DecisionQuotaWindow | undefined;
|
|
24
|
+
for (const row of windows ?? []) {
|
|
25
|
+
if (!["5h", "weekly", "monthly"].includes(row.window) && anthropicModelFamily(model)?.toLowerCase() !== row.window) continue;
|
|
26
|
+
if (!Number.isFinite(now) || now < 0 || !Number.isFinite(row.observedAt) || row.observedAt < 0 || row.observedAt > now
|
|
27
|
+
|| now - row.observedAt > JEV_QUOTA_SIGNAL_MAX_AGE_MS || !Number.isFinite(row.percent) || row.percent < 0 || row.percent > 100
|
|
28
|
+
|| row.resetAt !== undefined && (!Number.isFinite(row.resetAt) || row.resetAt < 0 || row.resetAt <= now)) continue;
|
|
29
|
+
if (!worst || row.percent > worst.percent) worst = row;
|
|
30
|
+
}
|
|
31
|
+
if (!worst) return unknown();
|
|
32
|
+
return { tier: jevQuotaTier(worst.percent, tiers), usedPercent: worst.percent, window: worst.window,
|
|
33
|
+
...(worst.resetAt !== undefined ? { resetsInSeconds: Math.min(31_536_000, Math.ceil((worst.resetAt - now) / 1000)) } : {}) };
|
|
34
|
+
}
|
|
35
|
+
/** Unknown ties healthy; stable order within tiers. Exhaustion requires EVERY usable row known. */
|
|
36
|
+
export function jevQuotaPoolSignal(signals: readonly JevQuotaSignal[]): JevQuotaSignal {
|
|
37
|
+
return signals.reduce<JevQuotaSignal | undefined>((best, signal) => !best || jevQuotaRank(signal.tier) < jevQuotaRank(best.tier) ? signal : best, undefined) ?? unknown();
|
|
38
|
+
}
|
|
39
|
+
/** Read only already-loaded evidence for a target provider and model and return its quota signal; never resolves credentials or probes upstream. */
|
|
40
|
+
export function jevQuotaSignalForTarget(config: OcxConfig, provider: string, model: string, now = Date.now(), tiers: ResolvedJevQuotaTiers = JEV_QUOTA_DEFAULT_TIERS): JevQuotaSignal {
|
|
41
|
+
const row = config.providers[provider];
|
|
42
|
+
if (!row || row.disabled) return unknown();
|
|
43
|
+
if (provider === "anthropic" && row.authMode === "oauth" && config.anthropicAccountPool?.enabled === true) {
|
|
44
|
+
const route = resolveAnthropicModelRoute(config, model);
|
|
45
|
+
if (route.error) return unknown();
|
|
46
|
+
const usable = (readLoadedDecisionQuotaPool("anthropic") ?? []).filter(account => account.usable);
|
|
47
|
+
const ids = new Set(routeCandidates(usable.map(account => account.id), route.decision));
|
|
48
|
+
return jevQuotaPoolSignal(usable.filter(account => ids.has(account.id)).map(account => jevQuotaSignalFromWindows(account.windows, model, now, tiers)));
|
|
49
|
+
}
|
|
50
|
+
if (isCanonicalOpenAiForwardProvider(row) && providerCodexAccountMode(provider, row) === "pool") {
|
|
51
|
+
if (model.toLowerCase().split("/").at(-1) === NATIVE_RESERVE_MODEL) return unknown();
|
|
52
|
+
const roster = readLoadedDecisionQuotaPool("codex");
|
|
53
|
+
if (!roster) return unknown();
|
|
54
|
+
const paused = new Set(config.pausedCodexAccountIds ?? []);
|
|
55
|
+
const ids = new Set((config.codexAccounts ?? []).filter(isSelectableCodexPoolAccount).map(account => account.id));
|
|
56
|
+
const loadedIds = new Set(roster.map(account => account.id));
|
|
57
|
+
const signals = roster.filter(account => account.usable && ids.has(account.id) && !paused.has(account.id))
|
|
58
|
+
.map(account => jevQuotaSignalFromWindows(account.windows, model, now, tiers));
|
|
59
|
+
for (const id of ids) if (!loadedIds.has(id) && !paused.has(id)) signals.push(unknown());
|
|
60
|
+
if (!paused.has(MAIN_CODEX_ACCOUNT_ID)) {
|
|
61
|
+
const main = readLoadedDecisionQuotaPool("codex-main")?.[0];
|
|
62
|
+
if (!main || main.usable) signals.push(jevQuotaSignalFromWindows(main?.windows, model, now, tiers));
|
|
63
|
+
}
|
|
64
|
+
return jevQuotaPoolSignal(signals);
|
|
65
|
+
}
|
|
66
|
+
return jevQuotaSignalFromWindows(readLoadedDecisionKeyQuota(provider, row), model, now, tiers);
|
|
67
|
+
}
|
|
68
|
+
/** Render the short natural-language quota clause appended to an option description. */
|
|
69
|
+
export function jevQuotaClause(signal: JevQuotaSignal): string {
|
|
70
|
+
return ` Quota ${signal.tier}${signal.usedPercent !== undefined ? ` (${signal.usedPercent}% of ${signal.window} used)` : ""}.`;
|
|
71
|
+
}
|
|
72
|
+
/** Project a quota signal onto the outbound criterion object: tier, used percentage, window and reset delay only. */
|
|
73
|
+
export function jevQuotaCriterion(signal: JevQuotaSignal): Record<string, unknown> {
|
|
74
|
+
return { tier: signal.tier, ...(signal.usedPercent !== undefined ? { used_percent: signal.usedPercent, window: signal.window } : {}),
|
|
75
|
+
...(signal.resetsInSeconds !== undefined ? { resets_in_seconds: signal.resetsInSeconds } : {}) };
|
|
76
|
+
}
|
|
77
|
+
export const JEV_QUOTA_INSTRUCTION = "Remaining subscription quota is advisory: among adequate options prefer healthier quota; unknown is not exhausted. Never change the target or effort allowlist.";
|
|
78
|
+
/** Count candidate quota tiers and record the selected candidate's tier; undefined when no candidate carried quota evidence. */
|
|
79
|
+
export function jevQuotaDecisionSummary(candidates: readonly { key: string; quota?: JevQuotaSignal }[], selectedKey: string): JevQuotaDecisionSummary | undefined {
|
|
80
|
+
if (!candidates.some(candidate => candidate.quota)) return undefined;
|
|
81
|
+
const summary: JevQuotaDecisionSummary = { unknown: 0, healthy: 0, moderate: 0, limited: 0, nearly_exhausted: 0 };
|
|
82
|
+
for (const candidate of candidates) {
|
|
83
|
+
const tier = candidate.quota?.tier ?? "unknown";
|
|
84
|
+
summary[tier]++;
|
|
85
|
+
if (candidate.key === selectedKey) summary.selected = tier;
|
|
86
|
+
}
|
|
87
|
+
return summary;
|
|
88
|
+
}
|
package/src/combos/jev.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { jevQuotaClause, jevQuotaCriterion, JEV_QUOTA_INSTRUCTION, type JevQuotaSignal } from "./jev-quota";
|
|
2
|
+
import type { JevQuotaTiers, JevQuotaDecisionSummary } from "./jev-quota-config";
|
|
3
|
+
import { boundedJevQuotaPayload, jevQuotaCandidates, withJevQuotaSummary } from "./jev-quota-route";
|
|
4
|
+
import { JEV_MAX_REQUEST_BYTES as REQUEST_LIMIT } from "./jev-service-exchange";
|
|
1
5
|
import type { providerOutboundPost } from "../lib/provider-outbound";
|
|
2
6
|
import type { OcxComboDefaultEffort, OcxConfig } from "../types";
|
|
3
7
|
import {
|
|
@@ -75,6 +79,7 @@ export interface JevCandidate {
|
|
|
75
79
|
reasoningEfforts: readonly OcxComboDefaultEffort[];
|
|
76
80
|
/** Optional operator note sent as decision evidence for this target only. */
|
|
77
81
|
modelProfile?: string;
|
|
82
|
+
quota?: JevQuotaSignal;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
/**
|
|
@@ -98,6 +103,7 @@ export interface JevDecision {
|
|
|
98
103
|
effort: OcxComboDefaultEffort | null;
|
|
99
104
|
gate: "apply" | "missing_key" | "no_choices" | "no_state" | "timeout" | "network" | "redirect" | "http" | "malformed" | "invalid";
|
|
100
105
|
latencyMs: number;
|
|
106
|
+
quota?: JevQuotaDecisionSummary;
|
|
101
107
|
confidence?: number;
|
|
102
108
|
chosenProbability?: number;
|
|
103
109
|
usage?: Record<string, number>;
|
|
@@ -113,6 +119,8 @@ export interface ResolveJevDecisionOptions {
|
|
|
113
119
|
* any other id names a configured `jev-decision` row (for example a self-hosted Ollama `tev1`).
|
|
114
120
|
*/
|
|
115
121
|
decisionProvider?: string;
|
|
122
|
+
decisionQuotaSignals?: boolean;
|
|
123
|
+
decisionQuotaTiers?: JevQuotaTiers;
|
|
116
124
|
/** Request-local authorization of the concrete decision destination, before credential access. */
|
|
117
125
|
isDestinationAllowed?: (providerName: string, modelId: string) => boolean;
|
|
118
126
|
/** Decision deadline; values outside 1000..120000 ms keep the four-second default. */
|
|
@@ -477,11 +485,12 @@ function modelProfile(candidate: JevCandidate): string {
|
|
|
477
485
|
?? "Configured target with capability unspecified by JEV; judge it only from the supplied request evidence.";
|
|
478
486
|
}
|
|
479
487
|
|
|
480
|
-
|
|
488
|
+
/** Describe one allowlisted target and effort option for a decision backend, including the optional advisory quota clause. */
|
|
489
|
+
function criterionDescription(criterion: JevRouteOption["criterion"], quota?: JevQuotaSignal): string {
|
|
481
490
|
const effort = criterion.reasoning_effort
|
|
482
491
|
? `${criterion.reasoning_effort} reasoning effort`
|
|
483
492
|
: "no reasoning-effort control";
|
|
484
|
-
return `Target ${criterion.target} (provider ${criterion.provider}, model ${criterion.model}) with ${effort}
|
|
493
|
+
return `Target ${criterion.target} (provider ${criterion.provider}, model ${criterion.model}) with ${effort}.${quota ? jevQuotaClause(quota) : ""}`;
|
|
485
494
|
}
|
|
486
495
|
|
|
487
496
|
/** One allowlisted target/effort option, shared by every decision backend. */
|
|
@@ -498,7 +507,7 @@ export function jevRouteOptions(candidates: readonly JevCandidate[]): JevRouteOp
|
|
|
498
507
|
key,
|
|
499
508
|
targetKey: option.targetKey,
|
|
500
509
|
effort: option.effort,
|
|
501
|
-
description: criterionDescription(option.criterion),
|
|
510
|
+
description: criterionDescription(option.criterion, candidates.find(candidate => candidate.key === option.targetKey)?.quota),
|
|
502
511
|
}));
|
|
503
512
|
}
|
|
504
513
|
|
|
@@ -516,7 +525,8 @@ export function buildJevRouteQuestion(
|
|
|
516
525
|
const routeOptions = candidateOptions(candidates);
|
|
517
526
|
const criteria: Record<string, unknown> = {};
|
|
518
527
|
for (const [choice, option] of routeOptions) {
|
|
519
|
-
|
|
528
|
+
const quota = candidates.find(candidate => candidate.key === option.targetKey)?.quota;
|
|
529
|
+
criteria[choice] = options.descriptiveCriteria ? criterionDescription(option.criterion, quota) : { ...option.criterion, ...(quota ? { quota: jevQuotaCriterion(quota) } : {}) };
|
|
520
530
|
}
|
|
521
531
|
const modelProfiles: Record<string, string> = {};
|
|
522
532
|
for (const candidate of candidates) modelProfiles[candidate.key] = modelProfile(candidate);
|
|
@@ -531,6 +541,7 @@ export function buildJevRouteQuestion(
|
|
|
531
541
|
model_profiles: modelProfiles,
|
|
532
542
|
effort_profiles: EFFORT_PROFILES,
|
|
533
543
|
speed: "Every option uses standard speed. Fast mode is unavailable.",
|
|
544
|
+
...(candidates.some(candidate => candidate.quota) ? { quota: JEV_QUOTA_INSTRUCTION } : {}),
|
|
534
545
|
},
|
|
535
546
|
criteria,
|
|
536
547
|
},
|
|
@@ -628,6 +639,7 @@ export async function resolveJevDecision(options: ResolveJevDecisionOptions): Pr
|
|
|
628
639
|
if (options.candidates.length === 0) return failed("no_choices");
|
|
629
640
|
if (!candidatesFitRequestBounds(options.candidates)) return failed("invalid");
|
|
630
641
|
|
|
642
|
+
let candidates = options.candidates;
|
|
631
643
|
const exchanged = await exchangeJevDecision(options, (endpoint) => {
|
|
632
644
|
if (endpoint.descriptiveCriteria) {
|
|
633
645
|
// Self-hosted choice questions accept 2..26 options; decide locally instead of spending a
|
|
@@ -638,19 +650,20 @@ export async function resolveJevDecision(options: ResolveJevDecisionOptions): Pr
|
|
|
638
650
|
}
|
|
639
651
|
const state = buildJevState(options.body, options.candidates);
|
|
640
652
|
if (!hasJevDecisionState(state)) return "no_state";
|
|
641
|
-
|
|
642
|
-
model: endpoint.model,
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
653
|
+
const payload = boundedJevQuotaPayload(jevQuotaCandidates(options), rows => JSON.stringify({
|
|
654
|
+
model: endpoint.model, state,
|
|
655
|
+
questions: buildJevRouteQuestion(rows, { descriptiveCriteria: endpoint.descriptiveCriteria }),
|
|
656
|
+
}), REQUEST_LIMIT);
|
|
657
|
+
candidates = payload.candidates;
|
|
658
|
+
return { body: payload.body };
|
|
646
659
|
}, (payload) => parseJevDecision(payload, options.candidates));
|
|
647
|
-
if ("gate" in exchanged) return failed(exchanged.gate);
|
|
648
|
-
return {
|
|
660
|
+
if ("gate" in exchanged) return withJevQuotaSummary(failed(exchanged.gate), candidates);
|
|
661
|
+
return withJevQuotaSummary({
|
|
649
662
|
backend,
|
|
650
663
|
...exchanged.value,
|
|
651
664
|
gate: "apply",
|
|
652
665
|
latencyMs: Math.max(0, now() - startedAt),
|
|
653
|
-
};
|
|
666
|
+
}, candidates);
|
|
654
667
|
}
|
|
655
668
|
|
|
656
669
|
export type JevDecisionProbeResult =
|