@bitkyc08/opencodex 2.86.0 → 2.88.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-CHC89EXQ.js +58 -0
- package/gui/dist/assets/App-wT3mXqFz.css +1 -0
- package/gui/dist/assets/{Tray-DyR9yBee.js → Tray-B5bC7AWL.js} +1 -1
- package/gui/dist/assets/index-B5bsvxor.js +94 -0
- package/gui/dist/assets/index-DPeB9HpV.css +1 -0
- package/gui/dist/assets/quota-summary-D8y2wTsB.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic/beta-allowlist.ts +9 -8
- package/src/adapters/anthropic/beta-body-pairs.ts +93 -0
- package/src/adapters/anthropic/passthrough.ts +7 -23
- package/src/adapters/anthropic-progress.ts +84 -0
- package/src/adapters/anthropic-rejection-memo.ts +71 -0
- package/src/adapters/anthropic-replay.ts +135 -0
- package/src/adapters/anthropic-tool-catalog.ts +11 -0
- package/src/adapters/anthropic.ts +151 -33
- package/src/adapters/base.ts +2 -0
- package/src/adapters/claude-cli/adapter.ts +111 -23
- package/src/adapters/claude-cli/profiles.ts +2 -1
- package/src/adapters/claude-cli/stable-replay.ts +108 -0
- package/src/adapters/claude-cli/usage-admission.ts +230 -0
- package/src/adapters/coding-agent/protocol.ts +1 -1
- package/src/adapters/coding-agent/turn.ts +18 -2
- package/src/adapters/dsh-account.ts +97 -0
- package/src/adapters/google-progress-history.ts +53 -0
- package/src/adapters/google.ts +52 -9
- package/src/adapters/openai-chat/messages.ts +4 -3
- package/src/adapters/openai-chat/passthrough.ts +8 -2
- package/src/adapters/openai-chat/reasoning-replay.ts +52 -0
- package/src/adapters/openai-responses/compaction-search-history.ts +14 -7
- package/src/adapters/openai-responses/passthrough.ts +57 -125
- package/src/adapters/openai-responses/request-strips.ts +25 -3
- package/src/adapters/openai-responses/transport.ts +81 -0
- package/src/adapters/qoder/adapter.ts +4 -2
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/zed.ts +26 -1
- package/src/advisor/activity.ts +55 -0
- package/src/advisor/budget.ts +62 -0
- package/src/advisor/consult.ts +4 -1
- package/src/advisor/context.ts +16 -4
- package/src/advisor/evidence-tool.ts +21 -0
- package/src/advisor/evidence.ts +117 -0
- package/src/advisor/runtime.ts +79 -31
- package/src/advisor/settings.ts +11 -2
- package/src/bridge/errors.ts +19 -1
- package/src/bridge/internal.ts +7 -1
- package/src/bridge/response-json.ts +18 -3
- package/src/bridge/sse.ts +59 -8
- package/src/bridge/terminal-refusal.ts +59 -0
- package/src/claude/desktop-3p-library.ts +32 -7
- package/src/claude/desktop-3p.ts +55 -13
- package/src/claude/desktop-first-party.ts +1 -1
- package/src/claude/desktop-picker-platform.ts +4 -0
- package/src/claude/desktop-picker.ts +15 -8
- package/src/claude/desktop-system-proxy.ts +50 -4
- package/src/claude/inbound-content-options.ts +77 -0
- package/src/claude/inbound.ts +33 -0
- package/src/claude/intercept/picker-ca-startup.ts +2 -1
- package/src/claude/intercept/picker-runtime.ts +6 -5
- package/src/claude/intercept/picker-trust-windows.ts +338 -0
- package/src/claude/intercept/picker-trust.ts +19 -0
- package/src/claude/intercept/port-policy.ts +30 -0
- package/src/claude/intercept/runtime.ts +97 -94
- package/src/claude/outbound.ts +46 -13
- package/src/cli/account-api.ts +1 -0
- package/src/cli/account-policy-dto.ts +6 -0
- package/src/cli/account-policy.ts +34 -5
- package/src/cli/account-preservation.ts +44 -0
- package/src/cli/account.ts +8 -2
- package/src/cli/advisor.ts +17 -3
- package/src/cli/capabilities-accounts.ts +20 -1
- package/src/cli/capabilities-agents-routing.ts +5 -2
- package/src/cli/capabilities-base.ts +38 -10
- package/src/cli/capabilities-observe-system.ts +63 -3
- package/src/cli/capabilities-provider-models.ts +11 -0
- package/src/cli/codex-cli-update.ts +151 -41
- package/src/cli/codex-provider.ts +44 -0
- package/src/cli/combo-input.ts +10 -1
- package/src/cli/combo-result.ts +1 -1
- package/src/cli/combo.ts +2 -1
- package/src/cli/cross-home-owner.ts +33 -16
- package/src/cli/dispatch.ts +25 -4
- package/src/cli/doctor.ts +6 -1
- package/src/cli/ensure-desktop-cold-start.ts +60 -7
- package/src/cli/help.ts +3 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/integration-plan-dto.ts +1 -0
- package/src/cli/log-history.ts +172 -0
- package/src/cli/loopback-family.ts +31 -0
- package/src/cli/models-runtime-subcommands.ts +1 -0
- package/src/cli/models-runtime.ts +72 -8
- package/src/cli/observe-stream.ts +2 -0
- package/src/cli/observe.ts +16 -4
- package/src/cli/provider.ts +27 -5
- package/src/cli/registry.ts +28 -8
- package/src/cli/runtime-api.ts +5 -3
- package/src/cli/system-command.ts +40 -8
- package/src/cli/uninstall-client-state.ts +32 -0
- package/src/cli/usage-compare.ts +309 -0
- package/src/cli/usage-model-search.ts +4 -4
- package/src/cli/usage-report.ts +2 -2
- package/src/cli/v2.ts +1 -1
- package/src/client/machine-listener.ts +1 -3
- package/src/client/runtime.ts +1 -0
- package/src/clients/client-path.ts +51 -0
- package/src/clients/config-export/contracts.ts +2 -1
- package/src/clients/config-export.ts +18 -44
- package/src/clients/gentle-shell-path.ts +87 -0
- package/src/codex/account-auto-switch.ts +2 -0
- package/src/codex/account-identity.ts +29 -0
- package/src/codex/account-label.ts +1 -1
- package/src/codex/account-lifecycle.ts +21 -5
- package/src/codex/account-preservation.ts +28 -0
- package/src/codex/account-use-remaining.ts +23 -0
- package/src/codex/app-server-processes.ts +97 -7
- package/src/codex/app-server-restart-service.ts +55 -4
- package/src/codex/auth-api/account-list.ts +4 -0
- package/src/codex/auth-api/account-pause-group.ts +6 -50
- package/src/codex/auth-api/account-preservation.ts +57 -0
- package/src/codex/auth-api/routes.ts +8 -0
- package/src/codex/auth-api/use-remaining-route.ts +36 -0
- package/src/codex/auth-api.ts +0 -1
- package/src/codex/auth-context.ts +2 -2
- package/src/codex/catalog/auto-review.ts +20 -3
- package/src/codex/catalog/build-entries.ts +9 -1
- package/src/codex/catalog/combo-member.ts +14 -5
- package/src/codex/catalog/configured-tiers.ts +1 -1
- package/src/codex/catalog/gather-capture.ts +2 -0
- package/src/codex/catalog/metadata.ts +11 -9
- package/src/codex/catalog/model-hints.ts +21 -16
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/parsing.ts +30 -86
- package/src/codex/catalog/provider-models.ts +36 -34
- package/src/codex/catalog/routed-gather.ts +14 -13
- package/src/codex/cli-install-provenance.ts +105 -17
- package/src/codex/cli-update-plan.ts +545 -0
- package/src/codex/config-write-lock.ts +23 -2
- package/src/codex/desktop-app/picker-freshness.ts +96 -0
- package/src/codex/desktop-app-restart.ts +21 -0
- package/src/codex/desktop-compatibility/installed-build.ts +24 -6
- package/src/codex/desktop-compatibility/routing-preflight.ts +1 -1
- package/src/codex/desktop-compatibility/runtime.ts +12 -3
- package/src/codex/desktop-compatibility/windows-key-protection.ts +115 -9
- package/src/codex/features.ts +85 -45
- package/src/codex/inject/history-failure.ts +27 -0
- package/src/codex/inject/restore.ts +24 -2
- package/src/codex/inject/routing-classify.ts +4 -3
- package/src/codex/inject.ts +18 -27
- package/src/codex/main-account-hard-lock.ts +30 -1
- package/src/codex/native-profile-startup.ts +1 -1
- package/src/codex/ocx-compaction-history.ts +12 -10
- package/src/codex/paths.ts +86 -1
- package/src/codex/provider-routing.ts +174 -0
- package/src/codex/refresh.ts +2 -2
- package/src/codex/routing/health-store.ts +1 -1
- package/src/codex/routing/selection.ts +4 -3
- package/src/codex/routing/thread-affinity.ts +1 -1
- package/src/codex/signed-in-identity.ts +43 -0
- package/src/codex/subagent-model-fallback.ts +1 -1
- package/src/codex/sync.ts +6 -1
- package/src/codex/user-identity.ts +16 -5
- package/src/codex/websocket-activity.ts +161 -0
- package/src/codex/websocket-registry.ts +25 -1
- package/src/codex/windows-local-appdata.ts +84 -0
- package/src/combos/failover.ts +6 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +46 -10
- package/src/combos/types.ts +15 -0
- package/src/config/diagnostics.ts +33 -6
- package/src/config/live-reconcile.ts +38 -2
- package/src/config/load-degrade.ts +17 -0
- package/src/config/opencode-wire-upgrade.ts +54 -0
- package/src/config/opencode-zen-upgrade.ts +53 -0
- package/src/config/owner-registry.ts +22 -17
- package/src/config/provider-validation.ts +45 -2
- package/src/config/schema/config-schema.ts +22 -0
- package/src/config/schema/leaf-validators.ts +38 -9
- package/src/config/schema/main-account-hard-lock-fallback.ts +26 -0
- package/src/config/schema/native-reasoning-retention.ts +26 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +657 -297
- package/src/grok/sync.ts +1 -1
- package/src/images/loop.ts +116 -27
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/mutation-plan.ts +1 -0
- package/src/integrations/registry.ts +8 -0
- package/src/lib/codex-restart-contract.ts +15 -0
- package/src/lib/desktop-upstream-tunnel.ts +18 -53
- package/src/lib/empty-tool-catalog.ts +62 -0
- package/src/lib/errors.ts +19 -3
- package/src/lib/gcp-adc.ts +96 -9
- package/src/lib/gcp-credential-input.ts +12 -0
- package/src/lib/lab-live-route-production.ts +5 -0
- package/src/lib/opaque-proxy-tunnel.ts +217 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/request-failure-model.ts +2 -0
- package/src/oauth/account-quota-rank.ts +69 -2
- package/src/oauth/anthropic-account-refusal.ts +20 -2
- package/src/oauth/anthropic-oauth-definitions.ts +3 -1
- package/src/oauth/anthropic-routing.ts +1 -21
- package/src/oauth/anthropic.ts +38 -7
- package/src/oauth/callback-server.ts +27 -1
- package/src/oauth/chatgpt-device.ts +1 -1
- package/src/oauth/chatgpt-shared.ts +83 -0
- package/src/oauth/chatgpt.ts +8 -72
- package/src/oauth/dsh.ts +203 -0
- package/src/oauth/generic-account-failover.ts +40 -11
- package/src/oauth/generic-session-affinity.ts +125 -0
- package/src/oauth/index.ts +184 -109
- package/src/oauth/local-token-detect.ts +21 -1
- package/src/oauth/login-cli-errors.ts +44 -0
- package/src/oauth/login-cli.ts +69 -7
- package/src/oauth/login-flow-state.ts +371 -23
- package/src/oauth/meta-muse-errors.ts +41 -0
- package/src/oauth/meta-muse-keychain.ts +52 -0
- package/src/oauth/meta-muse.ts +51 -84
- package/src/oauth/model-discovery-auth.ts +3 -0
- package/src/oauth/pool-settings-capability.ts +4 -2
- package/src/oauth/refresh-flight-retirement.ts +48 -0
- package/src/oauth/session-affinity-key.ts +23 -0
- package/src/oauth/store-anthropic-instance.ts +9 -2
- package/src/oauth/store.ts +12 -5
- package/src/protocols/encoders/adapter-events.ts +6 -5
- package/src/protocols/encoders/messages.ts +9 -6
- package/src/protocols/provider-summary.ts +2 -1
- package/src/providers/api-key-cooldowns.ts +49 -0
- package/src/providers/api-key-resolve.ts +11 -6
- package/src/providers/api-key-response-ownership.ts +57 -0
- package/src/providers/api-key-selection.ts +31 -6
- package/src/providers/api-keys.ts +214 -3
- package/src/providers/claude-cli-usage.ts +96 -0
- package/src/providers/dsh-account-destination.ts +31 -0
- package/src/providers/dsh-account-models.ts +41 -0
- package/src/providers/gcp-credential-cleanup.ts +59 -0
- package/src/providers/key-failover.ts +54 -50
- package/src/providers/key-store.ts +7 -2
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/model-rename-migration.ts +2 -2
- package/src/providers/openai-tiers-destination.ts +11 -2
- package/src/providers/openai-tiers.ts +5 -12
- package/src/providers/opencode-go-transport.ts +3 -1
- package/src/providers/opencode-zen-rate-limit.ts +7 -2
- package/src/providers/quota/account-cache.ts +6 -5
- package/src/providers/quota/account-row.ts +34 -0
- package/src/providers/quota/antigravity.ts +28 -7
- package/src/providers/quota/dsh-account.ts +70 -0
- package/src/providers/quota/vendor-probes-oauth.ts +1 -93
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +14 -20
- package/src/providers/registry/entries-core.ts +37 -8
- package/src/providers/registry/types.ts +1 -1
- package/src/providers/request-pacing.ts +77 -19
- package/src/providers/routed-config.ts +339 -0
- package/src/providers/service-tier.ts +2 -1
- package/src/quota/window-mapping.ts +5 -2
- package/src/responses/anthropic-progress.ts +34 -0
- package/src/responses/assistant-replay.ts +35 -0
- package/src/responses/compaction-history-framing.ts +65 -0
- package/src/responses/compaction.ts +6 -1
- package/src/responses/incomplete-reason.ts +31 -0
- package/src/responses/parser.ts +50 -5
- package/src/responses/reasoning-envelope.ts +4 -0
- package/src/responses/reasoning-replay-cache.ts +74 -6
- package/src/responses/reasoning-retention.ts +173 -0
- package/src/responses/retained-compaction.ts +46 -0
- package/src/responses/schema.ts +23 -0
- package/src/router.ts +8 -321
- package/src/routing/analytics.ts +7 -15
- package/src/routing/health.ts +37 -22
- package/src/routing/history/indexer.ts +6 -5
- package/src/routing/history/ingest.ts +6 -2
- package/src/routing/profile.ts +1 -1
- package/src/server/admission-model-scope.ts +17 -0
- package/src/server/auth-cors.ts +11 -0
- package/src/server/chat-completions.ts +7 -1
- package/src/server/chat-native-reasoning.ts +123 -0
- package/src/server/chat-native-sse.ts +110 -7
- package/src/server/chat-native.ts +76 -17
- package/src/server/claude-messages.ts +56 -414
- package/src/server/effort-policy.ts +1 -1
- package/src/server/images.ts +15 -2
- package/src/server/index/live-sideband.ts +3 -2
- package/src/server/index/model-alias-id.ts +36 -0
- package/src/server/index/optional-listeners.ts +2 -0
- package/src/server/index/serve-options.ts +20 -6
- package/src/server/index/startup-warnings.ts +2 -0
- package/src/server/index/websocket-handler.ts +18 -4
- package/src/server/index.ts +8 -8
- package/src/server/inference/advisor-diagnostics.ts +31 -0
- package/src/server/lifecycle.ts +13 -1
- package/src/server/management/advisor-routes.ts +15 -4
- package/src/server/management/claude-desktop-picker-routes.ts +1 -1
- package/src/server/management/combo-routes.ts +176 -50
- package/src/server/management/companion-routes.ts +1 -1
- package/src/server/management/config-routes.ts +75 -4
- package/src/server/management/cursor-integration-routes.ts +1 -1
- package/src/server/management/model-routes.ts +11 -6
- package/src/server/management/model-rows.ts +14 -0
- package/src/server/management/native-integration-routes.ts +2 -7
- package/src/server/management/native-reasoning-retention-routes.ts +41 -0
- package/src/server/management/oauth-account-routes.ts +87 -14
- package/src/server/management/provider-gcp-credential-routes.ts +29 -0
- package/src/server/management/provider-key-pacing-routes.ts +31 -0
- package/src/server/management/provider-overwrite-carry.ts +7 -0
- package/src/server/management/provider-routes.ts +25 -30
- package/src/server/management/route-registry.ts +10 -1
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native-tool-guard.ts +185 -0
- package/src/server/messages-native.ts +117 -24
- package/src/server/messages-shared.ts +431 -0
- package/src/server/relay.ts +1 -1
- package/src/server/request-log-terminal-status.ts +1 -1
- package/src/server/request-log.ts +6 -1
- package/src/server/responses/account-change-state.ts +8 -2
- package/src/server/responses/adapter-continuation.ts +47 -19
- package/src/server/responses/adapter-delivery.ts +72 -12
- package/src/server/responses/adapter-dispatch.ts +46 -3
- package/src/server/responses/advisor-retention.ts +117 -0
- package/src/server/responses/advisor-slot.ts +73 -41
- package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
- package/src/server/responses/codex-ws-wire.ts +15 -8
- package/src/server/responses/combo-stream-preflight.ts +3 -2
- package/src/server/responses/compact.ts +303 -125
- package/src/server/responses/compaction-recovery.ts +20 -10
- package/src/server/responses/compaction-retention.ts +61 -0
- package/src/server/responses/context-overflow.ts +11 -6
- package/src/server/responses/core-auth.ts +6 -0
- package/src/server/responses/core-codex-account.ts +11 -1
- package/src/server/responses/core-combo-failure.ts +2 -0
- package/src/server/responses/core-combo.ts +121 -36
- package/src/server/responses/core-errors.ts +3 -3
- package/src/server/responses/core-lifetime.ts +8 -4
- package/src/server/responses/core-opaque-recovery.ts +35 -0
- package/src/server/responses/core-options.ts +7 -0
- package/src/server/responses/core-pipeline.ts +141 -0
- package/src/server/responses/core-replay.ts +23 -3
- package/src/server/responses/core.ts +2 -115
- package/src/server/responses/fetch-helpers.ts +73 -19
- package/src/server/responses/main-hard-lock-routing.ts +109 -0
- package/src/server/responses/native-response-control.ts +4 -2
- package/src/server/responses/passthrough-delivery.ts +3 -1
- package/src/server/responses/passthrough-dispatch.ts +15 -13
- package/src/server/responses/passthrough-execution.ts +3 -2
- package/src/server/responses/request-prepare.ts +77 -12
- package/src/server/responses/request-sidecar-auth.ts +2 -2
- package/src/server/responses/request-transport.ts +104 -27
- package/src/server/responses/response-effects.ts +2 -0
- package/src/server/responses/response-tool-ownership.ts +21 -0
- package/src/server/responses/run-turn-execution.ts +96 -30
- package/src/server/responses/sidecar-execution.ts +63 -9
- package/src/server/responses/sidecar-send-budget.ts +2 -2
- package/src/server/responses/terminal-guard.ts +2 -16
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/ws-bridge.ts +2 -0
- package/src/service/auth-preflight.ts +71 -0
- package/src/service/cli.ts +4 -2
- package/src/service/definition.ts +97 -0
- package/src/service/guards.ts +1 -61
- package/src/service/health-sentinel-linux.ts +112 -0
- package/src/service/health-sentinel-main.ts +76 -57
- package/src/service/health.ts +5 -89
- package/src/service/launchd.ts +27 -7
- package/src/service/logging.ts +72 -9
- package/src/service/repair.ts +2 -1
- package/src/service/state.ts +7 -8
- package/src/service/systemd.ts +1 -1
- package/src/service/windows-guarded-manager.ts +2 -1
- package/src/service/windows-ops.ts +1 -1
- package/src/service/windows-scheduler.ts +2 -7
- package/src/service/windows-taskxml.ts +2 -2
- package/src/service/windows-wscript.ts +14 -0
- package/src/service.ts +4 -2
- package/src/sidecar/auth.ts +2 -0
- package/src/types/config.ts +64 -3
- package/src/types/opencode-go.ts +61 -0
- package/src/types/opencode-zen.ts +35 -0
- package/src/types/provider.ts +32 -6
- package/src/types/request.ts +31 -2
- package/src/types/wire.ts +31 -37
- package/src/types.ts +2 -0
- package/src/update/npm-invocation.mjs +12 -2
- package/src/usage/cost.ts +48 -12
- package/src/usage/expected-prices.ts +3 -0
- package/src/usage/ledger-retention.ts +2 -1
- package/src/usage/ledger-scanner.ts +6 -5
- package/src/usage/log.ts +17 -8
- package/src/usage/summary.ts +30 -11
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/usage/user-cost-overlays.ts +14 -19
- package/src/usage/user-cost-validation.ts +38 -0
- package/src/vision/eligibility.ts +13 -0
- package/src/web-search/anthropic-history.ts +59 -0
- package/src/web-search/loop.ts +236 -73
- package/src/web-search/progress-stream.ts +15 -2
- package/src/web-search/run-turn-loop.ts +9 -1
- package/gui/dist/assets/App-BjlttVLy.js +0 -58
- package/gui/dist/assets/App-CSJvmjNs.css +0 -1
- package/gui/dist/assets/index-BB6uK6tU.css +0 -1
- package/gui/dist/assets/index-BMssP4k3.js +0 -94
- package/gui/dist/assets/quota-summary-D8M1bc_R.js +0 -1
|
@@ -18,9 +18,12 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { resolveEnvValue } from "../config/proxy-env";
|
|
20
20
|
import { loadKeyringBinding } from "../lib/keyring-native";
|
|
21
|
+
import { isGcpCredentialMaterial } from "../lib/gcp-credential-input";
|
|
21
22
|
import type { OcxProviderConfig } from "../types";
|
|
22
23
|
|
|
23
24
|
export const KEYCHAIN_REFERENCE_PREFIX = "keychain:";
|
|
25
|
+
/** Stored GCP JSON uses the same OS service, but must never be restored as a literal API key. */
|
|
26
|
+
export const GCP_CREDENTIAL_MARKER_PREFIX = "gcp-sa:";
|
|
24
27
|
export const PROVIDER_KEYCHAIN_SERVICE = "opencodex.provider-api-key.v1";
|
|
25
28
|
|
|
26
29
|
export interface ProviderKeychainEntry {
|
|
@@ -87,15 +90,17 @@ export function providerKeychainEntry(account: string): ProviderKeychainEntry {
|
|
|
87
90
|
function readKeychain(account: string, quiet = false): string | undefined {
|
|
88
91
|
const cached = resolvedCache.get(account);
|
|
89
92
|
if (cached !== undefined) return cached;
|
|
93
|
+
let value: string | null = null;
|
|
90
94
|
try {
|
|
91
|
-
|
|
92
|
-
if (typeof value === "string" && value.trim()) {
|
|
93
|
-
resolvedCache.set(account, value);
|
|
94
|
-
return value;
|
|
95
|
-
}
|
|
95
|
+
value = providerKeychainEntry(account).getPassword();
|
|
96
96
|
} catch {
|
|
97
97
|
// fall through to the single warning below
|
|
98
98
|
}
|
|
99
|
+
if (typeof value === "string" && value.trim()) {
|
|
100
|
+
if (isGcpCredentialMaterial(value)) throw new Error("GCP credential JSON cannot be used as a literal API key; select a gcp-sa reference");
|
|
101
|
+
resolvedCache.set(account, value);
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
99
104
|
if (!quiet && !warnedAccounts.has(account)) {
|
|
100
105
|
warnedAccounts.add(account);
|
|
101
106
|
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)`);
|
|
@@ -125,7 +130,7 @@ export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none";
|
|
|
125
130
|
export function providerKeyStoreKind(provider: Pick<OcxProviderConfig, "apiKey"> | undefined): ProviderKeyStoreKind {
|
|
126
131
|
const key = provider?.apiKey;
|
|
127
132
|
if (!key) return "none";
|
|
128
|
-
if (isKeychainReference(key)) return "keychain";
|
|
133
|
+
if (key.startsWith(GCP_CREDENTIAL_MARKER_PREFIX) || isKeychainReference(key)) return "keychain";
|
|
129
134
|
if (/^\$\{?\w+\}?$/.test(key)) return "env";
|
|
130
135
|
return "file";
|
|
131
136
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
2
|
+
import { isEgressTransparentExecutor, markEgressTransparentExecutor } from "../lib/provider-egress";
|
|
3
|
+
import { isNonReplayableResponse } from "../lib/upstream-retry";
|
|
4
|
+
import { providerApiKeySelectionOwnsFailure } from "./api-key-selection";
|
|
5
|
+
import { recordDistributedKeyFailure } from "./key-failover";
|
|
6
|
+
import { registerProviderResponseProvenance } from "./request-pacing";
|
|
7
|
+
|
|
8
|
+
interface KeyResponseOwner {
|
|
9
|
+
readonly providerName: string;
|
|
10
|
+
readonly provider: OcxProviderConfig;
|
|
11
|
+
}
|
|
12
|
+
const owners = new WeakMap<Response, KeyResponseOwner | null>();
|
|
13
|
+
registerProviderResponseProvenance(owners);
|
|
14
|
+
|
|
15
|
+
/** Capture the credential after connection-policy rewrites, immediately before fetch. */
|
|
16
|
+
export function distributedKeyPhysicalFetch(
|
|
17
|
+
physicalFetch: typeof globalThis.fetch, providerName: string, provider: OcxProviderConfig, target: string,
|
|
18
|
+
authHeader: "authorization" | "x-api-key" = "authorization",
|
|
19
|
+
): typeof globalThis.fetch {
|
|
20
|
+
if (!provider._apiKeyAttempt?.distributed) return physicalFetch;
|
|
21
|
+
const wrapped = (async (input, init) => {
|
|
22
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
23
|
+
const destination = input instanceof Request ? input.url : String(input);
|
|
24
|
+
const ownsCredential = destination === target && !headers.has("api-key")
|
|
25
|
+
&& (authHeader === "authorization"
|
|
26
|
+
? headers.get("authorization") === `Bearer ${provider.apiKey}` && !headers.has("x-api-key")
|
|
27
|
+
: headers.get("x-api-key") === provider.apiKey && !headers.has("authorization"));
|
|
28
|
+
const owner: KeyResponseOwner | null = provider._apiKeyAttempt?.distributed && ownsCredential
|
|
29
|
+
? Object.freeze({ providerName, provider: Object.freeze({ ...provider,
|
|
30
|
+
_apiKeyAttempt: Object.freeze({ ...provider._apiKeyAttempt }) }) }) : null;
|
|
31
|
+
const response = await physicalFetch(input, init);
|
|
32
|
+
// Reused response identities cannot safely describe two physical sends.
|
|
33
|
+
owners.set(response, owners.has(response) ? null : owner);
|
|
34
|
+
return response;
|
|
35
|
+
}) as typeof globalThis.fetch;
|
|
36
|
+
return isEgressTransparentExecutor(physicalFetch) ? markEgressTransparentExecutor(wrapped) : wrapped;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Return only a still-configured credential captured at the physical send. */
|
|
40
|
+
export function distributedKeyResponseOwner(
|
|
41
|
+
response: Response, config: OcxConfig, providerName: string,
|
|
42
|
+
): OcxProviderConfig | null {
|
|
43
|
+
const owner = owners.get(response);
|
|
44
|
+
return owner?.providerName === providerName
|
|
45
|
+
&& providerApiKeySelectionOwnsFailure(config, providerName, owner.provider) ? owner.provider : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Settle only the terminal refusal; configured same-key 429 retries retain their key. */
|
|
49
|
+
export function recordDistributedResponseFailure(
|
|
50
|
+
response: Response, config: OcxConfig, providerName: string, signal?: AbortSignal,
|
|
51
|
+
): void {
|
|
52
|
+
if (signal?.aborted || isNonReplayableResponse(response) || (response.status !== 401 && response.status !== 429)) return;
|
|
53
|
+
const owner = distributedKeyResponseOwner(response, config, providerName);
|
|
54
|
+
if (!owner) return;
|
|
55
|
+
recordDistributedKeyFailure(config, providerName, owner._apiKeyAttempt,
|
|
56
|
+
response.status, Date.now(), response.headers.get("retry-after"));
|
|
57
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { isKeyInCooldown } from "./api-key-cooldowns";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { mutatePersistedConfig } from "../config";
|
|
3
4
|
import { publishAccountSelection } from "../lib/account-selection-events";
|
|
4
5
|
import { clearModelCache } from "../codex/model-cache";
|
|
5
6
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
6
7
|
import type { ProviderApiKeySelection } from "../types/provider";
|
|
7
|
-
import { routedProviderConfig } from "
|
|
8
|
+
import { routedProviderConfig } from "./routed-config";
|
|
8
9
|
import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport";
|
|
9
10
|
import { resolveProviderTransport, XAI_GROK_COMPATIBILITY, type OcxProviderTransport } from "./xai-transport";
|
|
10
11
|
import { captureProviderApiKeySelection } from "./api-key-selection-capture";
|
|
@@ -17,10 +18,17 @@ function matchesSelection(provider: OcxProviderConfig, expected: ProviderApiKeyS
|
|
|
17
18
|
&& current.revision === expected.revision;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
function currentKeyProvider(config: OcxConfig, name: string): OcxProviderConfig | null {
|
|
21
|
+
function currentKeyProvider(config: OcxConfig, name: string, selection?: ProviderApiKeySelection, allowCooling = false): OcxProviderConfig | null {
|
|
21
22
|
const configured = config.providers[name];
|
|
22
23
|
if (!configured || configured.disabled) return null;
|
|
23
|
-
|
|
24
|
+
let row = configured;
|
|
25
|
+
if (selection?.distributed) {
|
|
26
|
+
const entry = configured.apiKeyPool?.find(key => key.id === selection.entryId && key.key === selection.reference);
|
|
27
|
+
if (!entry || (!allowCooling && isKeyInCooldown(name, entry.id)) || configured.apiKeySelectionRevision !== selection.revision
|
|
28
|
+
|| !configured.requestPacing?.enabled || configured.requestPacing.keySelection !== "round-robin") return null;
|
|
29
|
+
row = { ...configured, apiKey: entry.key, _apiKeyAttempt: selection };
|
|
30
|
+
}
|
|
31
|
+
const current = routedProviderConfig(name, { ...row, _apiKeyAttempt: selection?.distributed ? selection : undefined });
|
|
24
32
|
if (current.authMode === "oauth" || current.authMode === "forward") return null;
|
|
25
33
|
if (current.authMode === "key" && !current.keyOptional && !current.apiKey?.trim()) return null;
|
|
26
34
|
return current;
|
|
@@ -32,10 +40,23 @@ export function providerApiKeySelectionIsCurrent(
|
|
|
32
40
|
name: string,
|
|
33
41
|
routedProvider: OcxProviderConfig,
|
|
34
42
|
): boolean {
|
|
35
|
-
|
|
43
|
+
return matchesConfiguredSelection(config, name, routedProvider, false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An already sent failure can extend cooldown, but cannot authorize another dispatch. */
|
|
47
|
+
export function providerApiKeySelectionOwnsFailure(
|
|
48
|
+
config: OcxConfig, name: string, routedProvider: OcxProviderConfig,
|
|
49
|
+
): boolean {
|
|
50
|
+
return matchesConfiguredSelection(config, name, routedProvider, true);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function matchesConfiguredSelection(
|
|
54
|
+
config: OcxConfig, name: string, routedProvider: OcxProviderConfig, allowCooling: boolean,
|
|
55
|
+
): boolean {
|
|
36
56
|
const expected = routedProvider._apiKeyAttempt;
|
|
57
|
+
const current = currentKeyProvider(config, name, expected, allowCooling);
|
|
37
58
|
return current !== null && expected !== undefined
|
|
38
|
-
&& matchesSelection(config.providers[name]!, expected)
|
|
59
|
+
&& (expected.distributed === true || matchesSelection(config.providers[name]!, expected))
|
|
39
60
|
&& current.apiKey === routedProvider.apiKey
|
|
40
61
|
&& current.authMode === routedProvider.authMode
|
|
41
62
|
&& current.baseUrl === routedProvider.baseUrl;
|
|
@@ -47,7 +68,11 @@ export function resolveCurrentProviderApiKeyTransport(
|
|
|
47
68
|
name: string,
|
|
48
69
|
routedProvider: OcxProviderConfig,
|
|
49
70
|
): OcxProviderConfig | null {
|
|
50
|
-
|
|
71
|
+
// A permit acquired for one key cannot authorize a different key after a manual edit.
|
|
72
|
+
if (routedProvider.requestPacing?.enabled && routedProvider.requestPacing.keys
|
|
73
|
+
&& routedProvider._apiKeyAttempt && config.providers[name]
|
|
74
|
+
&& !routedProvider._apiKeyAttempt.distributed && !matchesSelection(config.providers[name]!, routedProvider._apiKeyAttempt)) return null;
|
|
75
|
+
const current = currentKeyProvider(config, name, routedProvider._apiKeyAttempt);
|
|
51
76
|
if (!current) return null;
|
|
52
77
|
const runtime = routedProvider as OcxProviderTransport;
|
|
53
78
|
const headers = { ...current.headers };
|
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { saveConfigPreservingClaudeCode } from "../config";
|
|
11
11
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
12
|
+
import { GCP_CREDENTIAL_MARKER_PREFIX, isGcpCredentialMaterial, parseGcpCredentialJson } from "../lib/gcp-adc";
|
|
13
|
+
import { invalidateResolvedProviderKeyCache, probeProviderKeychain, providerKeychainEntry } from "./api-key-resolve";
|
|
12
14
|
import type { AccountQuotaFields } from "./quota-types";
|
|
13
15
|
import { commitProviderApiKeySelection } from "./api-key-selection";
|
|
16
|
+
import { cleanupRemovedGcpCredentials } from "./gcp-credential-cleanup";
|
|
14
17
|
|
|
15
18
|
export interface ProviderApiKeyInfo extends AccountQuotaFields {
|
|
16
19
|
id: string;
|
|
@@ -28,11 +31,159 @@ function isEnvReference(value: string): boolean {
|
|
|
28
31
|
export function maskApiKey(value: string): string {
|
|
29
32
|
// Env and keychain references carry no secret material; show them verbatim so an operator
|
|
30
33
|
// can tell where the key lives.
|
|
31
|
-
if (isEnvReference(value) || value.startsWith("keychain:")) return value;
|
|
34
|
+
if (isEnvReference(value) || value.startsWith("keychain:") || value.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) return value;
|
|
32
35
|
if (value.length <= 8) return "****";
|
|
33
36
|
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Split a pasted key value into candidate credentials. A plain API key is one row; a pasted GCP
|
|
41
|
+
* credential JSON (or several, comma/space-separated — the same convention as newline-separated
|
|
42
|
+
* API keys) is one row per JSON object. File paths are rejected with guidance rather
|
|
43
|
+
* than stored, because they are the most common paste mistake and silently break at runtime.
|
|
44
|
+
*
|
|
45
|
+
* Honors the parseGcpCredentialJson contract "a literal key that starts with `{` is never eaten":
|
|
46
|
+
* a brace-leading value that is NOT recognizable credential JSON falls back to a plain literal
|
|
47
|
+
* row (preserving the old single-line behavior), and only a multi-line or multi-object paste that
|
|
48
|
+
* fails credential validation rejects with guidance. The brace scanner tracks JSON string and
|
|
49
|
+
* escape state so a `}` inside a string field does not split the object early; any non-separator
|
|
50
|
+
* text between objects is rejected rather than silently discarded.
|
|
51
|
+
*/
|
|
52
|
+
export function splitCredentialPaste(value: string): Array<string | { credentialJson: string }> {
|
|
53
|
+
const trimmed = (value ?? "").trim();
|
|
54
|
+
if (!trimmed) return [];
|
|
55
|
+
// Fast path: a plain key (no JSON braces) — also covers env/keychain/gcp-sa references.
|
|
56
|
+
if (!trimmed.startsWith("{")) {
|
|
57
|
+
if (/(?:^|[\s,])\{/.test(trimmed)) {
|
|
58
|
+
throw new Error("Mixing credential JSON and literal API keys in one paste is not supported. Add them separately.");
|
|
59
|
+
}
|
|
60
|
+
if (isGcpCredentialMaterial(trimmed)) {
|
|
61
|
+
throw new Error("Invalid credential JSON: paste a GCP credential object without wrapping it in an array or string.");
|
|
62
|
+
}
|
|
63
|
+
if (/^(?:[A-Za-z]:[\\/]|[\\/]|~[\\/]|\.{1,2}[\\/])/.test(trimmed) || /^[^\s{}]+\.json$/i.test(trimmed)) {
|
|
64
|
+
throw new Error("This looks like a file path. Paste the full JSON body of the credential, not a file path.");
|
|
65
|
+
}
|
|
66
|
+
return [trimmed];
|
|
67
|
+
}
|
|
68
|
+
// Brace-leading scans: split adjacent JSON objects (comma/newline separated). The scanner
|
|
69
|
+
// tracks string/escape state so braces inside JSON string fields do not count, and records
|
|
70
|
+
// each object's [start, end] so the gap check below can reject non-separator junk between
|
|
71
|
+
// objects in one pass.
|
|
72
|
+
const spans: Array<{ start: number; end: number }> = [];
|
|
73
|
+
const objects: string[] = [];
|
|
74
|
+
let depth = 0;
|
|
75
|
+
let start = -1;
|
|
76
|
+
let inString = false;
|
|
77
|
+
let escaped = false;
|
|
78
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
79
|
+
const ch = trimmed[i];
|
|
80
|
+
if (inString) {
|
|
81
|
+
if (escaped) escaped = false;
|
|
82
|
+
else if (ch === "\\") escaped = true;
|
|
83
|
+
else if (ch === '"') inString = false;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (ch === '"') { inString = true; continue; }
|
|
87
|
+
if (ch === "{" && depth === 0) {
|
|
88
|
+
start = i;
|
|
89
|
+
}
|
|
90
|
+
if (ch === "{") depth += 1;
|
|
91
|
+
if (ch === "}") {
|
|
92
|
+
depth -= 1;
|
|
93
|
+
if (depth === 0 && start >= 0) {
|
|
94
|
+
objects.push(trimmed.slice(start, i + 1));
|
|
95
|
+
spans.push({ start, end: i });
|
|
96
|
+
start = -1;
|
|
97
|
+
}
|
|
98
|
+
if (depth < 0) throw new Error("Invalid credential JSON: unbalanced braces.");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (depth !== 0 || start >= 0) throw new Error("Invalid credential JSON: the pasted text ends inside a JSON object.");
|
|
102
|
+
// Single unrecognized object on a single line = a plain key starting with `{` (never eaten) —
|
|
103
|
+
// BUT only when it is NOT real JSON: a parseable JSON object with an unrecognized `type`
|
|
104
|
+
// (e.g. `external_account`) is a credential the user believes is valid, and saving it as a
|
|
105
|
+
// plaintext literal key would send it to the wire. Distinguish by parse success, not shape.
|
|
106
|
+
const singleLine = !/[\r\n]/.test(trimmed);
|
|
107
|
+
const isRealJson = (() => {
|
|
108
|
+
try { JSON.parse(objects[0]!); return true; } catch { return false; }
|
|
109
|
+
})();
|
|
110
|
+
if (objects.length === 1 && singleLine && spans[0]!.end === trimmed.length - 1 && !parseGcpCredentialJson(objects[0]!) && !isRealJson && !isGcpCredentialMaterial(trimmed)) {
|
|
111
|
+
return [trimmed];
|
|
112
|
+
}
|
|
113
|
+
// Non-separator content BETWEEN and AFTER objects is rejected, not silently discarded:
|
|
114
|
+
// everything between/after one object's close must be a separator character.
|
|
115
|
+
for (let i = 0; i < spans.length; i++) {
|
|
116
|
+
if (i === 0) {
|
|
117
|
+
if (!/^[\s,]*$/.test(trimmed.slice(0, spans[0]!.start))) {
|
|
118
|
+
throw new Error("Unrecognized credential JSON: unexpected content before the first JSON object. Paste credential JSON(s) separated by commas or newlines, or a plain API key.");
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const gap = trimmed.slice(spans[i - 1]!.end + 1, spans[i]!.start);
|
|
123
|
+
if (!/^[\s,]*$/.test(gap)) {
|
|
124
|
+
throw new Error("Unrecognized credential JSON: unexpected content between JSON objects. Paste credential JSON(s) separated by commas or newlines, or a plain API key.");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Tail gap: content after the LAST object's close up to EOF must also be separators —
|
|
128
|
+
// trailing garbage (`<json> garbage`) is rejected rather than silently kept.
|
|
129
|
+
const tailGap = trimmed.slice(spans[spans.length - 1]!.end + 1);
|
|
130
|
+
if (!/^[\s,]*$/.test(tailGap)) {
|
|
131
|
+
throw new Error("Unrecognized credential JSON: unexpected content after the last JSON object. Mixing JSON and literal keys is not supported; add them separately.");
|
|
132
|
+
}
|
|
133
|
+
return objects.map((json) => {
|
|
134
|
+
if (!parseGcpCredentialJson(json)) {
|
|
135
|
+
throw new Error("Unrecognized credential JSON: expected a GCP Service Account or authorized_user JSON with a \"type\" field.");
|
|
136
|
+
}
|
|
137
|
+
return { credentialJson: json };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Generic provider writes do not own the keychain transaction. Refuse before persistence. */
|
|
142
|
+
export function providerCredentialInputError(provider: { apiKey?: unknown; apiKeyPool?: unknown }): string | undefined {
|
|
143
|
+
const values = [provider.apiKey, ...(Array.isArray(provider.apiKeyPool)
|
|
144
|
+
? provider.apiKeyPool.map(entry => entry && typeof entry === "object" ? entry.key : undefined) : [])];
|
|
145
|
+
try {
|
|
146
|
+
for (const value of values) {
|
|
147
|
+
if (typeof value !== "string") continue;
|
|
148
|
+
if (splitCredentialPaste(value).some(part => typeof part !== "string")) {
|
|
149
|
+
return "Credential JSON must be added through the provider API-key pool endpoint (/api/providers/keys), after creating the provider without a key. Nothing was saved.";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return error instanceof Error ? error.message : "Invalid credential paste";
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Store a pasted GCP credential JSON in the OS keychain and return the `gcp-sa:` marker for it.
|
|
160
|
+
* Verified write (read-back check) mirrors storeProviderKeyInKeychain; on any failure the caller
|
|
161
|
+
* receives an error instead of half-stored state.
|
|
162
|
+
*/
|
|
163
|
+
function storeGcpCredentialJson(config: OcxConfig, name: string, credentialJson: string, existedBefore: boolean): string {
|
|
164
|
+
const probe = probeProviderKeychain();
|
|
165
|
+
if (!probe.available) throw new Error(`OS keychain unavailable: ${probe.reason}`);
|
|
166
|
+
const id = apiKeyPoolEntryId(credentialJson);
|
|
167
|
+
const account = gcpCredentialStoreAccount(name, id);
|
|
168
|
+
const entry = providerKeychainEntry(account);
|
|
169
|
+
entry.setPassword(credentialJson);
|
|
170
|
+
if (entry.getPassword() !== credentialJson) {
|
|
171
|
+
// Delete on mismatch ONLY when this write created the entry: a re-paste targets an account an
|
|
172
|
+
// existing `gcp-sa:` marker already names, and deleting it would destroy the live credential
|
|
173
|
+
// the config still references.
|
|
174
|
+
if (!existedBefore) {
|
|
175
|
+
try { entry.deletePassword(); } catch { /* best effort */ }
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`keychain read-back mismatch for ${account}`);
|
|
178
|
+
}
|
|
179
|
+
return `${GCP_CREDENTIAL_MARKER_PREFIX}${account}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The credential-store account a marker names for `name`/`id` (write + cleanup share it). */
|
|
183
|
+
function gcpCredentialStoreAccount(name: string, id: string): string {
|
|
184
|
+
return `${name}/${id}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
36
187
|
/** Content-derived id: re-adding the same key upserts instead of duplicating. */
|
|
37
188
|
export function apiKeyPoolEntryId(key: string): string {
|
|
38
189
|
return createHash("sha256").update(key).digest("hex").slice(0, 8);
|
|
@@ -84,6 +235,60 @@ export function addProviderApiKey(config: OcxConfig, name: string, key: string,
|
|
|
84
235
|
const provider = config.providers[name];
|
|
85
236
|
if (!provider || !isKeyAuthProvider(provider)) return { error: "provider does not use API-key auth" };
|
|
86
237
|
if (typeof key !== "string" || !key.trim()) return { error: "key is required" };
|
|
238
|
+
// GCP credential JSON never enters the literal-key path: it is diverted to the OS keychain and
|
|
239
|
+
// only the `gcp-sa:` marker is stored. splitCredentialPaste runs FIRST (before the CRLF
|
|
240
|
+
// rejection) because pretty-printed JSON contains line breaks, and it also catches the
|
|
241
|
+
// file-path paste mistake with its own guidance before anything is stored.
|
|
242
|
+
try {
|
|
243
|
+
const parts = splitCredentialPaste(key);
|
|
244
|
+
const first = parts[0];
|
|
245
|
+
const isJsonPaste = first !== undefined && typeof first !== "string";
|
|
246
|
+
if (!isJsonPaste) {
|
|
247
|
+
// Not a credential paste — fall through to the plain API-key path below. splitCredentialPaste
|
|
248
|
+
// has already thrown for file paths; a plain key lands here as a single literal row.
|
|
249
|
+
} else {
|
|
250
|
+
const stored: Array<{ id: string; marker: string }> = [];
|
|
251
|
+
const created: string[] = [];
|
|
252
|
+
try {
|
|
253
|
+
const committed = commitProviderApiKeySelection(config, name, fresh => {
|
|
254
|
+
// Hold the same mutation lock as cleanup from the verified OS write through marker
|
|
255
|
+
// persistence. Re-run idempotent writes on rebase so a removed payload is restored.
|
|
256
|
+
stored.length = 0;
|
|
257
|
+
for (const part of parts) {
|
|
258
|
+
const credentialJson = (part as { credentialJson: string }).credentialJson;
|
|
259
|
+
const id = apiKeyPoolEntryId(credentialJson);
|
|
260
|
+
const account = gcpCredentialStoreAccount(name, id);
|
|
261
|
+
const existedBefore = providerKeychainEntry(account).getPassword() !== null;
|
|
262
|
+
if (!existedBefore) created.push(`${GCP_CREDENTIAL_MARKER_PREFIX}${account}`);
|
|
263
|
+
stored.push({ id, marker: storeGcpCredentialJson(config, name, credentialJson, existedBefore) });
|
|
264
|
+
}
|
|
265
|
+
const pool = ensurePool(fresh);
|
|
266
|
+
for (const { id, marker } of stored) {
|
|
267
|
+
const existing = pool.find(e => e.id === id);
|
|
268
|
+
if (existing) {
|
|
269
|
+
existing.key = marker;
|
|
270
|
+
if (label?.trim()) existing.label = label.trim();
|
|
271
|
+
} else {
|
|
272
|
+
pool.push({ id, key: marker, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
fresh.apiKey = stored[0]!.marker;
|
|
276
|
+
return { changed: true, selectionChanged: true, value: stored[0]!.id };
|
|
277
|
+
});
|
|
278
|
+
if (committed.status === "committed") {
|
|
279
|
+
invalidateResolvedProviderKeyCache();
|
|
280
|
+
return { id: committed.value };
|
|
281
|
+
}
|
|
282
|
+
cleanupRemovedGcpCredentials(created);
|
|
283
|
+
return { error: "provider selection unavailable" };
|
|
284
|
+
} catch (error) {
|
|
285
|
+
cleanupRemovedGcpCredentials(created);
|
|
286
|
+
return { error: "credential storage failed; configuration was not changed" };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return { error: error instanceof Error ? error.message : "credential parse failed" };
|
|
291
|
+
}
|
|
87
292
|
const trimmed = sanitizeApiKeyValue(key);
|
|
88
293
|
if (!trimmed) return { error: "key must not include line breaks" };
|
|
89
294
|
const id = apiKeyPoolEntryId(trimmed);
|
|
@@ -98,7 +303,8 @@ export function addProviderApiKey(config: OcxConfig, name: string, key: string,
|
|
|
98
303
|
fresh.apiKey = trimmed;
|
|
99
304
|
return { changed: true, selectionChanged: true, value: id };
|
|
100
305
|
});
|
|
101
|
-
|
|
306
|
+
if (committed.status !== "committed") return { error: "provider selection unavailable" };
|
|
307
|
+
return { id };
|
|
102
308
|
}
|
|
103
309
|
|
|
104
310
|
/** Switch the ACTIVE key (mirrors into `provider.apiKey`). Persists config. */
|
|
@@ -129,11 +335,13 @@ export function setProviderApiKeyLabel(config: OcxConfig, name: string, id: stri
|
|
|
129
335
|
|
|
130
336
|
/** Remove one key; removing the active one promotes the first remaining. Persists config. */
|
|
131
337
|
export function removeProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
|
|
338
|
+
let removed: string | undefined;
|
|
132
339
|
const committed = commitProviderApiKeySelection(config, name, provider => {
|
|
133
340
|
const pool = provider.apiKeyPool?.length ? provider.apiKeyPool
|
|
134
341
|
: provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
|
|
135
342
|
const entry = pool.find(e => e.id === id);
|
|
136
343
|
if (!entry) return { changed: false, value: false };
|
|
344
|
+
removed = entry.key;
|
|
137
345
|
provider.apiKeyPool = pool.filter(e => e.id !== id);
|
|
138
346
|
if (provider.apiKey === entry.key) {
|
|
139
347
|
const next = provider.apiKeyPool[0];
|
|
@@ -143,5 +351,8 @@ export function removeProviderApiKey(config: OcxConfig, name: string, id: string
|
|
|
143
351
|
if (provider.apiKeyPool.length === 0) delete provider.apiKeyPool;
|
|
144
352
|
return { changed: true, value: true };
|
|
145
353
|
});
|
|
146
|
-
|
|
354
|
+
if (committed.status !== "committed" || !committed.value) return false;
|
|
355
|
+
if (removed?.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) cleanupRemovedGcpCredentials([removed]);
|
|
356
|
+
invalidateResolvedProviderKeyCache();
|
|
357
|
+
return true;
|
|
147
358
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { markAnthropicFamilyEnumeration } from "./quota/anthropic-family-headers";
|
|
2
|
+
import { CLAUDE_CLI_USER_AGENT } from "./claude-cli-identity";
|
|
3
|
+
import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS } from "./quota-wire";
|
|
4
|
+
import type { ProviderQuota, ProviderQuotaWindow } from "./quota-types";
|
|
5
|
+
const hasQuotaRows = (q: ProviderQuota) => q.fiveHourPercent !== undefined || q.weeklyPercent !== undefined || Boolean(q.customWindows?.length);
|
|
6
|
+
|
|
7
|
+
function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null {
|
|
8
|
+
const rec = asRecord(value);
|
|
9
|
+
if (!rec) return null;
|
|
10
|
+
const percent = normalizePercent(rec.utilization);
|
|
11
|
+
const resetAt = normalizeResetAt(rec.resets_at);
|
|
12
|
+
if (percent === undefined && resetAt === undefined) return null;
|
|
13
|
+
return { percent, resetAt };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TERMINAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
17
|
+
|
|
18
|
+
function parseClaudeLimit(value: unknown): ProviderQuotaWindow | null {
|
|
19
|
+
const rec = asRecord(value);
|
|
20
|
+
if (!rec) return null;
|
|
21
|
+
const percent = normalizePercent(rec.percent);
|
|
22
|
+
if (percent === undefined) return null;
|
|
23
|
+
const scope = asRecord(rec.scope);
|
|
24
|
+
const model = asRecord(scope?.model);
|
|
25
|
+
const rawLabel = String(model?.display_name ?? "")
|
|
26
|
+
.replace(TERMINAL_CONTROL_CHARACTERS, "")
|
|
27
|
+
.trim();
|
|
28
|
+
if (!rawLabel) return null;
|
|
29
|
+
const lowerLabel = rawLabel.toLowerCase();
|
|
30
|
+
const label = lowerLabel.includes("fable") ? "Fable"
|
|
31
|
+
: lowerLabel.includes("opus") ? "Opus"
|
|
32
|
+
: lowerLabel.includes("sonnet") ? "Sonnet"
|
|
33
|
+
: null;
|
|
34
|
+
// An unrecognized display_name is never published as a quota label: stripping
|
|
35
|
+
// control characters still leaves attacker-chosen residue on the quota line.
|
|
36
|
+
if (label === null) return null;
|
|
37
|
+
const resetAt = normalizeResetAt(rec.resets_at);
|
|
38
|
+
// Model scope is proven structurally here, not guessed from text: the caller admits only
|
|
39
|
+
// `kind: "weekly_scoped"`, and a limit without a recognized `scope.model.display_name` has
|
|
40
|
+
// already returned null above. Routing keys on `scope`, never on `label`.
|
|
41
|
+
return { label, scope: "model", percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function readAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
|
|
45
|
+
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
46
|
+
headers: {
|
|
47
|
+
Accept: "application/json, text/plain, */*",
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
"User-Agent": CLAUDE_CLI_USER_AGENT,
|
|
50
|
+
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
|
|
51
|
+
Authorization: `Bearer ${accessToken}`,
|
|
52
|
+
},
|
|
53
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
54
|
+
});
|
|
55
|
+
if (!response.ok) return null;
|
|
56
|
+
const body = asRecord(await readQuotaJson(response));
|
|
57
|
+
if (!body) return null;
|
|
58
|
+
const fiveHour = parseClaudeBucket(body.five_hour);
|
|
59
|
+
const sevenDay = parseClaudeBucket(body.seven_day);
|
|
60
|
+
const fable = parseClaudeBucket(body.seven_day_fable);
|
|
61
|
+
const opus = parseClaudeBucket(body.seven_day_opus);
|
|
62
|
+
const sonnet = parseClaudeBucket(body.seven_day_sonnet);
|
|
63
|
+
const customWindows: ProviderQuotaWindow[] = [];
|
|
64
|
+
if (fable?.percent !== undefined) customWindows.push({ label: "Fable", scope: "model", percent: fable.percent, ...(fable.resetAt !== undefined ? { resetAt: fable.resetAt } : {}) });
|
|
65
|
+
if (opus?.percent !== undefined) customWindows.push({ label: "Opus", scope: "model", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) });
|
|
66
|
+
if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", scope: "model", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) });
|
|
67
|
+
const knownLabels = new Set(customWindows.map(window => window.label.toLowerCase()));
|
|
68
|
+
const limits = Array.isArray(body.limits) ? body.limits : [];
|
|
69
|
+
for (const rawLimit of limits) {
|
|
70
|
+
const limitRecord = asRecord(rawLimit);
|
|
71
|
+
// `session` and `weekly_all` mirror the canonical five-hour and weekly
|
|
72
|
+
// buckets above; only model-scoped weekly limits add a third window.
|
|
73
|
+
if (String(limitRecord?.kind ?? "").trim().toLowerCase() !== "weekly_scoped") continue;
|
|
74
|
+
const limit = parseClaudeLimit(rawLimit);
|
|
75
|
+
if (!limit || knownLabels.has(limit.label.toLowerCase())) continue;
|
|
76
|
+
knownLabels.add(limit.label.toLowerCase());
|
|
77
|
+
customWindows.push(limit);
|
|
78
|
+
}
|
|
79
|
+
const quota: ProviderQuota = {
|
|
80
|
+
// Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly
|
|
81
|
+
// rows: report it in the canonical fields so the dashboard renders it with the standard
|
|
82
|
+
// "5-hour limit" label and ordering instead of as a generic extra window.
|
|
83
|
+
...(fiveHour?.percent !== undefined ? { fiveHourPercent: fiveHour.percent } : {}),
|
|
84
|
+
...(fiveHour?.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
|
|
85
|
+
...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}),
|
|
86
|
+
...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}),
|
|
87
|
+
...(customWindows.length > 0 ? { customWindows } : {}),
|
|
88
|
+
updatedAt: Date.now(),
|
|
89
|
+
};
|
|
90
|
+
// Empty / schema-changed payloads must not cache as "success with no bars".
|
|
91
|
+
return hasQuotaRows(quota) ? markAnthropicFamilyEnumeration(quota, Array.isArray(body.limits) && body.limits.every(raw => {
|
|
92
|
+
const kind = asRecord(raw)?.kind;
|
|
93
|
+
return kind === "session" || kind === "weekly_all" || kind === "weekly_scoped" && parseClaudeLimit(raw) !== null;
|
|
94
|
+
})) : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one destination a DeepSeek Harness account token may reach. Kept free of adapter imports so
|
|
3
|
+
* config validation can enforce the same pin the adapter applies at send time.
|
|
4
|
+
*/
|
|
5
|
+
export const DSH_ACCOUNT_DEFAULT_MESSAGES_URL = "https://api.deepseek.com/anthropic/v1/messages";
|
|
6
|
+
|
|
7
|
+
export function resolveDshAccountMessagesUrl(baseUrl?: string): string {
|
|
8
|
+
if (!baseUrl || !baseUrl.trim()) {
|
|
9
|
+
return DSH_ACCOUNT_DEFAULT_MESSAGES_URL;
|
|
10
|
+
}
|
|
11
|
+
let parsed: URL;
|
|
12
|
+
try {
|
|
13
|
+
parsed = new URL(baseUrl.trim());
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error(`Untrusted dsh-account destination: "${baseUrl}". DSH credentials may only be transmitted to https://api.deepseek.com`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Pin protocol and hostname strictly to official DeepSeek API endpoint
|
|
19
|
+
if (parsed.protocol !== "https:" || parsed.hostname.toLowerCase() !== "api.deepseek.com") {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Untrusted dsh-account destination host: "${parsed.hostname}". DSH credentials may only be transmitted to https://api.deepseek.com`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pathname = parsed.pathname.replace(/\/+$/, "");
|
|
26
|
+
if (!pathname || pathname === "" || pathname === "/anthropic" || pathname === "/anthropic/v1" || pathname === "/anthropic/v1/messages") {
|
|
27
|
+
return DSH_ACCOUNT_DEFAULT_MESSAGES_URL;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new Error(`Untrusted dsh-account destination path: "${parsed.pathname}". Expected /anthropic/v1/messages.`);
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek Harness Account (dsh-account) model definitions and capabilities.
|
|
3
|
+
*
|
|
4
|
+
* Provenance:
|
|
5
|
+
* DeepSeek Harness upstream v0.2.0-rc.2 (tag: dsh-v0.2.0-rc.2, commit: 639ed01539).
|
|
6
|
+
* Directly mirrors packages/llm/llm-deepseek/src/models.ts and model-info.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const DSH_ACCOUNT_MODELS = [
|
|
10
|
+
"deepseek-flash",
|
|
11
|
+
"deepseek-v4-pro",
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export const DSH_ACCOUNT_DEFAULT_MODEL = "deepseek-flash";
|
|
15
|
+
|
|
16
|
+
export const DSH_ACCOUNT_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|
17
|
+
"deepseek-flash": 1_000_000,
|
|
18
|
+
"deepseek-v4-pro": 1_000_000,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const DSH_ACCOUNT_MODEL_INPUT_MODALITIES: Record<string, string[]> = {
|
|
22
|
+
"deepseek-flash": ["text", "image"],
|
|
23
|
+
"deepseek-v4-pro": ["text"],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const DSH_ACCOUNT_MODEL_MAX_OUTPUT_TOKENS: Record<string, number> = {
|
|
27
|
+
"deepseek-flash": 64_000,
|
|
28
|
+
"deepseek-v4-pro": 64_000,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const DSH_ACCOUNT_REASONING_EFFORTS = ["off", "low", "high", "max"] as const;
|
|
32
|
+
|
|
33
|
+
export const DSH_ACCOUNT_MODEL_REASONING_EFFORTS: Record<string, string[]> = {
|
|
34
|
+
"deepseek-flash": [...DSH_ACCOUNT_REASONING_EFFORTS],
|
|
35
|
+
"deepseek-v4-pro": [...DSH_ACCOUNT_REASONING_EFFORTS],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const DSH_ACCOUNT_MODEL_DISPLAY_NAMES: Record<string, string> = {
|
|
39
|
+
"deepseek-flash": "DeepSeek-V4.1-Flash",
|
|
40
|
+
"deepseek-v4-pro": "DeepSeek-V4-Pro",
|
|
41
|
+
};
|