@bitkyc08/opencodex 2.82.0 → 2.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/App-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/cursor/catalog.ts +2 -2
- package/src/adapters/cursor/effort-map.ts +1 -1
- package/src/adapters/devin/live-models.ts +2 -2
- package/src/adapters/google-truncation.ts +22 -0
- package/src/adapters/google.ts +7 -3
- package/src/adapters/ollama-native.ts +47 -15
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -0
- package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
- package/src/adapters/openai-responses/reasoning.ts +11 -1
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +26 -1
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +57 -4
- package/src/claude/desktop-picker.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +44 -12
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/resolve.ts +1 -1
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export/model-metadata.ts +16 -0
- package/src/clients/config-export/zcode-store.ts +46 -11
- package/src/clients/config-export/zcode.ts +3 -5
- package/src/clients/config-export.ts +47 -17
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/pool-mode-gate.ts +4 -5
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/auth-context.ts +8 -3
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +17 -3
- package/src/codex/catalog/provider-models.ts +25 -0
- package/src/codex/desktop-app/darwin.ts +1 -1
- package/src/codex/desktop-app/handoff.ts +1 -1
- package/src/codex/desktop-app/linux.ts +1 -1
- package/src/codex/desktop-app/lock.ts +1 -1
- package/src/codex/desktop-app/types.ts +1 -1
- package/src/codex/desktop-app/windows.ts +1 -1
- package/src/codex/desktop-app-restart.ts +1 -1
- package/src/codex/main-account.ts +6 -7
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +373 -217
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/inject.ts +245 -221
- package/src/grok/reset-coupon-ledger.ts +293 -57
- package/src/grok/reset-coupons.ts +15 -6
- package/src/integrations/cursor-detect.ts +1 -1
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/errors.ts +25 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +25 -16
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/meta-muse-device.ts +1 -1
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +3 -1
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-fast.ts +1 -1
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/devin-cli-authmode-migration.ts +1 -1
- package/src/providers/devin-provider-merge-migration.ts +1 -1
- package/src/providers/github-copilot-auto.ts +169 -0
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-presets.ts +1 -1
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/muse-subscription-usage.ts +1 -1
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +20 -20
- package/src/providers/registry/entries-extended.ts +4 -1
- package/src/providers/registry/model-seeds.ts +20 -3
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/providers/xai-fast-model.ts +1 -1
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/inbound-body-admission.ts +24 -7
- package/src/server/index/link-listener.ts +30 -17
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +21 -2
- package/src/server/index.ts +1 -1
- package/src/server/lifecycle.ts +33 -5
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +333 -57
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +92 -390
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +7 -5
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +4 -0
- package/src/server/relay.ts +18 -7
- package/src/server/request-log-failure-attribution.ts +3 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +243 -0
- package/src/server/request-log.ts +15 -118
- package/src/server/responses/adapter-continuation.ts +9 -1
- package/src/server/responses/adapter-dispatch.ts +293 -81
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +129 -50
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-opaque-recovery.ts +5 -5
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core.ts +6 -6
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/empty-completion-guard.ts +18 -2
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/jev-model-invoke.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +130 -64
- package/src/server/responses/passthrough-execution.ts +3 -3
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +131 -13
- package/src/server/responses/run-turn-execution.ts +13 -5
- package/src/server/responses/sidecar-execution.ts +16 -2
- package/src/server/responses/spend-storage-error.ts +33 -0
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/types/request.ts +5 -0
- package/src/usage/expected-prices.ts +5 -1
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
- package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
- package/gui/dist/assets/index-D3FBCNiP.js +0 -94
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put one Anthropic account back into the pool after a settled reset grant.
|
|
3
|
+
*
|
|
4
|
+
* A fresh usage read lifts a 429 quota cooldown whose rejected windows read
|
|
5
|
+
* open, whatever its deadline source (#6989), but a grant should not have to
|
|
6
|
+
* wait for one: the grant itself lifts admission, as far as its evidence goes:
|
|
7
|
+
*
|
|
8
|
+
* - `claimedAt` is when the operation was first journaled, before its first
|
|
9
|
+
* claim was sent. A refusal recorded at or after it may already describe the
|
|
10
|
+
* post-reset account (another request can be refused while the claim is in
|
|
11
|
+
* flight), so it stays.
|
|
12
|
+
* - `cleared` is the window list the canonical settlement reported. The
|
|
13
|
+
* cooldown is lifted only when those windows cover every window its refusal
|
|
14
|
+
* named; an empty or partial list leaves it in place.
|
|
15
|
+
*
|
|
16
|
+
* Whatever the routing outcome (other than a newer refusal), the cached bars
|
|
17
|
+
* are dropped and usage is reread with its own request: joining a read that
|
|
18
|
+
* started before the grant could republish the pre-reset bars, and a fresh
|
|
19
|
+
* reading is what lets usage recovery release a cooldown the cleared
|
|
20
|
+
* list did not cover. The reread is best-effort; the grant is already spent.
|
|
21
|
+
*
|
|
22
|
+
* Model-family exclusions are not cleared here. Only that reread may release
|
|
23
|
+
* one, and only when its payload proves the family is no longer rejected.
|
|
24
|
+
*/
|
|
25
|
+
import { anthropicRoutingFor, type AnthropicQuotaWindow } from "../oauth/anthropic-routing";
|
|
26
|
+
import type { AnthropicInstanceId } from "./anthropic-instance";
|
|
27
|
+
import { fetchAnthropicAccountQuota } from "./quota/anthropic-account-quota";
|
|
28
|
+
import { forgetCachedProviderAccountQuota } from "./quota/account-cache";
|
|
29
|
+
import { anthropicCooldownRecoveryFor } from "./quota/anthropic-cooldown-recovery";
|
|
30
|
+
|
|
31
|
+
export interface AnthropicResetAdmissionEvidence {
|
|
32
|
+
/** Journal time of the operation's first record, written before the first claim was sent. */
|
|
33
|
+
claimedAt: number;
|
|
34
|
+
/** Windows the canonical settlement reported cleared (reset-grant window names). */
|
|
35
|
+
cleared: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const QUOTA_WINDOW_FOR_RESET_WINDOW: Readonly<Record<string, AnthropicQuotaWindow>> = {
|
|
39
|
+
five_hour: "five-hour",
|
|
40
|
+
seven_day: "weekly",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export async function admitAnthropicAccountAfterReset(
|
|
44
|
+
instance: AnthropicInstanceId,
|
|
45
|
+
accountId: string,
|
|
46
|
+
evidence: AnthropicResetAdmissionEvidence,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const cleared = new Set<AnthropicQuotaWindow>();
|
|
49
|
+
for (const window of evidence.cleared) {
|
|
50
|
+
const quotaWindow = QUOTA_WINDOW_FOR_RESET_WINDOW[window];
|
|
51
|
+
if (quotaWindow) cleared.add(quotaWindow);
|
|
52
|
+
}
|
|
53
|
+
const outcome = anthropicRoutingFor(instance).clearAnthropicAccountCooldownForReset(accountId, evidence.claimedAt, cleared);
|
|
54
|
+
if (outcome === "newer") return;
|
|
55
|
+
// Move any usage read that started before the grant onto a stale generation
|
|
56
|
+
// before dropping the cached row, so it cannot republish the pre-reset bars.
|
|
57
|
+
// "cleared" and "uncovered" already advanced the generation in routing (an
|
|
58
|
+
// uncovered entry moved with it, so the reread below can still recover it).
|
|
59
|
+
if (outcome === "absent") anthropicCooldownRecoveryFor(instance).noteAnthropicCooldownMutation(accountId);
|
|
60
|
+
forgetCachedProviderAccountQuota(instance, accountId);
|
|
61
|
+
await fetchAnthropicAccountQuota(instance, accountId, true, undefined, true);
|
|
62
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* ten minutes (`b5o = 600000`) and mints a new one afterwards; this journal keeps
|
|
8
8
|
* the same window.
|
|
9
9
|
*
|
|
10
|
-
* Rules (devlog/
|
|
10
|
+
* Rules (devlog/_fin/260923_claude_reset_grants/010_plan.md, "Ledger contract"):
|
|
11
11
|
* - Every read-modify-write runs synchronously inside a cross-process
|
|
12
12
|
* `BEGIN IMMEDIATE` lock on a sibling SQLite file; nothing awaits inside it,
|
|
13
13
|
* and the upstream claim always runs after the open record is on disk.
|
|
@@ -44,6 +44,8 @@ interface OperationRecord {
|
|
|
44
44
|
status: "open" | "settled";
|
|
45
45
|
code?: string;
|
|
46
46
|
resetsLeft?: number | null;
|
|
47
|
+
/** Windows the settled claim reported cleared; absent on records written before this field. */
|
|
48
|
+
cleared?: string[];
|
|
47
49
|
attempts: number;
|
|
48
50
|
leaseUntil: number;
|
|
49
51
|
createdAt: number;
|
|
@@ -64,7 +66,7 @@ export interface AnthropicResetOperationIdentity {
|
|
|
64
66
|
|
|
65
67
|
export type AnthropicResetBegin =
|
|
66
68
|
| { kind: "execute"; attempt: number }
|
|
67
|
-
| { kind: "replay"; code: string; resetsLeft: number | null; settledAt: number }
|
|
69
|
+
| { kind: "replay"; code: string; resetsLeft: number | null; settledAt: number; claimedAt: number; cleared: string[] }
|
|
68
70
|
| { kind: "identity-mismatch" }
|
|
69
71
|
| { kind: "in-flight" }
|
|
70
72
|
| { kind: "expired" }
|
|
@@ -102,7 +104,9 @@ function isRecordShape(value: unknown): value is OperationRecord {
|
|
|
102
104
|
return typeof record.accountId === "string" && typeof record.grantId === "string"
|
|
103
105
|
&& typeof record.orgDigest === "string" && (record.status === "open" || record.status === "settled")
|
|
104
106
|
&& typeof record.attempts === "number" && typeof record.leaseUntil === "number"
|
|
105
|
-
&& typeof record.createdAt === "number" && typeof record.updatedAt === "number"
|
|
107
|
+
&& typeof record.createdAt === "number" && typeof record.updatedAt === "number"
|
|
108
|
+
&& (record.cleared === undefined
|
|
109
|
+
|| Array.isArray(record.cleared) && record.cleared.every(window => typeof window === "string"));
|
|
106
110
|
}
|
|
107
111
|
|
|
108
112
|
/** Missing file → empty journal. Present but unreadable → throws; never an empty fallback. */
|
|
@@ -197,7 +201,10 @@ export function beginAnthropicResetOperation(
|
|
|
197
201
|
return { kind: "identity-mismatch" };
|
|
198
202
|
}
|
|
199
203
|
if (existing.status === "settled") {
|
|
200
|
-
return {
|
|
204
|
+
return {
|
|
205
|
+
kind: "replay", code: existing.code ?? "unavailable", resetsLeft: existing.resetsLeft ?? null,
|
|
206
|
+
settledAt: existing.updatedAt, claimedAt: existing.createdAt, cleared: existing.cleared ?? [],
|
|
207
|
+
};
|
|
201
208
|
}
|
|
202
209
|
if (existing.leaseUntil > now) return { kind: "in-flight" };
|
|
203
210
|
if (now - existing.createdAt >= ANTHROPIC_RESET_RETRY_WINDOW_MS) return { kind: "expired" };
|
|
@@ -232,6 +239,12 @@ export function beginAnthropicResetOperation(
|
|
|
232
239
|
export interface AnthropicResetSettlement {
|
|
233
240
|
code: string;
|
|
234
241
|
resetsLeft: number | null;
|
|
242
|
+
/** When the canonical (first) settlement was journaled, not when this caller arrived. */
|
|
243
|
+
settledAt: number;
|
|
244
|
+
/** When the operation was first journaled, before its first claim was sent. */
|
|
245
|
+
claimedAt: number;
|
|
246
|
+
/** Windows the canonical settlement reported cleared. */
|
|
247
|
+
cleared: string[];
|
|
235
248
|
}
|
|
236
249
|
|
|
237
250
|
/**
|
|
@@ -241,7 +254,7 @@ export interface AnthropicResetSettlement {
|
|
|
241
254
|
* throws, because an answer with no journal entry cannot be reported as durable.
|
|
242
255
|
*/
|
|
243
256
|
export function settleAnthropicResetOperation(
|
|
244
|
-
settlement: { operationId: string; code: string; resetsLeft: number | null },
|
|
257
|
+
settlement: { operationId: string; code: string; resetsLeft: number | null; cleared?: readonly string[] },
|
|
245
258
|
options: AnthropicResetLedgerOptions = {},
|
|
246
259
|
): AnthropicResetSettlement {
|
|
247
260
|
const now = options.now ?? Date.now();
|
|
@@ -250,14 +263,23 @@ export function settleAnthropicResetOperation(
|
|
|
250
263
|
const journal = readJournal(filePath);
|
|
251
264
|
const existing = journal.operations[settlement.operationId];
|
|
252
265
|
if (!existing) throw new AnthropicResetLedgerError("unavailable");
|
|
253
|
-
if (existing.status === "settled")
|
|
266
|
+
if (existing.status === "settled") {
|
|
267
|
+
return {
|
|
268
|
+
code: existing.code ?? "unavailable", resetsLeft: existing.resetsLeft ?? null,
|
|
269
|
+
settledAt: existing.updatedAt, claimedAt: existing.createdAt, cleared: existing.cleared ?? [],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
254
272
|
existing.status = "settled";
|
|
255
273
|
existing.code = settlement.code;
|
|
256
274
|
existing.resetsLeft = settlement.resetsLeft;
|
|
275
|
+
existing.cleared = [...(settlement.cleared ?? [])];
|
|
257
276
|
existing.leaseUntil = 0;
|
|
258
277
|
existing.updatedAt = now;
|
|
259
278
|
writeJournal(filePath, journal, now);
|
|
260
|
-
return {
|
|
279
|
+
return {
|
|
280
|
+
code: settlement.code, resetsLeft: settlement.resetsLeft, settledAt: now,
|
|
281
|
+
claimedAt: existing.createdAt, cleared: [...existing.cleared],
|
|
282
|
+
};
|
|
261
283
|
});
|
|
262
284
|
}
|
|
263
285
|
|
|
@@ -39,17 +39,25 @@ function defaultEntryFactory(service: string, account: string): ProviderKeychain
|
|
|
39
39
|
let entryFactory: ProviderKeychainEntryFactory = defaultEntryFactory;
|
|
40
40
|
const resolvedCache = new Map<string, string>();
|
|
41
41
|
const warnedAccounts = new Set<string>();
|
|
42
|
+
let resolutionGeneration = 0;
|
|
43
|
+
|
|
44
|
+
/** Advances whenever cached secrets may have changed, so derived snapshots can rebuild. */
|
|
45
|
+
export function providerKeyResolutionGeneration(): number {
|
|
46
|
+
return resolutionGeneration;
|
|
47
|
+
}
|
|
42
48
|
|
|
43
49
|
/** Test seam: swap the OS entry for an in-memory one and drop caches. */
|
|
44
50
|
export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void {
|
|
45
51
|
entryFactory = factory ?? defaultEntryFactory;
|
|
46
52
|
resolvedCache.clear();
|
|
53
|
+
resolutionGeneration++;
|
|
47
54
|
warnedAccounts.clear();
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
/** Write-path seam: a store/restore mutated secrets, so cached reads and warnings are stale. */
|
|
51
58
|
export function invalidateResolvedProviderKeyCache(): void {
|
|
52
59
|
resolvedCache.clear();
|
|
60
|
+
resolutionGeneration++;
|
|
53
61
|
warnedAccounts.clear();
|
|
54
62
|
}
|
|
55
63
|
|
|
@@ -76,7 +84,7 @@ export function providerKeychainEntry(account: string): ProviderKeychainEntry {
|
|
|
76
84
|
return entryFactory(PROVIDER_KEYCHAIN_SERVICE, account);
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
function readKeychain(account: string): string | undefined {
|
|
87
|
+
function readKeychain(account: string, quiet = false): string | undefined {
|
|
80
88
|
const cached = resolvedCache.get(account);
|
|
81
89
|
if (cached !== undefined) return cached;
|
|
82
90
|
try {
|
|
@@ -88,7 +96,7 @@ function readKeychain(account: string): string | undefined {
|
|
|
88
96
|
} catch {
|
|
89
97
|
// fall through to the single warning below
|
|
90
98
|
}
|
|
91
|
-
if (!warnedAccounts.has(account)) {
|
|
99
|
+
if (!quiet && !warnedAccounts.has(account)) {
|
|
92
100
|
warnedAccounts.add(account);
|
|
93
101
|
console.warn(`[opencodex] provider key reference keychain:${account} could not be read from the OS keychain; requests for this provider have no credential until the keychain is available (no plaintext fallback)`);
|
|
94
102
|
}
|
|
@@ -105,6 +113,13 @@ export function resolveProviderApiKey(value: string | undefined): string | undef
|
|
|
105
113
|
return resolveEnvValue(value);
|
|
106
114
|
}
|
|
107
115
|
|
|
116
|
+
/** The same resolution without the unreadable-reference warning, for observe-only callers. */
|
|
117
|
+
export function resolveProviderApiKeyQuietly(value: string | undefined): string | undefined {
|
|
118
|
+
if (!value) return undefined;
|
|
119
|
+
if (isKeychainReference(value)) return readKeychain(keychainAccount(value), true);
|
|
120
|
+
return resolveEnvValue(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none";
|
|
109
124
|
|
|
110
125
|
export function providerKeyStoreKind(provider: Pick<OcxProviderConfig, "apiKey"> | undefined): ProviderKeyStoreKind {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `devin-cli` used to be a local provider whose adapter spawned `devin acp`;
|
|
5
5
|
* it later became an account provider on the shared `devin` adapter, and the
|
|
6
6
|
* registry id itself has since merged into `devin` outright
|
|
7
|
-
* (devlog/
|
|
7
|
+
* (devlog/_fin/260913_devin_provider_merge).
|
|
8
8
|
*
|
|
9
9
|
* Only the adapter repair still lives here. The `authMode` half — rewriting
|
|
10
10
|
* `"local"` to `"oauth"` on the registry-id row — moved into
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Merge the retired `devin-cli` provider id into `devin` at startup
|
|
3
|
-
* (devlog/
|
|
3
|
+
* (devlog/_fin/260913_devin_provider_merge).
|
|
4
4
|
*
|
|
5
5
|
* The two registry entries were the same adapter, api-server, and credential
|
|
6
6
|
* shape — only the login path differed — so `devin-cli` was removed from the
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { OcxParsedRequest, OcxProviderConfig } from "../types";
|
|
3
|
+
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
4
|
+
import { ProviderOutboundSendCancelledError, providerOutboundGet, providerOutboundPost } from "../lib/provider-outbound";
|
|
5
|
+
import { githubCopilotHttpError, resolveCopilotApiBaseUrl } from "../oauth/github-copilot";
|
|
6
|
+
import { resolveGithubCopilotTransport } from "./github-copilot-transport";
|
|
7
|
+
import { waitForProviderRequestSlot, releaseProviderRequestSlot } from "./request-pacing";
|
|
8
|
+
|
|
9
|
+
export interface CopilotModel {
|
|
10
|
+
id: string;
|
|
11
|
+
model_picker_enabled?: boolean;
|
|
12
|
+
supported_endpoints?: string[];
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
const catalogs = new Map<string, { expires: number; models: CopilotModel[] }>();
|
|
16
|
+
const MAX_MODELS = 512;
|
|
17
|
+
const MAX_RESPONSE_BYTES = 1_048_576;
|
|
18
|
+
export class CopilotAutoHttpError extends Error {
|
|
19
|
+
/** Preserve only the upstream status and bounded retry delay; never retain response bodies. */
|
|
20
|
+
constructor(readonly status: number, readonly retryAfter?: string) {
|
|
21
|
+
super(githubCopilotHttpError("Auto negotiation", status).message);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Reject array/scalar payloads before interpreting negotiation objects. */
|
|
25
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
26
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
27
|
+
? value as Record<string, unknown> : undefined;
|
|
28
|
+
}
|
|
29
|
+
/** Accept a bounded, homogeneous list so malformed pools cannot grant routing eligibility. */
|
|
30
|
+
function strings(value: unknown): string[] {
|
|
31
|
+
return Array.isArray(value) && value.length <= MAX_MODELS
|
|
32
|
+
&& value.every(/** Require every pool entry to be a bounded nonempty string before it can authorize model selection. */
|
|
33
|
+
v => typeof v === "string" && v.length > 0 && v.length <= 256)
|
|
34
|
+
? value as string[] : [];
|
|
35
|
+
}
|
|
36
|
+
/** Isolate discovery caches by endpoint and credential without storing plaintext cache keys. */
|
|
37
|
+
function authority(provider: OcxProviderConfig): string {
|
|
38
|
+
return createHash("sha256").update(JSON.stringify([provider.baseUrl, provider.apiKey])).digest("hex");
|
|
39
|
+
}
|
|
40
|
+
/** Make one paced, origin-fenced negotiation send with bounded decoding and secret-safe errors. */
|
|
41
|
+
async function requestJson(provider: OcxProviderConfig, path: string, signal?: AbortSignal, body?: unknown,
|
|
42
|
+
sessionToken?: string, beforeSend?: () => boolean, concurrency = true): Promise<Record<string, unknown>> {
|
|
43
|
+
const boundedSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(8_000)]) : AbortSignal.timeout(8_000);
|
|
44
|
+
boundedSignal.throwIfAborted();
|
|
45
|
+
const headers = new Headers(provider.headers);
|
|
46
|
+
headers.set("Authorization", `Bearer ${provider.apiKey}`);
|
|
47
|
+
headers.set("X-GitHub-Api-Version", "2026-08-01");
|
|
48
|
+
headers.set("Content-Type", "application/json");
|
|
49
|
+
headers.delete("Copilot-Session-Token");
|
|
50
|
+
if (sessionToken) headers.set("Copilot-Session-Token", sessionToken);
|
|
51
|
+
const url = `${provider.baseUrl}${path}`;
|
|
52
|
+
// Queued-dispatch rebuilding can already own a provider concurrency lease; that
|
|
53
|
+
// caller opts out of taking a second lease while retaining interval pacing.
|
|
54
|
+
const slot = await waitForProviderRequestSlot("github-copilot", provider, "auto", boundedSignal, { concurrency });
|
|
55
|
+
try {
|
|
56
|
+
const init = { headers, signal: boundedSignal };
|
|
57
|
+
const response = body === undefined
|
|
58
|
+
? await providerOutboundGet("github-copilot", provider, url, init, { beforeSend })
|
|
59
|
+
: await providerOutboundPost("github-copilot", provider, url, { ...init, body: JSON.stringify(body) }, { beforeSend });
|
|
60
|
+
// Status-only failures: session endpoints can echo secret material in their bodies.
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
void response.body?.cancel().catch(/** Ignore cleanup failures after discarding a refusal body that may contain reflected credentials. */
|
|
63
|
+
() => undefined);
|
|
64
|
+
const retry = response.headers.get("retry-after");
|
|
65
|
+
throw new CopilotAutoHttpError(response.status, retry && /^\d{1,4}$/.test(retry)
|
|
66
|
+
? String(Math.max(1, Math.min(3600, Number(retry)))) : undefined);
|
|
67
|
+
}
|
|
68
|
+
const bounded = await readBoundedResponseBody(response, { maxBytes: MAX_RESPONSE_BYTES,
|
|
69
|
+
signal: boundedSignal, totalTimeoutMs: 8_000, fatalUtf8: true });
|
|
70
|
+
if (bounded.truncated) throw new Error("GitHub Copilot Auto negotiation returned an incomplete response");
|
|
71
|
+
let value: unknown;
|
|
72
|
+
try { value = JSON.parse(bounded.text); } catch { /* never include raw bodies */ }
|
|
73
|
+
const payload = record(value);
|
|
74
|
+
if (!payload) throw new Error("GitHub Copilot Auto negotiation returned invalid JSON");
|
|
75
|
+
return payload;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error instanceof CopilotAutoHttpError || error instanceof ProviderOutboundSendCancelledError) throw error;
|
|
78
|
+
// Transport/decoder exception messages may contain reflected header values.
|
|
79
|
+
throw new Error(boundedSignal.aborted ? "GitHub Copilot Auto negotiation cancelled"
|
|
80
|
+
: "GitHub Copilot Auto negotiation transport failed");
|
|
81
|
+
} finally { releaseProviderRequestSlot(slot); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Detect Auto-only permission only when every row explicitly denies named selection. */
|
|
85
|
+
export function copilotRequiresAuto(_provider: OcxProviderConfig, models: CopilotModel[]): boolean {
|
|
86
|
+
return models.length > 0 && models.every(/** Missing or malformed metadata cannot establish an account-wide picker denial. */
|
|
87
|
+
model => model.model_picker_enabled === false);
|
|
88
|
+
}
|
|
89
|
+
/** Retain legacy rows with unknown permissions; only an explicit denial removes a named picker row. */
|
|
90
|
+
export function copilotPickerModels(provider: OcxProviderConfig, models: CopilotModel[]): CopilotModel[] {
|
|
91
|
+
if (copilotRequiresAuto(provider, models)) return [{ id: "auto" }];
|
|
92
|
+
return models.filter(/** Preserve named selection unless the provider explicitly marks the row unavailable. */
|
|
93
|
+
model => model.model_picker_enabled !== false);
|
|
94
|
+
}
|
|
95
|
+
/** Read the credential-bound catalog; callers may disable caching or reuse an existing lease. */
|
|
96
|
+
export async function fetchCopilotAutoModels(provider: OcxProviderConfig, signal?: AbortSignal,
|
|
97
|
+
beforeSend?: () => boolean, cacheTtlMs = 60_000, concurrency = true): Promise<CopilotModel[]> {
|
|
98
|
+
const key = authority(provider);
|
|
99
|
+
const cached = catalogs.get(key);
|
|
100
|
+
if (cacheTtlMs > 0 && cached && cached.expires > Date.now()) return cached.models;
|
|
101
|
+
const payload = await requestJson(provider, "/models", signal, undefined, undefined, beforeSend, concurrency);
|
|
102
|
+
if (!Array.isArray(payload.data) || payload.data.length > MAX_MODELS)
|
|
103
|
+
throw new Error("GitHub Copilot model discovery returned an invalid catalog");
|
|
104
|
+
const models = payload.data.flatMap(/** Discard malformed catalog rows while retaining metadata for bounded model identifiers. */
|
|
105
|
+
value => {
|
|
106
|
+
const row = record(value);
|
|
107
|
+
if (!row || typeof row.id !== "string" || !row.id || row.id.length > 256) return [];
|
|
108
|
+
return [{ ...row, id: row.id } as CopilotModel];
|
|
109
|
+
});
|
|
110
|
+
if (cacheTtlMs > 0) {
|
|
111
|
+
if (!catalogs.has(key) && catalogs.size >= 32) catalogs.delete(catalogs.keys().next().value!);
|
|
112
|
+
catalogs.set(key, { expires: Date.now() + Math.min(cacheTtlMs, 60_000), models });
|
|
113
|
+
}
|
|
114
|
+
return models;
|
|
115
|
+
}
|
|
116
|
+
/** Reset discovery state between isolated account/permission regression cases. */
|
|
117
|
+
export function clearCopilotAutoModelsForTests(): void { catalogs.clear(); }
|
|
118
|
+
|
|
119
|
+
/** Resolve before adapter construction: the server can choose either wire on every turn. */
|
|
120
|
+
export async function resolveCopilotAuto(provider: OcxProviderConfig, requestedModel: string,
|
|
121
|
+
parsed: OcxParsedRequest, signal?: AbortSignal, beforeSend?: () => boolean, concurrency = true): Promise<{
|
|
122
|
+
provider: OcxProviderConfig; modelId: string; auto: boolean; expiresAt?: number;
|
|
123
|
+
}> {
|
|
124
|
+
const transport = resolveGithubCopilotTransport(provider, provider.authMode === "oauth"
|
|
125
|
+
? resolveCopilotApiBaseUrl(provider.baseUrl) : undefined);
|
|
126
|
+
// Never inherit a session token from a previous account or persist it in config.
|
|
127
|
+
const cleanHeaders = new Headers(transport.headers);
|
|
128
|
+
cleanHeaders.delete("Copilot-Session-Token");
|
|
129
|
+
transport.headers = Object.fromEntries(cleanHeaders);
|
|
130
|
+
let models: CopilotModel[];
|
|
131
|
+
try { models = await fetchCopilotAutoModels(transport, signal, beforeSend, 60_000, concurrency); }
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (signal?.aborted || error instanceof ProviderOutboundSendCancelledError) throw error;
|
|
134
|
+
// Permission discovery is additive for legacy paid configurations. A temporary
|
|
135
|
+
// discovery outage must not turn a previously usable named route into a refusal.
|
|
136
|
+
return { provider: transport, modelId: requestedModel, auto: false };
|
|
137
|
+
}
|
|
138
|
+
if (!copilotRequiresAuto(provider, models))
|
|
139
|
+
return { provider: transport, modelId: requestedModel, auto: false };
|
|
140
|
+
const session = await requestJson(transport, "/models/session", signal,
|
|
141
|
+
{ auto_mode: { model_hints: ["auto"] } }, undefined, beforeSend, concurrency);
|
|
142
|
+
const pool = strings(session.available_models);
|
|
143
|
+
if (typeof session.session_token !== "string" || !session.session_token || session.session_token.length > 32_768 || !/^[\x21-\x7e]+$/.test(session.session_token) || !pool.length)
|
|
144
|
+
throw new Error("GitHub Copilot Auto session omitted its routing credentials");
|
|
145
|
+
if (session.expires_at !== undefined && (typeof session.expires_at !== "number" || !Number.isFinite(session.expires_at)))
|
|
146
|
+
throw new Error("GitHub Copilot Auto session returned an invalid expiry");
|
|
147
|
+
const expiresAt = typeof session.expires_at === "number" && Number.isFinite(session.expires_at)
|
|
148
|
+
? session.expires_at * 1000 : Date.now() + 60_000;
|
|
149
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now() + 1000) throw new Error("GitHub Copilot Auto session is already expired");
|
|
150
|
+
const lastUser = [...parsed.context.messages].reverse().find(/** Find the newest user turn as the intent-routing source instead of replaying earlier conversation text. */
|
|
151
|
+
message => message.role === "user");
|
|
152
|
+
const prompt = lastUser?.role === "user"
|
|
153
|
+
? typeof lastUser.content === "string" ? lastUser.content : lastUser.content.flatMap(/** Extract text parts for intent negotiation without forwarding image or other multimodal payloads. */
|
|
154
|
+
part => part.type === "text" ? [part.text] : []).join("\n")
|
|
155
|
+
: "";
|
|
156
|
+
const intent = await requestJson(transport, "/models/session/intent", signal,
|
|
157
|
+
{ prompt: prompt.slice(0, 32_768), available_models: pool }, session.session_token, beforeSend, concurrency);
|
|
158
|
+
const modelId = strings(intent.candidate_models).find(/** Choose the first server-ranked candidate that belongs to this credential-bound session pool. */
|
|
159
|
+
candidate => pool.includes(candidate));
|
|
160
|
+
if (!modelId) throw new Error("GitHub Copilot Auto intent returned no eligible model");
|
|
161
|
+
const endpoints = strings(models.find(/** Read endpoint capabilities for the selected model before choosing its inference adapter. */
|
|
162
|
+
model => model.id === modelId)?.supported_endpoints);
|
|
163
|
+
const adapter = endpoints.includes("/responses") ? "openai-responses"
|
|
164
|
+
: endpoints.includes("/chat/completions") ? "openai-chat" : undefined;
|
|
165
|
+
if (!adapter) throw new Error("GitHub Copilot Auto selected a model with no supported inference endpoint");
|
|
166
|
+
return { auto: true, modelId, expiresAt: Math.min(expiresAt, Date.now() + 60_000), provider: { ...transport, adapter,
|
|
167
|
+
...(adapter === "openai-responses" ? { responsesPath: "/responses" } : { chatCompletionsPath: "/chat/completions" }),
|
|
168
|
+
headers: { ...transport.headers, "X-GitHub-Api-Version": "2026-08-01", "Copilot-Session-Token": session.session_token } } };
|
|
169
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools.
|
|
10
10
|
*/
|
|
11
|
-
import { commitProviderApiKeySelection } from "./api-key-selection";
|
|
11
|
+
import { commitProviderApiKeySelection, providerApiKeySelectionIsCurrent } from "./api-key-selection";
|
|
12
12
|
import type { ProviderApiKeySelection } from "../types/provider";
|
|
13
13
|
import { routedProviderConfig } from "../router";
|
|
14
14
|
import { getProviderRegistryEntry } from "./registry";
|
|
@@ -30,6 +30,16 @@ interface KeyCooldown {
|
|
|
30
30
|
|
|
31
31
|
const DEFAULT_COOLDOWN_MS = 60_000;
|
|
32
32
|
const MAX_COOLDOWN_MS = 10 * 60_000; // cap at 10 min for api-key rotation
|
|
33
|
+
/**
|
|
34
|
+
* Anthropic credit-balance refusal (#6958). An empty prepaid balance is account state, not a
|
|
35
|
+
* rate signal: it clears only when someone buys credits or auto-reload fires, so the
|
|
36
|
+
* ten-minute credential cap would hand a dead key back to rotation six times an hour. One hour
|
|
37
|
+
* keeps it out of replacement and proactive selection while a funded sibling exists, and still
|
|
38
|
+
* notices a top-up without a restart. When every key is held, requests keep using the active
|
|
39
|
+
* key and return Anthropic's original refusal. The hold is in memory; dashboard key management
|
|
40
|
+
* clears it.
|
|
41
|
+
*/
|
|
42
|
+
export const ANTHROPIC_BILLING_COOLDOWN_MS = 60 * 60_000;
|
|
33
43
|
|
|
34
44
|
/**
|
|
35
45
|
* Cap for a cooldown the upstream itself dated, as opposed to one we inferred.
|
|
@@ -688,12 +698,13 @@ export function rateLimitRetryDelayMs(
|
|
|
688
698
|
function rotateKeyAfterFailure(
|
|
689
699
|
config: OcxConfig,
|
|
690
700
|
providerName: string,
|
|
691
|
-
failureStatus: 401 | 429,
|
|
701
|
+
failureStatus: 401 | 429 | "billing",
|
|
692
702
|
retryAfterHeader: string | null | undefined,
|
|
693
703
|
now = Date.now(),
|
|
694
704
|
attemptedKey?: string,
|
|
695
705
|
attemptedSelection?: ProviderApiKeySelection,
|
|
696
706
|
quotaResetAt?: number,
|
|
707
|
+
acceptsFreshProvider?: (provider: OcxProviderConfig) => boolean,
|
|
697
708
|
): OcxProviderConfig | null {
|
|
698
709
|
const provider = config.providers[providerName];
|
|
699
710
|
if (!provider) return null;
|
|
@@ -704,6 +715,11 @@ function rotateKeyAfterFailure(
|
|
|
704
715
|
| { failedId?: string; candidateId?: string }
|
|
705
716
|
| { exhaustedCount: number; failedId?: string };
|
|
706
717
|
const outcome = commitProviderApiKeySelection<Rotation | null>(config, providerName, freshProvider => {
|
|
718
|
+
// Run on every persisted transaction attempt, including a freshness rebase.
|
|
719
|
+
if (acceptsFreshProvider) {
|
|
720
|
+
try { if (!acceptsFreshProvider(freshProvider)) return { changed: false, value: null }; }
|
|
721
|
+
catch { return { changed: false, value: null }; }
|
|
722
|
+
}
|
|
707
723
|
const pool = freshProvider.apiKeyPool;
|
|
708
724
|
if (!pool || pool.length < 2) return { changed: false, value: null };
|
|
709
725
|
|
|
@@ -741,6 +757,8 @@ function rotateKeyAfterFailure(
|
|
|
741
757
|
}, attemptedSelection);
|
|
742
758
|
if (outcome.status === "unavailable") return null;
|
|
743
759
|
if (outcome.status === "superseded") {
|
|
760
|
+
// Billing authority is bound to the sent selection; a newer manual choice grants no replay.
|
|
761
|
+
if (acceptsFreshProvider) return null;
|
|
744
762
|
// A newer manual selection (including A→B→A) owns subsequent dispatch. Reusing the
|
|
745
763
|
// same failed key here would loop forever; preserve its original failure instead.
|
|
746
764
|
return outcome.provider.apiKey !== failedKey ? structuredClone(outcome.provider) : null;
|
|
@@ -750,7 +768,9 @@ function rotateKeyAfterFailure(
|
|
|
750
768
|
// A 401 is a verdict about the credential itself, not a timing signal: the key is rejected
|
|
751
769
|
// until an operator replaces it, and upstreams send no Retry-After for it. Hold it for the
|
|
752
770
|
// full cap instead of the 429 default so a dead key is not re-tried once a minute.
|
|
753
|
-
const cooldownMs = failureStatus ===
|
|
771
|
+
const cooldownMs = failureStatus === "billing"
|
|
772
|
+
? ANTHROPIC_BILLING_COOLDOWN_MS
|
|
773
|
+
: failureStatus === 401
|
|
754
774
|
? MAX_COOLDOWN_MS
|
|
755
775
|
// A reset instant the upstream dated outranks both the header and the
|
|
756
776
|
// default: it is the only one of the three that knows when the quota
|
|
@@ -762,7 +782,7 @@ function rotateKeyAfterFailure(
|
|
|
762
782
|
sweepExpiredOnWrite(now);
|
|
763
783
|
}
|
|
764
784
|
if ("exhaustedCount" in outcome.value) {
|
|
765
|
-
console.warn(`[key-failover] ${providerName}: all ${outcome.value.exhaustedCount} keys in cooldown after ${failureStatus}; returning the upstream status to the client`);
|
|
785
|
+
console.warn(`[key-failover] ${providerName}: all ${outcome.value.exhaustedCount} keys in cooldown after ${failureLabel(failureStatus)}; returning the upstream status to the client`);
|
|
766
786
|
return null;
|
|
767
787
|
}
|
|
768
788
|
|
|
@@ -771,12 +791,16 @@ function rotateKeyAfterFailure(
|
|
|
771
791
|
if (outcome.value.candidateId) {
|
|
772
792
|
console.warn(
|
|
773
793
|
// Log ids only — labels are user-supplied free text and could carry secret material.
|
|
774
|
-
`[key-failover] ${providerName}: ${failureStatus} on key ${outcome.value.failedId ?? "?"}; rotating to key ${outcome.value.candidateId}`,
|
|
794
|
+
`[key-failover] ${providerName}: ${failureLabel(failureStatus)} on key ${outcome.value.failedId ?? "?"}; rotating to key ${outcome.value.candidateId}`,
|
|
775
795
|
);
|
|
776
796
|
}
|
|
777
797
|
return structuredClone(committed);
|
|
778
798
|
}
|
|
779
799
|
|
|
800
|
+
function failureLabel(failureStatus: 401 | 429 | "billing"): string {
|
|
801
|
+
return failureStatus === "billing" ? "credit-balance refusal" : String(failureStatus);
|
|
802
|
+
}
|
|
803
|
+
|
|
780
804
|
export function rotateKeyOn429(
|
|
781
805
|
config: OcxConfig,
|
|
782
806
|
providerName: string,
|
|
@@ -865,6 +889,29 @@ export function rotateProviderTransportOn401(
|
|
|
865
889
|
return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey);
|
|
866
890
|
}
|
|
867
891
|
|
|
892
|
+
/** Exact Anthropic billing refusal: cool the key for ANTHROPIC_BILLING_COOLDOWN_MS. */
|
|
893
|
+
export function rotateProviderTransportOnAnthropicBilling(
|
|
894
|
+
config: OcxConfig,
|
|
895
|
+
providerName: string,
|
|
896
|
+
routedProvider: OcxProviderTransport,
|
|
897
|
+
options: Omit<RotateProviderTransportOptions, "retryAfter"> = {},
|
|
898
|
+
): OcxProviderTransport | null {
|
|
899
|
+
if (routedProvider.adapter !== "anthropic"
|
|
900
|
+
|| (routedProvider.authMode !== undefined && routedProvider.authMode !== "key")
|
|
901
|
+
|| !hasKeyPoolFailover(routedProvider)) return null;
|
|
902
|
+
const headerShape = (provider: OcxProviderConfig) => JSON.stringify(Object.entries(provider.headers ?? {}).sort(([a], [b]) => a.localeCompare(b)));
|
|
903
|
+
const sentHeaders = headerShape(routedProvider);
|
|
904
|
+
const acceptsFreshProvider = (fresh: OcxProviderConfig): boolean => fresh.adapter === routedProvider.adapter
|
|
905
|
+
&& (fresh.authMode === undefined || fresh.authMode === "key") && hasKeyPoolFailover(fresh)
|
|
906
|
+
&& (fresh.apiKeyTransport ?? "x-api-key") === (routedProvider.apiKeyTransport ?? "x-api-key")
|
|
907
|
+
&& headerShape(fresh) === sentHeaders
|
|
908
|
+
&& providerApiKeySelectionIsCurrent({ ...config, providers: { ...config.providers, [providerName]: fresh } },
|
|
909
|
+
providerName, routedProvider);
|
|
910
|
+
const rotated = rotateKeyAfterFailure(config, providerName, "billing", null, options.now,
|
|
911
|
+
options.attemptedKey, options.attemptedSelection ?? routedProvider._apiKeyAttempt, undefined, acceptsFreshProvider);
|
|
912
|
+
return rotated ? applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey) : null;
|
|
913
|
+
}
|
|
914
|
+
|
|
868
915
|
function applyRotatedTransport(
|
|
869
916
|
providerName: string,
|
|
870
917
|
routedProvider: OcxProviderTransport,
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* The preset is a SEED, not a lock. Materialization evaluates these rules against the provider's
|
|
13
13
|
* current catalog and stores CONCRETE ids in `selectedModels`, so the visibility hot path
|
|
14
14
|
* (`filterCatalogVisibleModels`) never learns about patterns and an older binary still sees a
|
|
15
|
-
* plain allowlist. Design: devlog/
|
|
15
|
+
* plain allowlist. Design: devlog/_fin/260824_model_ux_aliases_and_defaults/030_default_preset.md
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { OcxProviderConfig } from "../types";
|
|
@@ -3,6 +3,7 @@ import { projectModelRenames } from "./model-rename-migration";
|
|
|
3
3
|
import { projectStaleContextWindows } from "./stale-context-window-migration";
|
|
4
4
|
import { projectStaleModelRosters } from "./stale-model-roster-migration";
|
|
5
5
|
import { projectStaleVisionClassifications } from "./stale-vision-classification-migration";
|
|
6
|
+
import { projectStaleNoVisionSeeds } from "./stale-no-vision-seed-migration";
|
|
6
7
|
import { projectDevinCliAuthMode } from "./devin-cli-authmode-migration";
|
|
7
8
|
import type { OcxConfig } from "../types";
|
|
8
9
|
|
|
@@ -17,12 +18,13 @@ export function projectStartupConfigRepairs(config: OcxConfig): ReturnType<typeo
|
|
|
17
18
|
const renames = projectModelRenames(config);
|
|
18
19
|
const windows = projectStaleContextWindows(renames.config);
|
|
19
20
|
const vision = projectStaleVisionClassifications(windows.config);
|
|
20
|
-
const
|
|
21
|
+
const noVision = projectStaleNoVisionSeeds(vision.config);
|
|
22
|
+
const rosters = projectStaleModelRosters(noVision.config);
|
|
21
23
|
const devinCli = projectDevinCliAuthMode(rosters.config);
|
|
22
24
|
return {
|
|
23
25
|
config: devinCli.config,
|
|
24
|
-
changed: renames.changed || windows.changed || vision.changed || rosters.changed || devinCli.changed,
|
|
25
|
-
warnings: [...renames.warnings, ...windows.warnings, ...vision.warnings, ...rosters.warnings, ...devinCli.warnings],
|
|
26
|
+
changed: renames.changed || windows.changed || vision.changed || noVision.changed || rosters.changed || devinCli.changed,
|
|
27
|
+
warnings: [...renames.warnings, ...windows.warnings, ...vision.warnings, ...noVision.warnings, ...rosters.warnings, ...devinCli.warnings],
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -99,7 +99,7 @@ export function museUsageWindowsToQuota(usage: unknown): ProviderQuota | null {
|
|
|
99
99
|
*
|
|
100
100
|
* Kept as a thin adapter over the shared mapper: the frame nests the same two windows
|
|
101
101
|
* under `subscription`, the muse-code/key response nests them under `subs_usage`
|
|
102
|
-
* (devlog/
|
|
102
|
+
* (devlog/_fin/260912_muse_device_oauth/001 C). Two parsers would drift, and the
|
|
103
103
|
* five-hour-window discrimination above is the part that must not.
|
|
104
104
|
*/
|
|
105
105
|
export function parseMuseSubscriptionUsage(payload: unknown): ProviderQuota | null {
|
|
@@ -473,6 +473,17 @@ export function clearAccountQuotaCache(provider?: string): void {
|
|
|
473
473
|
persistAccountQuotaCache();
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
+
/** Drop one account's cached bars and any joined probe so a reset is not ranked from the old window. */
|
|
477
|
+
export function forgetCachedProviderAccountQuota(provider: string, accountId: string): void {
|
|
478
|
+
hydrateAccountQuotaCache();
|
|
479
|
+
const key = accountCacheKey(provider, accountId);
|
|
480
|
+
accountQuotaCache.delete(key);
|
|
481
|
+
for (const flight of [...accountQuotaInflight.keys()]) {
|
|
482
|
+
if (flight === key || flight.startsWith(key + "\u0000")) accountQuotaInflight.delete(flight);
|
|
483
|
+
}
|
|
484
|
+
persistAccountQuotaCache();
|
|
485
|
+
}
|
|
486
|
+
|
|
476
487
|
/**
|
|
477
488
|
* Resolve a bearer for quota probing without silently adopting a newer global
|
|
478
489
|
* Claude CLI credential into a background multiauth slot.
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
*/
|
|
21
21
|
export async function fetchAnthropicAccountQuota(
|
|
22
22
|
instance: AnthropicInstanceId, accountId: string, force: boolean, config: OcxConfig = loadConfig(),
|
|
23
|
+
/** Never join an older account or vendor flight; a reset reread needs post-grant evidence. */
|
|
24
|
+
freshDispatch = false,
|
|
23
25
|
): Promise<AccountQuotaCacheEntry> {
|
|
24
26
|
const unavailable = (): AccountQuotaCacheEntry => ({ ts: Date.now(), quota: null, unavailable: true });
|
|
25
27
|
const target = config.providers[instance];
|
|
@@ -52,7 +54,7 @@ export async function fetchAnthropicAccountQuota(
|
|
|
52
54
|
const flightKey = recovery.anthropicCredentialQuotaFlightKey(key, accountId);
|
|
53
55
|
const flightCurrent = () => ownerCurrent()
|
|
54
56
|
&& recovery.anthropicCredentialQuotaFlightKey(key, accountId) === flightKey;
|
|
55
|
-
const joinable = accountQuotaInflight.get(flightKey);
|
|
57
|
+
const joinable = freshDispatch ? undefined : accountQuotaInflight.get(flightKey);
|
|
56
58
|
if (joinable) {
|
|
57
59
|
const joined = await joinable;
|
|
58
60
|
return ownerCurrent() && (joined.isCurrent?.() ?? flightCurrent()) ? joined : unavailable();
|
|
@@ -78,7 +80,7 @@ export async function fetchAnthropicAccountQuota(
|
|
|
78
80
|
const result = await recovery.probeAnthropicQuotaWithRecovery(accountId, token, fresh => {
|
|
79
81
|
if (!flightCurrent()) throw new AnthropicQuotaProbeOwnershipError("quota flight changed");
|
|
80
82
|
recovery.assertAnthropicQuotaSendAllowed(accountId, token);
|
|
81
|
-
return fetchAnthropicUsageQuotaForInstance(instance, token, fresh);
|
|
83
|
+
return fetchAnthropicUsageQuotaForInstance(instance, token, fresh || freshDispatch);
|
|
82
84
|
}, () => ownerCurrent() && mayCommitAccountQuotaKey(key, writerGeneration));
|
|
83
85
|
if (result && !result.isCurrent()) return unavailable();
|
|
84
86
|
if (!result && !flightCurrent()) return unavailable();
|
|
@@ -344,3 +344,17 @@ export function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolea
|
|
|
344
344
|
return (generation === undefined || isMainAccountIdentityGenerationLive(generation))
|
|
345
345
|
&& (accountReportCurrent.get(value)?.() ?? true);
|
|
346
346
|
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The reports this process already holds for the configured providers, for a passive read (#6959):
|
|
350
|
+
* no probe, token refresh, account snapshot or publication into routing evidence. The Home's own
|
|
351
|
+
* dashboard and traffic keep the cache current.
|
|
352
|
+
*/
|
|
353
|
+
export function heldProviderQuotaReports(configuredProviders: readonly string[]): ProviderQuotaResponse {
|
|
354
|
+
const held = getProviderQuotaReportCache()?.response;
|
|
355
|
+
const configured = new Set(configuredProviders);
|
|
356
|
+
return {
|
|
357
|
+
generatedAt: held?.generatedAt ?? Date.now(),
|
|
358
|
+
reports: (held?.reports ?? []).filter(item => configured.has(item.provider) && isProviderQuotaReportCurrent(item)),
|
|
359
|
+
};
|
|
360
|
+
}
|