@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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { captureAccountAutoPauseFence, accountAutoPauseFenceIsLocal, accountAutoPauseFenceMatchesDisk } from "../config/account-auto-pause-revisions";
|
|
2
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
3
|
+
import type { OAuthAccessSnapshot } from "./index";
|
|
4
|
+
import type { CodexAuthContext } from "../codex/auth-context";
|
|
5
|
+
import type { AccountPauseReason } from "../types/account-auto-pause";
|
|
6
|
+
import { credentialGeneration, getAccountSet, setAccountPaused } from "./store";
|
|
7
|
+
import { mutatePersistedConfig } from "../config";
|
|
8
|
+
import { isCodexAccountGenerationLive, readCodexAccountRecord } from "../codex/account-store";
|
|
9
|
+
import { captureMainQuotaDispatch, isMainQuotaDispatchLive } from "../codex/main-account-cache";
|
|
10
|
+
import { isCodexAccountPaused, setCodexAccountPaused } from "../codex/account-pause";
|
|
11
|
+
import { apiKeyPoolEntryId } from "../providers/api-keys";
|
|
12
|
+
import { resolveProviderApiKey } from "../providers/api-key-resolve";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { setProviderApiKeyPaused } from "../providers/api-key-pause";
|
|
15
|
+
import { AccountRefusalStreaks, autoPauseThreshold, permanentAccountRefusal } from "./account-auto-pause-policy";
|
|
16
|
+
import { clearThreadAccountMapForAccount, reconcileCodexActiveAfterExclusion } from "../codex/routing";
|
|
17
|
+
import { isNonReplayableResponse } from "../lib/upstream-retry";
|
|
18
|
+
import { hasUpstreamRewriters, upstreamRewriterEpoch } from "../plugins/upstream-hooks";
|
|
19
|
+
|
|
20
|
+
const streaks = new WeakMap<OcxConfig, AccountRefusalStreaks>();
|
|
21
|
+
export { wasAccountAutoPaused, wasPermanentAccountRefusal } from "../lib/account-auto-pause-response";
|
|
22
|
+
import { markAccountAutoPaused, markPermanentAccountRefusal } from "../lib/account-auto-pause-response";
|
|
23
|
+
export interface AccountAutoPauseBinding {
|
|
24
|
+
config: OcxConfig;
|
|
25
|
+
oauth?: OAuthAccessSnapshot;
|
|
26
|
+
codex?: CodexAuthContext;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Capture before transport. A later response cannot acquire authority over a newer login/resume. */
|
|
30
|
+
export type AccountAutoPauseObserver = ((response: Response) => Promise<Response>) & {
|
|
31
|
+
/** Final-send report: only a response whose wire credential and destination match is counted. */
|
|
32
|
+
physical: (sameDestination: boolean, headers: Headers) => void;
|
|
33
|
+
};
|
|
34
|
+
const CREDENTIAL_HEADERS = ["authorization", "x-api-key", "x-goog-api-key", "api-key"] as const;
|
|
35
|
+
const CODEX_SELECTOR_HEADERS = ["openai-organization", "openai-project"] as const;
|
|
36
|
+
// Nested capture sites (a providerFetch observer around a dispatchOverride's own observer) see the
|
|
37
|
+
// same physical response. It counts once per account, and a response whose final send was reported
|
|
38
|
+
// to any observer is never attributed by an observer that received no report.
|
|
39
|
+
const countedResponses = new WeakMap<Response, Set<string>>();
|
|
40
|
+
const reportedResponses = new WeakSet<Response>();
|
|
41
|
+
export function captureAccountAutoPauseAttempt(name: string, provider: OcxProviderConfig, binding?: AccountAutoPauseBinding, headers?: Headers):
|
|
42
|
+
AccountAutoPauseObserver | undefined {
|
|
43
|
+
if (!binding) return;
|
|
44
|
+
const { config, oauth, codex } = binding;
|
|
45
|
+
const threshold = autoPauseThreshold(config.providers[name]?.accountAutoPause);
|
|
46
|
+
if (!threshold) return;
|
|
47
|
+
const configured = config.providers[name];
|
|
48
|
+
if (!configured || configured.disabled) return;
|
|
49
|
+
const fence = captureAccountAutoPauseFence(config, name);
|
|
50
|
+
if (!fence) return;
|
|
51
|
+
const route = JSON.stringify([configured.baseUrl, configured.adapter, configured.authMode]);
|
|
52
|
+
const policyCurrent = () => accountAutoPauseFenceIsLocal(name, fence) && !config.providers[name]?.disabled
|
|
53
|
+
&& autoPauseThreshold(config.providers[name]?.accountAutoPause) === threshold
|
|
54
|
+
&& JSON.stringify([config.providers[name]?.baseUrl, config.providers[name]?.adapter, config.providers[name]?.authMode]) === route;
|
|
55
|
+
let key: string, incarnation: string, current: () => boolean, owns: (wire: Headers) => boolean;
|
|
56
|
+
let pause: (reason: AccountPauseReason) => Promise<boolean>;
|
|
57
|
+
if (provider.authMode === "oauth") {
|
|
58
|
+
if (!oauth || oauth.provider !== name || headers?.get("authorization") !== `Bearer ${oauth.accessToken}` || headers.has("x-api-key")) return;
|
|
59
|
+
const row = getAccountSet(name)?.accounts.find(a => a.id === oauth.accountId);
|
|
60
|
+
if (!row || row.paused || credentialGeneration(row.credential) !== oauth.generation) return;
|
|
61
|
+
const revision = row.pauseRevision;
|
|
62
|
+
owns = wire => wire.get("authorization") === `Bearer ${oauth.accessToken}` && !wire.has("x-api-key");
|
|
63
|
+
key = `${name}\0oauth\0${oauth.accountId}`;
|
|
64
|
+
incarnation = `${row.loginId ?? row.addedAt ?? oauth.accountId}\0${revision ?? ""}`;
|
|
65
|
+
current = () => {
|
|
66
|
+
const live = getAccountSet(name)?.accounts.find(a => a.id === oauth.accountId);
|
|
67
|
+
return !!live && !live.paused && live.pauseRevision === revision && credentialGeneration(live.credential) === oauth.generation;
|
|
68
|
+
};
|
|
69
|
+
pause = async reason => (await setAccountPaused(name, oauth.accountId, true, {
|
|
70
|
+
generation: oauth.generation, pauseRevision: revision, isCurrent: policyCurrent,
|
|
71
|
+
assertPolicyBeforePersist: () => { if (!accountAutoPauseFenceMatchesDisk(name, fence)) throw new Error("Account auto-pause policy changed before persistence"); }, details: { reason, pausedAt: Date.now() },
|
|
72
|
+
})).status === "updated";
|
|
73
|
+
} else if (provider.authMode === "forward") {
|
|
74
|
+
if (!codex || codex.kind !== "pool" && codex.kind !== "main-pool") return;
|
|
75
|
+
const { accountId } = codex;
|
|
76
|
+
// The bearer and the workspace together name the Codex account; organization/project selectors
|
|
77
|
+
// must stay as captured, so a rewrite that keeps the bearer cannot redirect the refusal.
|
|
78
|
+
const selectors = CODEX_SELECTOR_HEADERS.map(header => headers?.get(header) ?? null);
|
|
79
|
+
const codexOwns = (wire: Headers) => wire.get("authorization") === `Bearer ${codex.accessToken}`
|
|
80
|
+
&& wire.get("chatgpt-account-id") === codex.chatgptAccountId
|
|
81
|
+
&& CODEX_SELECTOR_HEADERS.every((header, index) => wire.get(header) === selectors[index]);
|
|
82
|
+
if (!headers || !codexOwns(headers)) return;
|
|
83
|
+
owns = codexOwns;
|
|
84
|
+
const revision = config.codexAccountPauseState?.[accountId]?.revision;
|
|
85
|
+
const mainDispatch = codex.kind === "main-pool" ? captureMainQuotaDispatch(codex.accessToken, codex.chatgptAccountId, codex.writerGeneration) : undefined;
|
|
86
|
+
const credentialCurrent = () => codex.kind === "pool"
|
|
87
|
+
? isCodexAccountGenerationLive(accountId, codex.generation)
|
|
88
|
+
: !!mainDispatch && isMainQuotaDispatchLive(mainDispatch);
|
|
89
|
+
key = `${name}\0codex\0${accountId}`;
|
|
90
|
+
incarnation = `${codex.kind === "pool" ? readCodexAccountRecord(accountId)?.quotaHistoryIdentity ?? codex.generation : mainDispatch?.credentialGeneration}\0${revision ?? ""}`;
|
|
91
|
+
current = () => !isCodexAccountPaused(config, accountId)
|
|
92
|
+
&& config.codexAccountPauseState?.[accountId]?.revision === revision && credentialCurrent();
|
|
93
|
+
pause = async reason => {
|
|
94
|
+
let pauseState: OcxConfig["codexAccountPauseState"];
|
|
95
|
+
const outcome = mutatePersistedConfig(fresh => {
|
|
96
|
+
if (!policyCurrent() || !accountAutoPauseFenceMatchesDisk(name, fence, fresh) || !credentialCurrent() || isCodexAccountPaused(fresh, accountId)
|
|
97
|
+
|| fresh.codexAccountPauseState?.[accountId]?.revision !== revision
|
|
98
|
+
|| autoPauseThreshold(fresh.providers[name]?.accountAutoPause) !== threshold) return { changed: false, value: false };
|
|
99
|
+
setCodexAccountPaused(fresh, accountId, true, { reason, pausedAt: Date.now() });
|
|
100
|
+
pauseState = structuredClone(fresh.codexAccountPauseState);
|
|
101
|
+
return { changed: true, value: true };
|
|
102
|
+
});
|
|
103
|
+
if (outcome.status !== "committed" || !outcome.value) return false;
|
|
104
|
+
config.pausedCodexAccountIds = [...new Set([...(config.pausedCodexAccountIds ?? []), accountId])];
|
|
105
|
+
config.codexAccountPauseState = pauseState;
|
|
106
|
+
clearThreadAccountMapForAccount(accountId);
|
|
107
|
+
reconcileCodexActiveAfterExclusion(config, accountId);
|
|
108
|
+
return true;
|
|
109
|
+
};
|
|
110
|
+
} else {
|
|
111
|
+
const selection = provider._apiKeyAttempt;
|
|
112
|
+
if (!headers || !provider.apiKey || ![headers.get("authorization"), headers.get("x-api-key"), headers.get("x-goog-api-key")].some(value => value === provider.apiKey || value === `Bearer ${provider.apiKey}`)) return;
|
|
113
|
+
const entry = configured.apiKeyPool?.find(e => e.id === selection?.entryId && e.key === selection.reference)
|
|
114
|
+
?? (!configured.apiKeyPool?.length && configured.apiKey ? { id: apiKeyPoolEntryId(configured.apiKey), key: configured.apiKey, disabled: false, pauseRevision: undefined } : undefined);
|
|
115
|
+
if (!entry || entry.disabled || !selection) return;
|
|
116
|
+
const { id, key: reference, pauseRevision } = entry;
|
|
117
|
+
key = `${name}\0key\0${id}`;
|
|
118
|
+
const resolvedKey = provider.apiKey;
|
|
119
|
+
owns = wire => {
|
|
120
|
+
const sent = CREDENTIAL_HEADERS.map(header => wire.get(header)).filter((value): value is string => value !== null);
|
|
121
|
+
return sent.length > 0 && sent.every(value => value === resolvedKey || value === `Bearer ${resolvedKey}`);
|
|
122
|
+
};
|
|
123
|
+
incarnation = `${createHash("sha256").update(resolvedKey).digest("hex")}\0${pauseRevision ?? ""}`;
|
|
124
|
+
current = () => {
|
|
125
|
+
const configured = config.providers[name];
|
|
126
|
+
const live = configured?.apiKeyPool?.find(e => e.id === id) ?? (!configured?.apiKeyPool?.length && configured?.apiKey === reference ? entry : undefined);
|
|
127
|
+
return !!live && !live.disabled && live.key === reference && live.pauseRevision === pauseRevision && resolveProviderApiKey(reference) === resolvedKey;
|
|
128
|
+
};
|
|
129
|
+
pause = async reason => setProviderApiKeyPaused(config, name, id, true, {
|
|
130
|
+
selection, pauseRevision, resolvedKey, fence, details: { reason, pausedAt: Date.now() },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
incarnation += `\0${fence.diskRevision}\0${fence.localGen}`;
|
|
134
|
+
let tracker = streaks.get(config);
|
|
135
|
+
if (!tracker) { tracker = new AccountRefusalStreaks(); streaks.set(config, tracker); }
|
|
136
|
+
const observed = new WeakSet<Response>();
|
|
137
|
+
// undefined: no HTTP final-send report (Codex WebSocket). Such a send is attributable only when
|
|
138
|
+
// no rewriter was registered at capture and none was registered or removed before the response,
|
|
139
|
+
// so no plugin could have rewritten the dial in between.
|
|
140
|
+
const rewriterEpoch = hasUpstreamRewriters() ? undefined : upstreamRewriterEpoch();
|
|
141
|
+
let wireOwned: boolean | undefined;
|
|
142
|
+
const observer = (async (response: Response) => {
|
|
143
|
+
if (observed.has(response) || isNonReplayableResponse(response)) return response;
|
|
144
|
+
observed.add(response);
|
|
145
|
+
if (wireOwned !== undefined) reportedResponses.add(response);
|
|
146
|
+
else if (reportedResponses.has(response)) return response;
|
|
147
|
+
if (!(wireOwned ?? (rewriterEpoch !== undefined && upstreamRewriterEpoch() === rewriterEpoch))) return response;
|
|
148
|
+
const counted = countedResponses.get(response) ?? new Set<string>();
|
|
149
|
+
if (counted.has(key)) return response;
|
|
150
|
+
countedResponses.set(response, counted.add(key));
|
|
151
|
+
try {
|
|
152
|
+
const verdict = permanentAccountRefusal(response).then(reason => {
|
|
153
|
+
if (reason) markPermanentAccountRefusal(response);
|
|
154
|
+
return reason;
|
|
155
|
+
});
|
|
156
|
+
const paused = await tracker.observe(key, incarnation, threshold, verdict, response.ok,
|
|
157
|
+
() => policyCurrent() && current(), pause);
|
|
158
|
+
if (paused) {
|
|
159
|
+
markAccountAutoPaused(response);
|
|
160
|
+
// Fixed codes only. Never print the upstream body, provider label, or account identifier.
|
|
161
|
+
console.warn(`[account-auto-pause] paused after ${threshold} consecutive permanent account refusals`);
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// Optional persistence must preserve the original provider response.
|
|
165
|
+
console.warn("[account-auto-pause] could not persist account pause; upstream response preserved");
|
|
166
|
+
}
|
|
167
|
+
return response;
|
|
168
|
+
}) as AccountAutoPauseObserver;
|
|
169
|
+
observer.physical = (sameDestination, wire) => { wireOwned = sameDestination && owns(wire); };
|
|
170
|
+
return observer;
|
|
171
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validateAntigravityImportCredential } from "../google-antigravity";
|
|
2
2
|
import { upsertCredentialByIdentity } from "../store";
|
|
3
3
|
import type { OAuthCredentials } from "../types";
|
|
4
|
+
import { probeAntigravityUsageQuota } from "../../providers/quota/antigravity";
|
|
4
5
|
import {
|
|
5
6
|
ACCOUNT_IMPORT_FORMAT,
|
|
6
7
|
ACCOUNT_IMPORT_MAX_EMAIL_LENGTH,
|
|
@@ -14,6 +15,26 @@ import {
|
|
|
14
15
|
export interface AntigravityImportAdapterDeps {
|
|
15
16
|
validate(refreshToken: string, signal?: AbortSignal): Promise<ValidatedAntigravityCredential>;
|
|
16
17
|
upsert(credential: OAuthCredentials): Promise<"inserted" | "updated">;
|
|
18
|
+
/**
|
|
19
|
+
* Prove that the freshly validated account token can use an operator-supplied project.
|
|
20
|
+
* Without this owner a fallback project is never admitted.
|
|
21
|
+
*/
|
|
22
|
+
verifyProject?(accessToken: string, projectId: string, signal?: AbortSignal): Promise<boolean>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The account-bound admission probe for a fallback project: the same Cloud Code Assist quota
|
|
27
|
+
* probe the account pool runs, authenticated with this account's own access token against the
|
|
28
|
+
* named project. Only a parsed quota answer admits it; denial, rate limiting, transport failure
|
|
29
|
+
* or an unusable body all keep the record out.
|
|
30
|
+
*/
|
|
31
|
+
export async function verifyAntigravityImportProject(accessToken: string, projectId: string, signal?: AbortSignal): Promise<boolean> {
|
|
32
|
+
if (signal?.aborted) return false;
|
|
33
|
+
try {
|
|
34
|
+
return (await probeAntigravityUsageQuota(accessToken, projectId, signal)).kind === "available" && !signal?.aborted;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
17
38
|
}
|
|
18
39
|
|
|
19
40
|
function safeProviderEmail(value: unknown): string | null {
|
|
@@ -32,6 +53,7 @@ export function createAntigravityAccountImportAdapter(
|
|
|
32
53
|
deps: AntigravityImportAdapterDeps = {
|
|
33
54
|
validate: validateAntigravityImportCredential,
|
|
34
55
|
upsert: credential => upsertCredentialByIdentity(ACCOUNT_IMPORT_PROVIDER, credential),
|
|
56
|
+
verifyProject: verifyAntigravityImportProject,
|
|
35
57
|
},
|
|
36
58
|
): AccountImportAdapter {
|
|
37
59
|
return {
|
|
@@ -52,15 +74,29 @@ export function createAntigravityAccountImportAdapter(
|
|
|
52
74
|
const providerEmail = safeProviderEmail(credential.email);
|
|
53
75
|
if (!providerEmail) return { status: "failed", code: "credential_rejected" };
|
|
54
76
|
if (providerEmail !== record.email) return { status: "failed", code: "identity_mismatch" };
|
|
55
|
-
|
|
56
|
-
|
|
77
|
+
// Automatic discovery wins: a discovered project is used as-is and the record's project_id
|
|
78
|
+
// is ignored. The record's project is a fallback only, admitted after an account-bound probe.
|
|
79
|
+
let projectId = typeof credential.projectId === "string" && credential.projectId.length > 0
|
|
80
|
+
? credential.projectId
|
|
81
|
+
: undefined;
|
|
82
|
+
if (!projectId && record.projectId && deps.verifyProject) {
|
|
83
|
+
let admitted = false;
|
|
84
|
+
try {
|
|
85
|
+
admitted = await deps.verifyProject(credential.access, record.projectId, signal);
|
|
86
|
+
} catch {
|
|
87
|
+
admitted = false;
|
|
88
|
+
}
|
|
89
|
+
throwIfAccountImportAborted(signal);
|
|
90
|
+
if (admitted) projectId = record.projectId;
|
|
57
91
|
}
|
|
92
|
+
if (!projectId) return { status: "failed", code: "missing_project" };
|
|
58
93
|
|
|
59
94
|
throwIfAccountImportAborted(signal);
|
|
60
95
|
try {
|
|
61
96
|
const disposition = await deps.upsert({
|
|
62
97
|
...credential,
|
|
63
98
|
email: providerEmail,
|
|
99
|
+
projectId,
|
|
64
100
|
source: "credential-file",
|
|
65
101
|
});
|
|
66
102
|
return disposition === "updated"
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ACCOUNT_IMPORT_MAX_BYTES,
|
|
3
3
|
ACCOUNT_IMPORT_MAX_EMAIL_LENGTH,
|
|
4
|
+
ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH,
|
|
4
5
|
ACCOUNT_IMPORT_MAX_RECORDS,
|
|
5
6
|
ACCOUNT_IMPORT_MAX_REFRESH_TOKEN_BYTES,
|
|
6
7
|
type ParsedCockpitRecord,
|
|
7
8
|
} from "./types";
|
|
8
9
|
|
|
9
10
|
const encoder = new TextEncoder();
|
|
10
|
-
const ALLOWED_RECORD_KEYS = new Set(["email", "refresh_token", "tags", "notes"]);
|
|
11
|
+
const ALLOWED_RECORD_KEYS = new Set(["email", "refresh_token", "project_id", "tags", "notes"]);
|
|
12
|
+
// Google Cloud project id grammar: a lowercase letter, then lowercase letters, digits or hyphens,
|
|
13
|
+
// not ending in a hyphen. Exact match only — no trimming, so whitespace or controls are rejected.
|
|
14
|
+
const PROJECT_ID_SHAPE = /^[a-z][a-z0-9-]*[a-z0-9]$/;
|
|
11
15
|
|
|
12
16
|
export type CockpitDocumentParseResult =
|
|
13
17
|
| { ok: true; records: ParsedCockpitRecord[] }
|
|
@@ -50,10 +54,18 @@ function validMetadata(record: Record<string, unknown>): boolean {
|
|
|
50
54
|
return record.notes === undefined || typeof record.notes === "string";
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
function validProjectId(value: unknown): value is string | undefined {
|
|
58
|
+
if (value === undefined) return true;
|
|
59
|
+
return typeof value === "string"
|
|
60
|
+
&& value.length >= 6
|
|
61
|
+
&& value.length <= ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH
|
|
62
|
+
&& PROJECT_ID_SHAPE.test(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
function parseRecord(value: unknown, index: number): ParsedCockpitRecord {
|
|
54
66
|
if (!isPlainRecord(value)) return { index, code: "invalid_record" };
|
|
55
67
|
if (Object.keys(value).some(key => !ALLOWED_RECORD_KEYS.has(key))) return { index, code: "invalid_record" };
|
|
56
|
-
if (!validEmail(value.email) || !validRefreshToken(value.refresh_token) || !validMetadata(value)) {
|
|
68
|
+
if (!validEmail(value.email) || !validRefreshToken(value.refresh_token) || !validProjectId(value.project_id) || !validMetadata(value)) {
|
|
57
69
|
return { index, code: "invalid_record" };
|
|
58
70
|
}
|
|
59
71
|
return {
|
|
@@ -61,6 +73,7 @@ function parseRecord(value: unknown, index: number): ParsedCockpitRecord {
|
|
|
61
73
|
record: {
|
|
62
74
|
email: value.email.toLowerCase(),
|
|
63
75
|
refreshToken: value.refresh_token,
|
|
76
|
+
...(typeof value.project_id === "string" ? { projectId: value.project_id } : {}),
|
|
64
77
|
},
|
|
65
78
|
};
|
|
66
79
|
}
|
|
@@ -11,6 +11,8 @@ export const ACCOUNT_IMPORT_MAX_REQUEST_BYTES = ACCOUNT_IMPORT_MAX_BYTES + 1024;
|
|
|
11
11
|
export const ACCOUNT_IMPORT_DEADLINE_MS = 600_000;
|
|
12
12
|
export const ACCOUNT_IMPORT_MAX_RECORDS = 25;
|
|
13
13
|
export const ACCOUNT_IMPORT_MAX_EMAIL_LENGTH = 254;
|
|
14
|
+
/** Google Cloud project ids are 6–30 characters; anything longer cannot name a project. */
|
|
15
|
+
export const ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH = 30;
|
|
14
16
|
export const ACCOUNT_IMPORT_MAX_REFRESH_TOKEN_BYTES = 16 * 1024;
|
|
15
17
|
|
|
16
18
|
export type AccountImportStatus = "imported" | "updated" | "failed" | "unsupported";
|
|
@@ -65,6 +67,11 @@ export function throwIfAccountImportAborted(signal?: AbortSignal): void {
|
|
|
65
67
|
export interface CockpitAccountRecord {
|
|
66
68
|
email: string;
|
|
67
69
|
refreshToken: string;
|
|
70
|
+
/**
|
|
71
|
+
* Operator-supplied Cloud Code Assist project, used only when automatic discovery finds none
|
|
72
|
+
* and only after the account's own token proves it can use that project.
|
|
73
|
+
*/
|
|
74
|
+
projectId?: string;
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
export type ParsedCockpitRecord =
|
|
@@ -153,7 +153,8 @@ export function accountResetTimestamp(
|
|
|
153
153
|
const quota = getCachedProviderAccountQuota(provider, accountId);
|
|
154
154
|
if (!quota) return null;
|
|
155
155
|
const family = classifyModelFamilyForQuota(provider, requestedModelId);
|
|
156
|
-
|
|
156
|
+
// Model-attributed rows are diagnostics; one must never become the account's reset time.
|
|
157
|
+
const windows = (quota.customWindows ?? []).filter(w => w.modelId === undefined && (!family || windowMatchesFamily(w.label, family)));
|
|
157
158
|
const weekly = windows.find(w => /week/i.test(w.label));
|
|
158
159
|
let rawReset = weekly?.resetAt ?? quota.weeklyResetAt;
|
|
159
160
|
if (rawReset === undefined && provider !== "google-antigravity" && provider !== "anthropic") {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { wasAccountAutoPaused } from "./account-auto-pause";
|
|
1
2
|
import { anthropicModelFamily } from "./anthropic-model-quota";
|
|
2
3
|
/** Narrow pre-output account recovery, fenced to the bearer that physically sent the turn. */
|
|
3
4
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
@@ -117,6 +118,11 @@ export async function rotateAnthropicAccountOnResponseForInstance(
|
|
|
117
118
|
const { pauseAnthropicRateAdmission, anthropicRatePauseUntil } = anthropicRatePolicyFor(instance);
|
|
118
119
|
const { recordAnthropicAccountRefusal, rotateAnthropicAccountOnRefusal,
|
|
119
120
|
hasAnthropicFailoverQuorum, isAnthropicAccountPoolEnabled, pickAlternateAnthropicAccount } = routing;
|
|
121
|
+
if (wasAccountAutoPaused(response)) {
|
|
122
|
+
if (!options.canRetry || options.signal?.aborted) return null;
|
|
123
|
+
return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(),
|
|
124
|
+
options.currentDecision ? options.currentDecision() : options.decision ?? null, options.model, options.excludedAccountIds);
|
|
125
|
+
}
|
|
120
126
|
const ownedCurrent = () => {
|
|
121
127
|
if (configuredAnthropicInstance(options.config, instance) !== instance
|
|
122
128
|
|| !anthropicPhysicalSendOwnershipIsCurrent(sent)) return undefined;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Pure family identity shared by existing hard policy and loaded-only advisory evidence. */
|
|
2
|
+
export type AnthropicModelFamily = "Fable" | "Opus" | "Sonnet";
|
|
3
|
+
/** Return the Anthropic model family (Fable, Opus or Sonnet) named by a model id, or undefined for other models. */
|
|
4
|
+
export function anthropicModelFamily(model?: string): AnthropicModelFamily | undefined {
|
|
5
|
+
return /(?:^|[/])claude-fable-5(?:-|$)/i.test(model ?? "") ? "Fable"
|
|
6
|
+
: /(?:^|[/])claude-opus-/i.test(model ?? "") ? "Opus"
|
|
7
|
+
: /(?:^|[/])claude-sonnet-/i.test(model ?? "") ? "Sonnet" : undefined;
|
|
8
|
+
}
|
|
@@ -6,12 +6,8 @@ import type { AnthropicInstanceId } from "../providers/anthropic-instance-id";
|
|
|
6
6
|
import type { GenerationContext } from "../lib/state-store-sweeper";
|
|
7
7
|
|
|
8
8
|
export const ANTHROPIC_PASSIVE_FAMILY_MAX_AGE_MS = 30 * 60_000;
|
|
9
|
-
|
|
10
|
-
export
|
|
11
|
-
return /(?:^|[/])claude-fable-5(?:-|$)/i.test(model ?? "") ? "Fable"
|
|
12
|
-
: /(?:^|[/])claude-opus-/i.test(model ?? "") ? "Opus"
|
|
13
|
-
: /(?:^|[/])claude-sonnet-/i.test(model ?? "") ? "Sonnet" : undefined;
|
|
14
|
-
}
|
|
9
|
+
import { anthropicModelFamily } from "./anthropic-model-family";
|
|
10
|
+
export { anthropicModelFamily, type AnthropicModelFamily } from "./anthropic-model-family";
|
|
15
11
|
|
|
16
12
|
export function anthropicModelPercents(quota: ProviderQuota | null, model?: string): number[] {
|
|
17
13
|
const family = anthropicModelFamily(model);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { OcxProviderConfig } from "../types";
|
|
2
|
+
import { sanitizeApiKeyValue } from "../providers/api-keys";
|
|
3
|
+
import { accountPauseDetailsSchema } from "../config/schema/account-auto-pause";
|
|
4
|
+
|
|
5
|
+
/** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
|
|
6
|
+
export function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["apiKeyPool"]> | undefined {
|
|
7
|
+
if (!Array.isArray(value)) return undefined;
|
|
8
|
+
const pool: NonNullable<OcxProviderConfig["apiKeyPool"]> = [];
|
|
9
|
+
const ids = new Set<string>();
|
|
10
|
+
const keys = new Set<string>();
|
|
11
|
+
for (const entry of value as unknown[]) {
|
|
12
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
13
|
+
const candidate = entry as Record<string, unknown>;
|
|
14
|
+
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
|
|
15
|
+
const key = sanitizeApiKeyValue(candidate.key);
|
|
16
|
+
if (!id || !key || ids.has(id) || keys.has(key)) continue;
|
|
17
|
+
const label = typeof candidate.label === "string" ? candidate.label : undefined;
|
|
18
|
+
const addedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt)
|
|
19
|
+
? candidate.addedAt
|
|
20
|
+
: undefined;
|
|
21
|
+
const details = accountPauseDetailsSchema.safeParse(candidate.pauseDetails);
|
|
22
|
+
ids.add(id);
|
|
23
|
+
keys.add(key);
|
|
24
|
+
pool.push({
|
|
25
|
+
id,
|
|
26
|
+
key,
|
|
27
|
+
...(label !== undefined ? { label } : {}),
|
|
28
|
+
...(addedAt !== undefined ? { addedAt } : {}),
|
|
29
|
+
...(typeof candidate.disabled === "boolean" ? { disabled: candidate.disabled } : {}),
|
|
30
|
+
...(typeof candidate.pauseRevision === "string" ? { pauseRevision: candidate.pauseRevision } : {}),
|
|
31
|
+
...(details.success ? { pauseDetails: details.data } : {}),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
// Retain paused slots too: their metadata and credentials are needed for explicit resume.
|
|
35
|
+
return pool.length > 0 ? pool : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
@@ -46,7 +46,16 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u
|
|
|
46
46
|
if (!token) continue;
|
|
47
47
|
const payload = decodeJwtPayload(token);
|
|
48
48
|
if (!payload) continue;
|
|
49
|
-
if (typeof payload.email === "string"
|
|
49
|
+
if (typeof payload.email === "string" && payload.email.trim()) {
|
|
50
|
+
return payload.email.trim().toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
const profile = payload["https://api.openai.com/profile"];
|
|
53
|
+
if (profile && typeof profile === "object" && !Array.isArray(profile)) {
|
|
54
|
+
const email = (profile as Record<string, unknown>).email;
|
|
55
|
+
if (typeof email === "string" && email.trim()) {
|
|
56
|
+
return email.trim().toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
50
59
|
}
|
|
51
60
|
return undefined;
|
|
52
61
|
}
|
package/src/oauth/dsh.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
12
|
+
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
12
13
|
import { homedir } from "node:os";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -16,6 +17,20 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
16
17
|
export const DSH_PLATFORM_ORIGIN = "https://platform.deepseek.com";
|
|
17
18
|
export const DSH_INFERENCE_ORIGIN = "https://api.deepseek.com/anthropic";
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Byte ceiling for the profile document returned by /auth-api/v0/users/current.
|
|
22
|
+
*
|
|
23
|
+
* A legitimate profile is a small fixed-shape JSON object (code, data.biz_code,
|
|
24
|
+
* biz_data.id/email/id_profile{name,picture}) well under a few KiB; 64 KiB matches
|
|
25
|
+
* the shared bounded-body ceiling and still leaves orders of magnitude of
|
|
26
|
+
* headroom. Anything larger is rejected before parsing so a hostile or broken
|
|
27
|
+
* upstream cannot turn an import validation into unbounded memory work.
|
|
28
|
+
*/
|
|
29
|
+
export const DSH_PROFILE_MAX_BYTES = 64 * 1024;
|
|
30
|
+
|
|
31
|
+
/** Wall-clock budget for the profile request and its body read, in milliseconds. */
|
|
32
|
+
const DSH_PROFILE_FETCH_TIMEOUT_MS = 10_000;
|
|
33
|
+
|
|
19
34
|
export interface DshAccountDetectionResult {
|
|
20
35
|
detected: boolean;
|
|
21
36
|
token?: string;
|
|
@@ -80,7 +95,7 @@ export async function validateDshAccountToken(
|
|
|
80
95
|
signal?: AbortSignal,
|
|
81
96
|
): Promise<DshValidatedAccountIdentity> {
|
|
82
97
|
try {
|
|
83
|
-
const timeoutSignal = AbortSignal.timeout(
|
|
98
|
+
const timeoutSignal = AbortSignal.timeout(DSH_PROFILE_FETCH_TIMEOUT_MS);
|
|
84
99
|
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
85
100
|
const response = await fetch(`${DSH_PLATFORM_ORIGIN}/auth-api/v0/users/current`, {
|
|
86
101
|
method: "GET",
|
|
@@ -99,7 +114,35 @@ export async function validateDshAccountToken(
|
|
|
99
114
|
return { valid: false, error: `Platform returned HTTP ${response.status}` };
|
|
100
115
|
}
|
|
101
116
|
|
|
102
|
-
|
|
117
|
+
// A declared length past the cap is rejected without touching the stream.
|
|
118
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
119
|
+
if (Number.isFinite(declaredLength) && declaredLength > DSH_PROFILE_MAX_BYTES) {
|
|
120
|
+
try {
|
|
121
|
+
void response.body?.cancel(
|
|
122
|
+
new DOMException("DSH profile response is too large", "QuotaExceededError"),
|
|
123
|
+
).catch(() => undefined);
|
|
124
|
+
} catch {
|
|
125
|
+
// Best-effort cancellation only.
|
|
126
|
+
}
|
|
127
|
+
return { valid: false, error: "PROFILE_VALIDATION_FAILED" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Missing or understated Content-Length still meets the cap on the decoded
|
|
131
|
+
// stream itself: once an over-limit byte is observed the retained buffer is
|
|
132
|
+
// discarded and cancellation is requested on the body. Malformed UTF-8 fails
|
|
133
|
+
// closed so a replacement character can never alter the authoritative ID.
|
|
134
|
+
const bounded = await readBoundedResponseBody(response, {
|
|
135
|
+
signal: combinedSignal,
|
|
136
|
+
maxBytes: DSH_PROFILE_MAX_BYTES,
|
|
137
|
+
totalTimeoutMs: DSH_PROFILE_FETCH_TIMEOUT_MS,
|
|
138
|
+
inactivityTimeoutMs: DSH_PROFILE_FETCH_TIMEOUT_MS,
|
|
139
|
+
fatalUtf8: true,
|
|
140
|
+
});
|
|
141
|
+
if (bounded.oversized || bounded.truncated || !bounded.displaySafe) {
|
|
142
|
+
return { valid: false, error: "PROFILE_VALIDATION_FAILED" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const body = JSON.parse(bounded.text) as {
|
|
103
146
|
code?: number;
|
|
104
147
|
data?: {
|
|
105
148
|
biz_code?: number;
|
package/src/oauth/index.ts
CHANGED
|
@@ -55,6 +55,7 @@ import { loginOrcaRouter, orcaRouterInferenceBaseUrl, refreshOrcaRouterKey } fro
|
|
|
55
55
|
import { ANTIGRAVITY_DISCOVERY_UA } from "../adapters/google-antigravity-wire";
|
|
56
56
|
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
|
|
57
57
|
import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
|
|
58
|
+
import { preservableApiKeyPool } from "./api-key-pool-preserve";
|
|
58
59
|
import { effectiveGoogleMode, getProviderRegistryEntry, mergeRegistryStaticHeaders, providerMatchesRegistryTransport } from "../providers/registry";
|
|
59
60
|
import { providerModelsUrl, resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
|
|
60
61
|
import { resolveProviderTransport } from "../providers/xai-transport";
|
|
@@ -1513,36 +1514,6 @@ export function reconcileOAuthProviders(config: OcxConfig, persist = true): bool
|
|
|
1513
1514
|
return true;
|
|
1514
1515
|
}
|
|
1515
1516
|
|
|
1516
|
-
/** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
|
|
1517
|
-
function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["apiKeyPool"]> | undefined {
|
|
1518
|
-
if (!Array.isArray(value)) return undefined;
|
|
1519
|
-
const pool: NonNullable<OcxProviderConfig["apiKeyPool"]> = [];
|
|
1520
|
-
const ids = new Set<string>();
|
|
1521
|
-
const keys = new Set<string>();
|
|
1522
|
-
for (const entry of value as unknown[]) {
|
|
1523
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
1524
|
-
const candidate = entry as Record<string, unknown>;
|
|
1525
|
-
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
|
|
1526
|
-
const key = sanitizeApiKeyValue(candidate.key);
|
|
1527
|
-
if (!id || !key || ids.has(id) || keys.has(key)) continue;
|
|
1528
|
-
const label = typeof candidate.label === "string" ? candidate.label : undefined;
|
|
1529
|
-
const addedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt)
|
|
1530
|
-
? candidate.addedAt
|
|
1531
|
-
: undefined;
|
|
1532
|
-
ids.add(id);
|
|
1533
|
-
keys.add(key);
|
|
1534
|
-
pool.push({
|
|
1535
|
-
id,
|
|
1536
|
-
key,
|
|
1537
|
-
...(label !== undefined ? { label } : {}),
|
|
1538
|
-
...(addedAt !== undefined ? { addedAt } : {}),
|
|
1539
|
-
});
|
|
1540
|
-
}
|
|
1541
|
-
// `apiKey` remains the routing source of truth. Keep valid alternate slots even when a
|
|
1542
|
-
// hand-edited config left the pool out of sync, rather than deleting usable credentials.
|
|
1543
|
-
return pool.length > 0 ? pool : undefined;
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
1517
|
const OAUTH_LOGIN_OWNED_PROVIDER_FIELDS = [
|
|
1547
1518
|
"adapter",
|
|
1548
1519
|
"baseUrl",
|
|
@@ -1597,11 +1568,10 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
|
|
|
1597
1568
|
// Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
|
|
1598
1569
|
let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
|
|
1599
1570
|
const storedApiKeyPool = preservableApiKeyPool(existing.apiKeyPool);
|
|
1600
|
-
//
|
|
1601
|
-
|
|
1602
|
-
if (storedApiKey === undefined
|
|
1603
|
-
|
|
1604
|
-
}
|
|
1571
|
+
// Retain all-paused pools without activating any paused credential during login.
|
|
1572
|
+
if (storedApiKeyPool?.some(entry => entry.key === storedApiKey && entry.disabled)) storedApiKey = undefined;
|
|
1573
|
+
if (storedApiKey === undefined) storedApiKey = storedApiKeyPool?.find(entry => !entry.disabled)?.key;
|
|
1574
|
+
if (storedApiKeyPool) next.apiKeyPool = storedApiKeyPool;
|
|
1605
1575
|
if (storedApiKey !== undefined) {
|
|
1606
1576
|
const pool = storedApiKeyPool ? [...storedApiKeyPool] : [];
|
|
1607
1577
|
// Keep routing and listProviderApiKeys in sync: never leave a hidden active key that
|
package/src/oauth/kiro.ts
CHANGED
|
@@ -205,11 +205,54 @@ function profileArnFromWhoami(parsed: Record<string, unknown>): string | undefin
|
|
|
205
205
|
: undefined);
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/**
|
|
209
|
+
* `kiro-cli whoami --format json` prints its identity as one JSON object, but once an IAM Identity
|
|
210
|
+
* Center profile is selected kiro-cli 2.32 follows that object with a human-readable `Profile:`
|
|
211
|
+
* block on stdout. Parsing the whole stream then failed, which silently dropped the user email that
|
|
212
|
+
* tells organization users apart (#6641). Only the leading top-level object is identity evidence;
|
|
213
|
+
* it must start the output and end its line, so text on later lines is ignored while a second
|
|
214
|
+
* value on the same line makes the identity unreadable.
|
|
215
|
+
*/
|
|
216
|
+
function parseWhoamiObject(stdout: string): Record<string, unknown> | undefined {
|
|
217
|
+
const start = stdout.search(/\S/);
|
|
218
|
+
if (start < 0 || stdout[start] !== "{") return undefined;
|
|
219
|
+
let depth = 0;
|
|
220
|
+
let inString = false;
|
|
221
|
+
let escaped = false;
|
|
222
|
+
for (let index = start; index < stdout.length; index++) {
|
|
223
|
+
const char = stdout[index];
|
|
224
|
+
if (inString) {
|
|
225
|
+
if (escaped) escaped = false;
|
|
226
|
+
else if (char === "\\") escaped = true;
|
|
227
|
+
else if (char === "\"") inString = false;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (char === "\"") inString = true;
|
|
231
|
+
else if (char === "{" || char === "[") depth++;
|
|
232
|
+
else if (char === "}" || char === "]") {
|
|
233
|
+
depth--;
|
|
234
|
+
if (depth > 0) continue;
|
|
235
|
+
if (!/^[ \t]*(?:\r?\n|$)/.test(stdout.slice(index + 1))) return undefined;
|
|
236
|
+
const parsed: unknown = JSON.parse(stdout.slice(start, index + 1));
|
|
237
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
238
|
+
? parsed as Record<string, unknown>
|
|
239
|
+
: undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Same-session identity reported by `kiro-cli whoami --format json`: the lower-cased user email
|
|
247
|
+
* and a structurally valid profile ARN. Any failure (non-zero exit, unreadable output, a thrown
|
|
248
|
+
* runner) yields an empty identity, so a login falls back to the SQLite import instead of failing.
|
|
249
|
+
*/
|
|
208
250
|
async function readKiroCliIdentity(runner: KiroCliRunner, signal?: AbortSignal): Promise<{ email?: string; profileArn?: string }> {
|
|
209
251
|
try {
|
|
210
252
|
const result = await runner(["whoami", "--format", "json"], signal);
|
|
211
253
|
if (result.exitCode !== 0) return {};
|
|
212
|
-
const parsed =
|
|
254
|
+
const parsed = parseWhoamiObject(result.stdout);
|
|
255
|
+
if (!parsed) return {};
|
|
213
256
|
const email = typeof parsed.email === "string" ? parsed.email.trim().toLowerCase() : "";
|
|
214
257
|
const profileArn = profileArnFromWhoami(parsed);
|
|
215
258
|
return {
|
|
@@ -297,14 +297,23 @@ export function clearLoginState(provider: string): void {
|
|
|
297
297
|
/**
|
|
298
298
|
* Cancel one login. `flowId` names it exactly; without one the provider's newest in-flight flow
|
|
299
299
|
* is cancelled, which is every single-flow caller (GUI, CLI) unchanged. A flow id belonging to a
|
|
300
|
-
* different provider is refused rather than cancelled.
|
|
300
|
+
* different provider is refused rather than cancelled. Only the public OAuth cancellation
|
|
301
|
+
* endpoint opts into failed-outcome dismissal; callers using this boolean as proof of an
|
|
302
|
+
* aborted controller (Codex flow ownership) retain their original contract.
|
|
301
303
|
*/
|
|
302
|
-
export function cancelLoginFlow(provider: string, flowId?: string): boolean {
|
|
304
|
+
export function cancelLoginFlow(provider: string, flowId?: string, options?: { dismissSettledFailure?: boolean }): boolean {
|
|
303
305
|
const flowKey = flowId ?? latestLoginFlowKey(provider);
|
|
304
306
|
if (flowKey === undefined) return false;
|
|
305
307
|
const active = loginAbort.get(flowKey);
|
|
306
308
|
const existing = loginState.get(flowKey);
|
|
307
309
|
if ((active?.provider ?? loginFlowProvider(flowKey)) !== provider) return false;
|
|
310
|
+
if (options?.dismissSettledFailure && !active && existing?.done && existing.error) {
|
|
311
|
+
// A failed add-account attempt does not invalidate a previously signed-in account. Let
|
|
312
|
+
// its owner dismiss the stale UI outcome without erasing credentials or another flow.
|
|
313
|
+
clearManualCodeSlot(flowKey);
|
|
314
|
+
dropLoginFlow(flowKey);
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
308
317
|
if (!active && (!existing || existing.done)) return false;
|
|
309
318
|
abandonLoginFlow(flowKey, { error: "Login cancelled", errorCode: "no_pending_login" });
|
|
310
319
|
return true;
|