@iislee/opencodex 2.11.0 → 2.35.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/AGENTS_INSTALL.md +109 -0
- package/README.md +117 -22
- package/bin/ocx.mjs +245 -88
- package/bin/package-main.mjs +1 -1
- package/gui/dist/assets/index-Be1sDpWw.css +1 -0
- package/gui/dist/assets/index-D4OKgUV4.js +114 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +21 -10
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +475 -62
- package/src/adapters/base.ts +82 -7
- package/src/adapters/client-fingerprint.ts +18 -12
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
- package/src/adapters/command-code.ts +637 -0
- package/src/adapters/cursor/call-id.ts +44 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/checkpoint-store.ts +308 -0
- package/src/adapters/cursor/cursor-errors.ts +144 -6
- package/src/adapters/cursor/discovery.ts +122 -14
- package/src/adapters/cursor/effort-map.ts +106 -4
- package/src/adapters/cursor/envelope-echo.ts +290 -0
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/http1-bidi.ts +361 -0
- package/src/adapters/cursor/images.ts +704 -0
- package/src/adapters/cursor/live-models.ts +180 -59
- package/src/adapters/cursor/live-transport.ts +623 -170
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-common.ts +23 -2
- package/src/adapters/cursor/native-exec-desktop.ts +23 -0
- package/src/adapters/cursor/native-exec-fs.ts +10 -7
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +5 -3
- package/src/adapters/cursor/native-exec.ts +121 -14
- package/src/adapters/cursor/protobuf-events.ts +829 -11
- package/src/adapters/cursor/protobuf-request.ts +447 -75
- package/src/adapters/cursor/request-builder.ts +271 -35
- package/src/adapters/cursor/tool-definitions.ts +252 -12
- package/src/adapters/cursor/tool-result-normalize.ts +115 -0
- package/src/adapters/cursor/transport.ts +22 -0
- package/src/adapters/cursor/types.ts +48 -1
- package/src/adapters/cursor.ts +336 -39
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +713 -41
- package/src/adapters/google-antigravity-wire.ts +48 -10
- package/src/adapters/google-errors.ts +44 -12
- package/src/adapters/google-http.ts +38 -10
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +742 -86
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/image.ts +1 -1
- package/src/adapters/kiro-constants.ts +27 -0
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +18 -2
- package/src/adapters/kiro-tools.ts +65 -17
- package/src/adapters/kiro.ts +270 -73
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +1377 -344
- package/src/adapters/openai-responses-url.ts +16 -0
- package/src/adapters/openai-responses.ts +1164 -64
- package/src/adapters/registry.ts +175 -0
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/adapters/run-turn-queue.ts +36 -1
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +105 -21
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/adapters/xai-web-search.ts +186 -0
- package/src/bridge.ts +512 -92
- package/src/chat/inbound.ts +57 -20
- package/src/chat/outbound.ts +97 -33
- package/src/claude/agents-inject.ts +40 -10
- package/src/claude/context-windows.ts +37 -14
- package/src/claude/desktop-3p.ts +243 -9
- package/src/claude/gateway-cache.ts +41 -4
- package/src/claude/inbound.ts +72 -3
- package/src/claude/model-info.ts +38 -15
- package/src/claude/outbound.ts +88 -21
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +150 -19
- package/src/cli/account-auth.ts +33 -6
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +648 -28
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +97 -19
- package/src/cli/agent.ts +53 -2
- package/src/cli/alias.ts +66 -0
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +496 -0
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/claude-desktop.ts +60 -15
- package/src/cli/claude.ts +129 -35
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +76 -0
- package/src/cli/combo.ts +8 -0
- package/src/cli/config-command.ts +74 -10
- package/src/cli/dispatch.ts +821 -0
- package/src/cli/doctor.ts +440 -56
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/export-command.ts +46 -20
- package/src/cli/help.ts +34 -274
- package/src/cli/index.ts +417 -528
- package/src/cli/init.ts +5 -17
- package/src/cli/inspect.ts +230 -0
- package/src/cli/integrations.ts +120 -2
- package/src/cli/lab.ts +607 -0
- package/src/cli/launcher-context.ts +77 -0
- package/src/cli/minimax.ts +497 -0
- package/src/cli/models-runtime.ts +130 -2
- package/src/cli/models.ts +118 -16
- package/src/cli/observe.ts +123 -11
- package/src/cli/opencode.ts +4 -2
- package/src/cli/provider-runtime.ts +34 -3
- package/src/cli/provider.ts +25 -3
- package/src/cli/ready.ts +301 -0
- package/src/cli/registry.ts +476 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +57 -10
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +51 -4
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/cli/usage-report.ts +184 -0
- package/src/cli/v2.ts +137 -18
- package/src/cli/version-skew.ts +46 -0
- package/src/cli.ts +1 -1
- package/src/clients/config-export.ts +1442 -23
- package/src/clients/effective-status.ts +30 -2
- package/src/clients/probes/cline.ts +81 -0
- package/src/codex/account-label.ts +35 -1
- package/src/codex/account-lifecycle.ts +130 -13
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-pause.ts +2 -1
- package/src/codex/account-priority.ts +84 -0
- package/src/codex/account-store.ts +29 -2
- package/src/codex/account-usability.ts +25 -2
- package/src/codex/admission.ts +256 -0
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/app-server-processes.ts +570 -107
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/auth-api.ts +893 -246
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/auth-context.ts +348 -32
- package/src/codex/autostart-health.ts +24 -1
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +86 -10
- package/src/codex/catalog/bundled.ts +331 -33
- package/src/codex/catalog/effort.ts +138 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +555 -45
- package/src/codex/catalog/native-models.ts +75 -0
- package/src/codex/catalog/parsing.ts +269 -30
- package/src/codex/catalog/provider-fetch.ts +1626 -144
- package/src/codex/catalog/sync.ts +1576 -187
- package/src/codex/catalog-admission.ts +199 -0
- package/src/codex/catalog-refresh-status.ts +105 -0
- package/src/codex/catalog-write-serialization.ts +242 -0
- package/src/codex/catalog.ts +6 -3
- package/src/codex/codex-write-lock.ts +386 -0
- package/src/codex/convergence-types.ts +614 -0
- package/src/codex/convergence.ts +676 -0
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/desktop-app-restart.ts +355 -0
- package/src/codex/features.ts +636 -39
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +436 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-manifest.ts +112 -0
- package/src/codex/history-migration-guardian.ts +30 -24
- package/src/codex/history-provider.ts +1016 -235
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +223 -0
- package/src/codex/inject-coordination.ts +373 -0
- package/src/codex/inject.ts +1114 -152
- package/src/codex/injected-marker.ts +37 -3
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +81 -0
- package/src/codex/journal.ts +66 -4
- package/src/codex/log-guard/inspect.ts +524 -0
- package/src/codex/log-guard/lock.ts +150 -0
- package/src/codex/log-guard/maintenance.ts +403 -0
- package/src/codex/log-guard/path-safety.ts +88 -0
- package/src/codex/log-guard/policy.ts +44 -0
- package/src/codex/log-guard/processes.ts +205 -0
- package/src/codex/log-guard/protection.ts +489 -0
- package/src/codex/log-guard/sqlite-errors.ts +9 -0
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/main-account.ts +29 -1
- package/src/codex/management-convergence.ts +167 -0
- package/src/codex/model-cache.ts +56 -10
- package/src/codex/model-entitlements.ts +360 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +178 -0
- package/src/codex/native-main-lock-file.ts +162 -0
- package/src/codex/native-main-owner.ts +329 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1531 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +622 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +675 -0
- package/src/codex/paths.ts +80 -1
- package/src/codex/plan-from-token.ts +140 -0
- package/src/codex/plan.ts +40 -0
- package/src/codex/plugins-doctor.ts +1 -1
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/project-config-warnings.ts +20 -6
- package/src/codex/prompt-journal.ts +352 -0
- package/src/codex/prompt-layers.ts +1313 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/prompt-text-probe.ts +238 -0
- package/src/codex/quota-rejection.ts +298 -0
- package/src/codex/quota.ts +264 -21
- package/src/codex/refresh.ts +11 -2
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/routing.ts +514 -94
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +1065 -31
- package/src/codex/subagent-model-fallback.ts +437 -43
- package/src/codex/sync.ts +191 -2
- package/src/codex/transition-state.ts +720 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +557 -0
- package/src/codex/warmup.ts +187 -81
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +47 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/request.ts +32 -0
- package/src/combos/types.ts +91 -9
- package/src/compatibility/index.ts +26 -0
- package/src/compatibility/manifest.ts +253 -0
- package/src/compatibility/openai-responses.ts +121 -0
- package/src/config/atomic-write.ts +219 -0
- package/src/config/paths.ts +40 -0
- package/src/config/process-state.ts +309 -0
- package/src/config/provider-name.ts +24 -0
- package/src/config/provider-validation.ts +177 -0
- package/src/config/rebase-provenance.ts +68 -0
- package/src/config.ts +1713 -709
- package/src/generated/compatibility-version.json +3324 -0
- package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
- package/src/grok/inject.ts +16 -5
- package/src/grok/inspect.ts +45 -0
- package/src/grok/sync.ts +2 -2
- package/src/images/loop.ts +157 -33
- package/src/images/plan.ts +24 -13
- package/src/integrations/config-io.ts +269 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/mutation-flight.ts +71 -0
- package/src/integrations/native/ownership-preflight.ts +202 -0
- package/src/integrations/omp-yaml-source.ts +358 -0
- package/src/integrations/owned-refresh.ts +74 -0
- package/src/integrations/ownership-policy.ts +160 -0
- package/src/integrations/ownership.ts +155 -0
- package/src/integrations/registry.ts +166 -0
- package/src/integrations/serialize.ts +314 -0
- package/src/integrations/state.ts +433 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +715 -0
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/automation/budgets.ts +78 -0
- package/src/lab/automation/config-persistence.ts +256 -0
- package/src/lab/automation/constants.ts +39 -0
- package/src/lab/automation/cooldown.ts +103 -0
- package/src/lab/automation/dispatch.ts +211 -0
- package/src/lab/automation/index.ts +13 -0
- package/src/lab/automation/orchestrator.ts +499 -0
- package/src/lab/automation/persistence.ts +512 -0
- package/src/lab/automation/planner.ts +371 -0
- package/src/lab/automation/policy.ts +136 -0
- package/src/lab/automation/queue.ts +191 -0
- package/src/lab/automation/recovery.ts +24 -0
- package/src/lab/automation/route-context.ts +21 -0
- package/src/lab/automation/run-key.ts +44 -0
- package/src/lab/automation/runs-query.ts +34 -0
- package/src/lab/automation/types.ts +160 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +64 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +164 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +68 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +214 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +229 -0
- package/src/lab/events/validate.ts +781 -0
- package/src/lab/fabric/constants.ts +40 -0
- package/src/lab/fabric/executor.ts +492 -0
- package/src/lab/fabric/index.ts +80 -0
- package/src/lab/fabric/manifest.ts +222 -0
- package/src/lab/fabric/observe.ts +489 -0
- package/src/lab/fabric/patch.ts +79 -0
- package/src/lab/fabric/producer-child.ts +139 -0
- package/src/lab/fabric/producer-isolate.ts +276 -0
- package/src/lab/fabric/producer-protocol.ts +61 -0
- package/src/lab/fabric/scratch.ts +439 -0
- package/src/lab/fabric/subject.ts +106 -0
- package/src/lab/fabric/types.ts +134 -0
- package/src/lab/fabric/verifier.ts +98 -0
- package/src/lab/index.ts +54 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +310 -0
- package/src/lab/ledger/store.ts +532 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +336 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +118 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +301 -0
- package/src/lab/observe/from-live.ts +117 -0
- package/src/lab/paths.ts +153 -0
- package/src/lab/projection/rebuild.ts +495 -0
- package/src/lab/projection/schema.ts +135 -0
- package/src/lab/projection/verdicts.ts +474 -0
- package/src/lab/projection/verification.ts +412 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +243 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lab/query/catalog.ts +101 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/freshness.ts +53 -0
- package/src/lab/query/index.ts +45 -0
- package/src/lab/query/latest-observation.ts +59 -0
- package/src/lab/query/passive-production.ts +159 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +112 -0
- package/src/lab/subject/protocol-subject.ts +80 -0
- package/src/lab/subject/route-subject.ts +74 -0
- package/src/lib/admin-secrets.ts +24 -0
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/bounded-body.ts +167 -11
- package/src/lib/bun-runtime.ts +125 -12
- package/src/lib/bun-stream-caps.ts +13 -9
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/config-ownership.ts +26 -2
- package/src/lib/destination-policy.ts +65 -1
- package/src/lib/errors.ts +80 -5
- package/src/lib/fabric-task-execution-authority.ts +7 -0
- package/src/lib/fabric-task-host.ts +29 -0
- package/src/lib/lab-activation.ts +223 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +56 -0
- package/src/lib/lab-live-route-production.ts +130 -0
- package/src/lib/lab-passive-linker-registration.ts +26 -0
- package/src/lib/local-management-attestation.ts +51 -0
- package/src/lib/local-management-capability.ts +100 -0
- package/src/lib/local-provider-reload-contract.ts +100 -0
- package/src/lib/optional-shutdown-hooks.ts +57 -0
- package/src/lib/package-tree-integrity.ts +101 -0
- package/src/lib/pinned-http.ts +145 -26
- package/src/lib/process-control.ts +6 -2
- package/src/lib/provider-outbound.ts +49 -9
- package/src/lib/redact.ts +419 -3
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/server-resource-ownership.ts +71 -0
- package/src/lib/service-secrets.ts +15 -0
- package/src/lib/shadow-call.ts +35 -4
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/state-store-registrations.ts +10 -2
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/token-estimate.ts +19 -2
- package/src/lib/tool-argument-integers.ts +253 -0
- package/src/lib/translator-budget.ts +44 -0
- package/src/lib/upstream-http-version.ts +57 -0
- package/src/lib/upstream-reachability.ts +95 -0
- package/src/lib/upstream-retry.ts +156 -3
- package/src/lib/windows-atomic-replace.ts +155 -0
- package/src/lib/windows-elevation.ts +70 -2
- package/src/lib/windows-secret-acl.ts +409 -69
- package/src/lib/windows-service-wrappers.ts +72 -0
- package/src/lib/windows-text.ts +106 -0
- package/src/lib/windows-user-principal.ts +341 -0
- package/src/lib/winsw.ts +33 -5
- package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
- package/src/oauth/account-import/index.ts +15 -0
- package/src/oauth/account-import/parser.ts +83 -0
- package/src/oauth/account-import/registry.ts +18 -0
- package/src/oauth/account-import/service.ts +75 -0
- package/src/oauth/account-import/types.ts +91 -0
- package/src/oauth/anthropic.ts +12 -1
- package/src/oauth/callback-server.ts +30 -4
- package/src/oauth/chatgpt.ts +12 -1
- package/src/oauth/cline.ts +203 -0
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +46 -5
- package/src/oauth/generic-account-failover.ts +231 -0
- package/src/oauth/google-antigravity.ts +35 -3
- package/src/oauth/health.ts +20 -12
- package/src/oauth/index.ts +462 -71
- package/src/oauth/key-providers.ts +25 -0
- package/src/oauth/kimi.ts +25 -3
- package/src/oauth/kiro.ts +95 -6
- package/src/oauth/local-token-detect.ts +13 -2
- package/src/oauth/log.ts +3 -1
- package/src/oauth/login-cli.ts +88 -28
- package/src/oauth/nous.ts +798 -0
- package/src/oauth/open-browser-choice.ts +26 -0
- package/src/oauth/store.ts +133 -26
- package/src/oauth/token-guardian.ts +9 -3
- package/src/oauth/types.ts +15 -0
- package/src/pi/models.ts +2 -2
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/antigravity-models.ts +521 -31
- package/src/providers/auto-compact-budget.ts +65 -0
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/codex-capacity.ts +292 -0
- package/src/providers/command-code-efforts.ts +176 -0
- package/src/providers/context-cap.ts +26 -8
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/default-aliases.ts +65 -0
- package/src/providers/derive.ts +281 -6
- package/src/providers/fastwire.ts +507 -0
- package/src/providers/free-directory.ts +10 -7
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/label.ts +35 -2
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +115 -22
- package/src/providers/model-presets.ts +119 -0
- package/src/providers/model-rename-migration.ts +255 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/new-model-policy.ts +146 -0
- package/src/providers/openai-sidecar.ts +72 -4
- package/src/providers/openai-tier-startup.ts +31 -2
- package/src/providers/openai-tiers.ts +119 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +30 -0
- package/src/providers/quota.ts +1379 -40
- package/src/providers/registry.ts +1545 -112
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +277 -0
- package/src/providers/slug-codec.ts +94 -6
- package/src/providers/static-model-discovery.ts +86 -0
- package/src/providers/xai-responses-opt-in.ts +15 -0
- package/src/providers/xai-transport.ts +32 -4
- package/src/reasoning-effort.ts +68 -3
- package/src/responses/apply-patch-envelope.ts +63 -0
- package/src/responses/code-mode-helper-compat.ts +50 -0
- package/src/responses/compaction.ts +26 -1
- package/src/responses/custom-tool-compat.ts +384 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/namespace-tool-compat.ts +435 -0
- package/src/responses/parser.ts +225 -38
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/provider-opaque-metadata.ts +73 -0
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +504 -0
- package/src/responses/schema.ts +12 -2
- package/src/responses/spill-store.ts +75 -10
- package/src/responses/state.ts +659 -31
- package/src/responses/thought-signature-replay.ts +364 -0
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/responses/turn-termination.ts +107 -0
- package/src/router.ts +405 -31
- package/src/routing/analytics.ts +379 -0
- package/src/routing/capability.ts +244 -0
- package/src/routing/compatibility/assemble.ts +73 -0
- package/src/routing/compatibility/behavior.ts +278 -0
- package/src/routing/compatibility/catalog.ts +99 -0
- package/src/routing/compatibility/endpoint.ts +52 -0
- package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
- package/src/routing/compatibility/policy.ts +181 -0
- package/src/routing/compatibility/provider-slot.ts +56 -0
- package/src/routing/compatibility/reader.ts +110 -0
- package/src/routing/compatibility/subject.ts +191 -0
- package/src/routing/compatibility/types.ts +64 -0
- package/src/routing/compatibility/version.ts +104 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +495 -0
- package/src/routing/health.ts +412 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +605 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +547 -0
- package/src/routing/quota.ts +155 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +776 -0
- package/src/server/adapter-resolve.ts +2 -29
- package/src/server/auth-cors.ts +289 -48
- package/src/server/background-lifecycle.ts +182 -0
- package/src/server/chat-completions.ts +154 -72
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +445 -0
- package/src/server/claude-messages.ts +160 -44
- package/src/server/direct-local-http.ts +347 -0
- package/src/server/effort-policy.ts +18 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +94 -12
- package/src/server/index.ts +1021 -190
- package/src/server/lifecycle.ts +336 -14
- package/src/server/live.ts +136 -17
- package/src/server/local-management-read-client.ts +90 -0
- package/src/server/local-provider-reload-client.ts +137 -0
- package/src/server/management/agent-settings-routes.ts +433 -116
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/body.ts +6 -0
- package/src/server/management/codex-prompt-routes.ts +570 -0
- package/src/server/management/combo-routes.ts +64 -25
- package/src/server/management/config-routes.ts +511 -51
- package/src/server/management/context.ts +89 -2
- package/src/server/management/integration-routes.ts +498 -0
- package/src/server/management/lab-automation-routes.ts +206 -0
- package/src/server/management/lab-routes.ts +563 -0
- package/src/server/management/logs-usage-routes.ts +127 -52
- package/src/server/management/model-routes.ts +461 -133
- package/src/server/management/model-rows.ts +167 -0
- package/src/server/management/native-integration-routes.ts +770 -0
- package/src/server/management/oauth-account-routes.ts +90 -8
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +836 -159
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/route-registry.ts +332 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +380 -0
- package/src/server/management/shared.ts +27 -11
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/storage-log-guard-routes.ts +186 -0
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +289 -33
- package/src/server/management/system-routes.ts +77 -0
- package/src/server/management/usage-summary-cache.ts +9 -1
- package/src/server/management/vision-sidecar-options.ts +167 -0
- package/src/server/management/web-search-sidecar-options.ts +120 -0
- package/src/server/management-api.ts +126 -23
- package/src/server/management-auth.ts +220 -5
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/port-reclaim.ts +1 -1
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +161 -10
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +221 -54
- package/src/server/relay.ts +479 -87
- package/src/server/request-decompress.ts +113 -6
- package/src/server/request-log-conversation.ts +51 -0
- package/src/server/request-log.ts +298 -31
- package/src/server/responses/agent-task-recovery-cache.ts +143 -0
- package/src/server/responses/agent-task-recovery.ts +465 -0
- package/src/server/responses/codex-auth-error.ts +55 -0
- package/src/server/responses/collaboration.ts +239 -36
- package/src/server/responses/combo-stream-preflight.ts +178 -0
- package/src/server/responses/compact.ts +444 -72
- package/src/server/responses/core.ts +3579 -436
- package/src/server/responses/empty-completion-guard.ts +333 -0
- package/src/server/responses/encrypted-payload.ts +62 -39
- package/src/server/responses/fetch-helpers.ts +89 -99
- package/src/server/responses/input-admission.ts +187 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/policy-fallback.ts +178 -0
- package/src/server/responses/responses-field-backfill.ts +343 -0
- package/src/server/responses/terminal-guard.ts +26 -5
- package/src/server/responses/upstream-error.ts +5 -0
- package/src/server/responses/ws-upstream.ts +381 -0
- package/src/server/responses-custom-tool-repair.ts +363 -0
- package/src/server/responses-item-id-repair.ts +54 -6
- package/src/server/responses-json-events.ts +90 -0
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/responses-terminal-repair.ts +363 -0
- package/src/server/responses-tool-search-repair.ts +267 -0
- package/src/server/responses-undeclared-tool-guard.ts +376 -0
- package/src/server/responses.ts +18 -2
- package/src/server/search.ts +78 -13
- package/src/server/sse-frame-buffer.ts +319 -0
- package/src/server/sse-payload-rewrite.ts +110 -22
- package/src/server/startup-action-control.ts +8 -1
- package/src/server/startup-health-cache.ts +19 -1
- package/src/server/system-env.ts +80 -9
- package/src/server/ws-bridge.ts +53 -40
- package/src/service-manager-probe.ts +892 -0
- package/src/service.ts +1153 -94
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/storage/cleanup.ts +2 -2
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +88 -23
- package/src/storage/scanner.ts +1 -1
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +83 -9
- package/src/tray/windows.ts +43 -16
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +911 -0
- package/src/types/provider.ts +614 -0
- package/src/types/request.ts +397 -0
- package/src/types/tools.ts +236 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +107 -1237
- package/src/update/index.ts +37 -23
- package/src/update/job.ts +445 -68
- package/src/update/notify.ts +12 -6
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/transactional-install.d.mts +22 -0
- package/src/update/transactional-install.mjs +266 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +287 -16
- package/src/usage/log.ts +666 -50
- package/src/usage/summary.ts +388 -17
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/anthropic-describe.ts +10 -6
- package/src/vision/backends.ts +97 -0
- package/src/vision/describe.ts +27 -16
- package/src/vision/eligibility.ts +250 -0
- package/src/vision/index.ts +238 -24
- package/src/vision/reasoning.ts +55 -0
- package/src/vision/routed-describe.ts +175 -0
- package/src/vision/timeout-bounds.ts +9 -0
- package/src/web-search/anthropic-executor.ts +13 -7
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +119 -0
- package/src/web-search/executor.ts +21 -6
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +150 -15
- package/src/web-search/loop.ts +284 -54
- package/src/web-search/parse.ts +125 -30
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/xai-executor.ts +219 -0
- package/gui/dist/assets/index-DTpMHS4F.js +0 -67
- package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
- package/src/cli/internal-dispatch.ts +0 -20
package/src/config.ts
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
6
4
|
import { Database } from "bun:sqlite";
|
|
7
5
|
import * as z from "zod/v4";
|
|
6
|
+
import { isValidProviderName, hasOwnProvider } from "./config/provider-name";
|
|
7
|
+
import {
|
|
8
|
+
apiKeyTransportConfigError,
|
|
9
|
+
booleanRecordConfigError,
|
|
10
|
+
modelAdapterRecordConfigError,
|
|
11
|
+
nonBlankStringArrayConfigError,
|
|
12
|
+
normalizeNonBlankStringArray,
|
|
13
|
+
positiveIntegerConfigError,
|
|
14
|
+
positiveIntegerRecordConfigError,
|
|
15
|
+
providerBaseUrlConfigError,
|
|
16
|
+
providerHeadersConfigError,
|
|
17
|
+
reasoningSummaryDeliveryRecordConfigError,
|
|
18
|
+
upstreamHttpVersionConfigError,
|
|
19
|
+
} from "./config/provider-validation";
|
|
20
|
+
import {
|
|
21
|
+
bumpConfigGenerationAtPath,
|
|
22
|
+
bumpCurrentConfigGeneration,
|
|
23
|
+
initializeConfigGeneration,
|
|
24
|
+
observeConfigGenerationAtPath,
|
|
25
|
+
readConfigGenerationAtPath,
|
|
26
|
+
readConfigGenerationInTransaction,
|
|
27
|
+
type ConfigGenerationObservation,
|
|
28
|
+
} from "./codex/generation";
|
|
29
|
+
import type {
|
|
30
|
+
BumpConfigGeneration,
|
|
31
|
+
ConfigGeneration,
|
|
32
|
+
ReadConfigGeneration,
|
|
33
|
+
WithExpectedConfigGenerationSync,
|
|
34
|
+
} from "./codex/convergence-types";
|
|
8
35
|
import {
|
|
9
36
|
CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
|
|
10
37
|
codexAccountNamespaceForModel,
|
|
@@ -12,234 +39,118 @@ import {
|
|
|
12
39
|
isValidCodexAccountNamespaceTarget,
|
|
13
40
|
MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
|
|
14
41
|
} from "./codex/account-namespace-match";
|
|
42
|
+
import { isCodexAccountPriorityKey } from "./codex/account-priority";
|
|
43
|
+
import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "./codex/upstream-host-health";
|
|
44
|
+
import {
|
|
45
|
+
adoptCustomModelCatalogMigration,
|
|
46
|
+
projectCustomModelCatalogMigration,
|
|
47
|
+
} from "./codex/custom-model-catalog-migration";
|
|
48
|
+
import { parseAccountPriority } from "./codex/pool-rotation";
|
|
15
49
|
import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
|
|
50
|
+
import { routingProfileIssues } from "./routing/profile";
|
|
51
|
+
import { POLICY_NAMESPACE } from "./routing/profile-namespace";
|
|
16
52
|
import {
|
|
17
|
-
|
|
53
|
+
forgetEphemeralSecretPath,
|
|
18
54
|
hardenSecretDir,
|
|
19
55
|
hardenSecretPath,
|
|
20
|
-
hardenSecretPathAsync,
|
|
21
56
|
windowsSecretAclApplies,
|
|
22
57
|
} from "./lib/windows-secret-acl";
|
|
23
58
|
import { recordOwnedConfigPath } from "./lib/config-ownership";
|
|
24
59
|
import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
|
|
25
60
|
import { providerDestinationConfigError } from "./lib/destination-policy";
|
|
61
|
+
import { redactSecretString } from "./lib/redact";
|
|
26
62
|
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
|
|
63
|
+
import { MODEL_ALIAS_PATTERN } from "./providers/default-aliases";
|
|
27
64
|
import {
|
|
28
|
-
isWirePinnedModel,
|
|
29
65
|
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
30
66
|
OPENAI_PROVIDER_TIER_VERSION,
|
|
31
|
-
|
|
67
|
+
pinnedWireAdapter,
|
|
68
|
+
UPSTREAM_HTTP_VERSION_VALUES,
|
|
32
69
|
type OcxClaudeCodeConfig,
|
|
33
70
|
type OcxConfig,
|
|
34
71
|
type OcxApiKeyEntry,
|
|
35
72
|
type OcxProviderConfig,
|
|
73
|
+
type FastWire,
|
|
74
|
+
type ProviderCostOverlay,
|
|
36
75
|
} from "./types";
|
|
37
|
-
import {
|
|
76
|
+
import { OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
|
|
77
|
+
import { modelAutoCompactTokenLimitsConfigError } from "./providers/auto-compact-budget";
|
|
78
|
+
import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire";
|
|
79
|
+
import {
|
|
80
|
+
getProviderRegistryEntry,
|
|
81
|
+
providerMatchesRegistryTransport,
|
|
82
|
+
providerModelWireDefault,
|
|
83
|
+
registryModelServiceTierCapabilityApplies,
|
|
84
|
+
} from "./providers/registry";
|
|
85
|
+
import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models";
|
|
38
86
|
import { parseDesktopProfile } from "./claude/desktop-profile";
|
|
39
|
-
import { isCodexReasoningEffort
|
|
87
|
+
import { isCodexReasoningEffort } from "./reasoning-effort";
|
|
88
|
+
import {
|
|
89
|
+
COST4_RATE_KEYS,
|
|
90
|
+
isValidCost4Rate,
|
|
91
|
+
refreshPreservedProviderOwner,
|
|
92
|
+
refreshUserCostOverlays,
|
|
93
|
+
withPreservedDiskOnlyProviders,
|
|
94
|
+
} from "./usage/user-cost-overlays";
|
|
95
|
+
import { MAX_COST4_RATE } from "./usage/expected-prices";
|
|
40
96
|
import {
|
|
41
97
|
DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES,
|
|
42
98
|
MAX_APP_OWNED_MEMORY_BUDGET_MB,
|
|
43
99
|
MIN_APP_OWNED_MEMORY_BUDGET_MB,
|
|
44
100
|
} from "./lib/app-owned-memory";
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
super("Atomic config write could not scrub or remove a secret-bearing temporary file", options);
|
|
99
|
-
this.name = "AtomicWriteSecretResidualError";
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function isMissingPathError(error: unknown): boolean {
|
|
104
|
-
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO = {
|
|
108
|
-
write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
|
|
109
|
-
harden: target => {
|
|
110
|
-
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
|
|
111
|
-
if (process.platform === "win32") hardenSecretPath(target, { required: true });
|
|
112
|
-
},
|
|
113
|
-
rename: renameAtomicFile,
|
|
114
|
-
truncate: target => truncateSync(target, 0),
|
|
115
|
-
unlink: unlinkSync,
|
|
116
|
-
}): void {
|
|
117
|
-
recordOwnedConfigPath(resolveConfigDir(), path);
|
|
118
|
-
const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
119
|
-
let hardened = false;
|
|
120
|
-
try {
|
|
121
|
-
io.write(tmp, content);
|
|
122
|
-
io.harden(tmp);
|
|
123
|
-
hardened = true;
|
|
124
|
-
io.rename(tmp, path);
|
|
125
|
-
forgetHardenedSecretPath(tmp);
|
|
126
|
-
} catch (cause) {
|
|
127
|
-
let scrubbed = false;
|
|
128
|
-
try {
|
|
129
|
-
io.truncate(tmp);
|
|
130
|
-
scrubbed = true;
|
|
131
|
-
} catch (error) {
|
|
132
|
-
if (isMissingPathError(error)) scrubbed = true;
|
|
133
|
-
else {
|
|
134
|
-
try { io.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
let removed = false;
|
|
138
|
-
try {
|
|
139
|
-
io.unlink(tmp);
|
|
140
|
-
removed = true;
|
|
141
|
-
} catch (error) {
|
|
142
|
-
if (isMissingPathError(error)) removed = true;
|
|
143
|
-
else {
|
|
144
|
-
try { io.unlink(tmp); removed = true; }
|
|
145
|
-
catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
|
|
149
|
-
if (!removed && !hardened) {
|
|
150
|
-
try { io.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
|
|
151
|
-
}
|
|
152
|
-
if (removed) forgetHardenedSecretPath(tmp);
|
|
153
|
-
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
|
|
154
|
-
throw cause;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Async atomic-write I/O: harden may await icacls without blocking the event loop (#612). */
|
|
159
|
-
export interface AtomicWriteAsyncIO {
|
|
160
|
-
write: (path: string, content: string) => void | Promise<void>;
|
|
161
|
-
harden: (path: string) => void | Promise<void>;
|
|
162
|
-
rename: (source: string, destination: string) => void | Promise<void>;
|
|
163
|
-
truncate: (path: string) => void | Promise<void>;
|
|
164
|
-
unlink: (path: string) => void | Promise<void>;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function renameAtomicFileAsync(source: string, destination: string): Promise<void> {
|
|
168
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
169
|
-
try {
|
|
170
|
-
renameSync(source, destination);
|
|
171
|
-
return;
|
|
172
|
-
} catch (error) {
|
|
173
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
174
|
-
const transientWindowsError = process.platform === "win32"
|
|
175
|
-
&& (code === "EBUSY" || code === "EPERM" || code === "EACCES");
|
|
176
|
-
if (!transientWindowsError || attempt >= 2) throw error;
|
|
177
|
-
await Bun.sleep(25 * (attempt + 1));
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Async atomic write (#612): same temp+harden+rename and residual-temp policy as
|
|
184
|
-
* atomicWriteFile, but Windows ACL harden yields the event loop. Timeout memo is keyed
|
|
185
|
-
* by the final destination path (not the unique temp, not the parent directory).
|
|
186
|
-
*/
|
|
187
|
-
export async function atomicWriteFileAsync(
|
|
188
|
-
path: string,
|
|
189
|
-
content: string,
|
|
190
|
-
io?: AtomicWriteAsyncIO,
|
|
191
|
-
): Promise<void> {
|
|
192
|
-
const effective: AtomicWriteAsyncIO = io ?? {
|
|
193
|
-
write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
|
|
194
|
-
harden: async target => {
|
|
195
|
-
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
|
|
196
|
-
if (process.platform === "win32") {
|
|
197
|
-
await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path });
|
|
198
|
-
}
|
|
199
|
-
},
|
|
200
|
-
rename: renameAtomicFileAsync,
|
|
201
|
-
truncate: target => truncateSync(target, 0),
|
|
202
|
-
unlink: unlinkSync,
|
|
203
|
-
};
|
|
204
|
-
const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
205
|
-
let hardened = false;
|
|
206
|
-
try {
|
|
207
|
-
await effective.write(tmp, content);
|
|
208
|
-
await effective.harden(tmp);
|
|
209
|
-
hardened = true;
|
|
210
|
-
await effective.rename(tmp, path);
|
|
211
|
-
forgetHardenedSecretPath(tmp);
|
|
212
|
-
} catch (cause) {
|
|
213
|
-
let scrubbed = false;
|
|
214
|
-
try {
|
|
215
|
-
await effective.truncate(tmp);
|
|
216
|
-
scrubbed = true;
|
|
217
|
-
} catch (error) {
|
|
218
|
-
if (isMissingPathError(error)) scrubbed = true;
|
|
219
|
-
else {
|
|
220
|
-
try { await effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
let removed = false;
|
|
224
|
-
try {
|
|
225
|
-
await effective.unlink(tmp);
|
|
226
|
-
removed = true;
|
|
227
|
-
} catch (error) {
|
|
228
|
-
if (isMissingPathError(error)) removed = true;
|
|
229
|
-
else {
|
|
230
|
-
try { await effective.unlink(tmp); removed = true; }
|
|
231
|
-
catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
|
|
235
|
-
if (!removed && !hardened) {
|
|
236
|
-
try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
|
|
237
|
-
}
|
|
238
|
-
if (removed) forgetHardenedSecretPath(tmp);
|
|
239
|
-
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
|
|
240
|
-
throw cause;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
101
|
+
import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy";
|
|
102
|
+
import {
|
|
103
|
+
atomicWriteFile,
|
|
104
|
+
isMissingPathError,
|
|
105
|
+
nextAtomicTempSequence,
|
|
106
|
+
} from "./config/atomic-write";
|
|
107
|
+
export {
|
|
108
|
+
AtomicWriteResidualTempError,
|
|
109
|
+
AtomicWriteSecretResidualError,
|
|
110
|
+
atomicWriteFile,
|
|
111
|
+
atomicWriteFileAsync,
|
|
112
|
+
renameAtomicFile,
|
|
113
|
+
resolveWriteTarget,
|
|
114
|
+
type AtomicRenameIO,
|
|
115
|
+
type AtomicWriteAsyncIO,
|
|
116
|
+
type AtomicWriteAsyncTestSeam,
|
|
117
|
+
type AtomicWriteIO,
|
|
118
|
+
} from "./config/atomic-write";
|
|
119
|
+
import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
|
|
120
|
+
export { expandUserPath, getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
|
|
121
|
+
export {
|
|
122
|
+
getPidPath,
|
|
123
|
+
getRuntimePortPath,
|
|
124
|
+
isOcxStartCommandLine,
|
|
125
|
+
ocxStartProcessCacheSizeForTests,
|
|
126
|
+
parsePidFile,
|
|
127
|
+
readAlivePid,
|
|
128
|
+
readPid,
|
|
129
|
+
readPidFileValue,
|
|
130
|
+
readRuntimePort,
|
|
131
|
+
removePid,
|
|
132
|
+
removePidIfValueIs,
|
|
133
|
+
removeRuntimePort,
|
|
134
|
+
removeRuntimePortIfPidIs,
|
|
135
|
+
setOcxStartProcessCacheForTests,
|
|
136
|
+
setOcxStartProcessProbeForTests,
|
|
137
|
+
setProcessCommandLineExecForTests,
|
|
138
|
+
setProcessCommandLinePlatformForTests,
|
|
139
|
+
sweepDeadOcxStartProcessCache,
|
|
140
|
+
verifyPidIdentity,
|
|
141
|
+
writePid,
|
|
142
|
+
writeRuntimePort,
|
|
143
|
+
type RuntimePortState,
|
|
144
|
+
} from "./config/process-state";
|
|
145
|
+
import {
|
|
146
|
+
clearPendingConfigTopLevelDeletions,
|
|
147
|
+
configHasRebaseProvenance,
|
|
148
|
+
configRebaseDeletionKeys,
|
|
149
|
+
CONFIG_REBASE_PROVENANCE_KEY,
|
|
150
|
+
deleteConfigTopLevelKey,
|
|
151
|
+
projectConfigRebaseProvenance,
|
|
152
|
+
} from "./config/rebase-provenance";
|
|
153
|
+
export { deleteConfigTopLevelKey } from "./config/rebase-provenance";
|
|
243
154
|
|
|
244
155
|
export class OpenAiTierBackupCleanupError extends Error {
|
|
245
156
|
constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
|
|
@@ -250,7 +161,21 @@ export class OpenAiTierBackupRollbackError extends Error {
|
|
|
250
161
|
}
|
|
251
162
|
|
|
252
163
|
export class OpenAiTierBackupCollisionError extends Error {
|
|
253
|
-
|
|
164
|
+
readonly configPath?: string;
|
|
165
|
+
constructor(configPath?: string) {
|
|
166
|
+
super("Existing OpenAI tier backup differs from the current config");
|
|
167
|
+
this.name = "OpenAiTierBackupCollisionError";
|
|
168
|
+
this.configPath = configPath;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export class OpenAiTierRollbackPreserveError extends Error {
|
|
173
|
+
readonly code?: "missing" | "not-rollback" | "mismatch" | "exhausted";
|
|
174
|
+
constructor(message: string, options?: ErrorOptions & { code?: OpenAiTierRollbackPreserveError["code"] }) {
|
|
175
|
+
super(message, options);
|
|
176
|
+
this.name = "OpenAiTierRollbackPreserveError";
|
|
177
|
+
this.code = options?.code;
|
|
178
|
+
}
|
|
254
179
|
}
|
|
255
180
|
|
|
256
181
|
export class OpenAiTierBackupSecretResidualError extends Error {
|
|
@@ -338,7 +263,7 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
338
263
|
// point would be surprising and potentially destructive.
|
|
339
264
|
const backupBytes = io.read(backup);
|
|
340
265
|
if (classifyOpenAiTierBackup(backupBytes) === "rollback") {
|
|
341
|
-
throw new OpenAiTierBackupCollisionError();
|
|
266
|
+
throw new OpenAiTierBackupCollisionError(source);
|
|
342
267
|
}
|
|
343
268
|
console.warn("[openai-provider-migration] Replacing stale pre-migration backup (post-migration config was rewritten since last migration).");
|
|
344
269
|
io.unlink(backup);
|
|
@@ -346,7 +271,7 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
346
271
|
return "reused";
|
|
347
272
|
}
|
|
348
273
|
}
|
|
349
|
-
const temp = `${backup}.ocx.${process.pid}.${
|
|
274
|
+
const temp = `${backup}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
|
|
350
275
|
let published = false;
|
|
351
276
|
let cleanupAttempted = false;
|
|
352
277
|
|
|
@@ -379,7 +304,7 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
379
304
|
}
|
|
380
305
|
}
|
|
381
306
|
}
|
|
382
|
-
if (removed)
|
|
307
|
+
if (removed) forgetEphemeralSecretPath(temp);
|
|
383
308
|
if (!removed && !scrubbed) throw new OpenAiTierBackupSecretResidualError(temp);
|
|
384
309
|
if (!removed) throw new OpenAiTierBackupCleanupError();
|
|
385
310
|
};
|
|
@@ -393,23 +318,23 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
393
318
|
} catch (cause) {
|
|
394
319
|
if (!isAlreadyExistsError(cause)) throw cause;
|
|
395
320
|
const winner = io.read(backup);
|
|
396
|
-
if (!sameBytes(original, winner)) throw new OpenAiTierBackupCollisionError();
|
|
321
|
+
if (!sameBytes(original, winner)) throw new OpenAiTierBackupCollisionError(source);
|
|
397
322
|
scrubUnpublishedTemp();
|
|
398
323
|
return "reused";
|
|
399
324
|
}
|
|
400
325
|
published = true;
|
|
401
326
|
try {
|
|
402
327
|
io.unlink(temp);
|
|
403
|
-
|
|
328
|
+
forgetEphemeralSecretPath(temp);
|
|
404
329
|
} catch (firstError) {
|
|
405
330
|
if (isMissingPathError(firstError)) {
|
|
406
|
-
|
|
331
|
+
forgetEphemeralSecretPath(temp);
|
|
407
332
|
} else try {
|
|
408
333
|
io.unlink(temp);
|
|
409
|
-
|
|
334
|
+
forgetEphemeralSecretPath(temp);
|
|
410
335
|
} catch (secondError) {
|
|
411
336
|
if (isMissingPathError(secondError)) {
|
|
412
|
-
|
|
337
|
+
forgetEphemeralSecretPath(temp);
|
|
413
338
|
return "created";
|
|
414
339
|
}
|
|
415
340
|
// temp and backup are hard links to the same inode. Roll back the backup
|
|
@@ -429,103 +354,198 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
429
354
|
}
|
|
430
355
|
}
|
|
431
356
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
export function expandUserPath(raw: string): string {
|
|
438
|
-
if (raw === "~") return homedir();
|
|
439
|
-
if (raw.startsWith("~/") || raw.startsWith("~\\")) return join(homedir(), raw.slice(2));
|
|
440
|
-
return raw;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null;
|
|
444
|
-
|
|
445
|
-
function resolveConfigDir(): string {
|
|
446
|
-
const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined;
|
|
447
|
-
if (resolvedConfigDirCache && resolvedConfigDirCache.raw === raw) return resolvedConfigDirCache.path;
|
|
448
|
-
const path = raw ? resolve(expandUserPath(raw)) : join(homedir(), ".opencodex");
|
|
449
|
-
resolvedConfigDirCache = { raw, path };
|
|
450
|
-
return path;
|
|
357
|
+
export interface OpenAiTierRollbackPreserveIO {
|
|
358
|
+
exists(path: string): boolean;
|
|
359
|
+
read(path: string): Uint8Array;
|
|
360
|
+
copyExclusive(source: string, destination: string): void;
|
|
361
|
+
unlink(path: string): void;
|
|
451
362
|
}
|
|
452
363
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
364
|
+
const DEFAULT_ROLLBACK_PRESERVE_IO: OpenAiTierRollbackPreserveIO = {
|
|
365
|
+
exists: existsSync,
|
|
366
|
+
read: target => readFileSync(target),
|
|
367
|
+
copyExclusive: (source, destination) => {
|
|
368
|
+
copyFileSync(source, destination, fsConstants.COPYFILE_EXCL);
|
|
369
|
+
},
|
|
370
|
+
unlink: unlinkSync,
|
|
371
|
+
};
|
|
456
372
|
|
|
457
|
-
|
|
458
|
-
return join(resolveConfigDir(), "ocx.pid");
|
|
459
|
-
}
|
|
373
|
+
const OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS = 16;
|
|
460
374
|
|
|
461
|
-
|
|
462
|
-
|
|
375
|
+
/**
|
|
376
|
+
* Copy a rollback-classified `.pre-openai-tiers-v2.bak` to a unique
|
|
377
|
+
* `.pre-openai-tiers-v1-rollback.<timestamp>[suffix].bak` path, then unlink the
|
|
378
|
+
* blocking v2 name. The original bytes are copied with no-replace publication;
|
|
379
|
+
* the v2 path is removed only after the copy is verified. Shared by startup
|
|
380
|
+
* migration recovery and `ocx init` cleanup so the two paths cannot drift.
|
|
381
|
+
*/
|
|
382
|
+
export function preserveOpenAiTierRollbackSnapshot(
|
|
383
|
+
configPath = getConfigPath(),
|
|
384
|
+
io: OpenAiTierRollbackPreserveIO = DEFAULT_ROLLBACK_PRESERVE_IO,
|
|
385
|
+
): string {
|
|
386
|
+
const backup = `${configPath}.pre-openai-tiers-v2.bak`;
|
|
387
|
+
if (!io.exists(backup)) {
|
|
388
|
+
throw new OpenAiTierRollbackPreserveError("OpenAI tier rollback backup is missing", { code: "missing" });
|
|
389
|
+
}
|
|
390
|
+
const original = io.read(backup);
|
|
391
|
+
if (classifyOpenAiTierBackup(original) !== "rollback") {
|
|
392
|
+
throw new OpenAiTierRollbackPreserveError("OpenAI tier backup is not a rollback snapshot", { code: "not-rollback" });
|
|
393
|
+
}
|
|
394
|
+
for (let attempt = 0; attempt < OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS; attempt++) {
|
|
395
|
+
const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`;
|
|
396
|
+
try {
|
|
397
|
+
io.copyExclusive(backup, preserved);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (isAlreadyExistsError(error)) continue;
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
let copied: Uint8Array;
|
|
403
|
+
try {
|
|
404
|
+
copied = io.read(preserved);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
throw new OpenAiTierRollbackPreserveError("Failed to read preserved rollback snapshot", { cause: error, code: "mismatch" });
|
|
407
|
+
}
|
|
408
|
+
if (!sameBytes(original, copied)) {
|
|
409
|
+
try { io.unlink(preserved); } catch { /* keep the original backup; incomplete copy is best-effort */ }
|
|
410
|
+
throw new OpenAiTierRollbackPreserveError("Preserved rollback snapshot does not match source bytes", { code: "mismatch" });
|
|
411
|
+
}
|
|
412
|
+
io.unlink(backup);
|
|
413
|
+
return preserved;
|
|
414
|
+
}
|
|
415
|
+
throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback snapshot path", { code: "exhausted" });
|
|
463
416
|
}
|
|
464
417
|
|
|
465
418
|
const warnedConfigFallbacks = new Set<string>();
|
|
419
|
+
const warnedInheritedFastWireConflicts = new Set<string>();
|
|
466
420
|
let lastWarningReconciledGeneration = 0;
|
|
467
421
|
|
|
468
422
|
export function reconcileConfigWarningMemos(generation: number): number {
|
|
469
423
|
if (generation <= lastWarningReconciledGeneration) return 0;
|
|
470
|
-
const removed = warnedConfigFallbacks.size;
|
|
424
|
+
const removed = warnedConfigFallbacks.size + warnedInheritedFastWireConflicts.size;
|
|
471
425
|
warnedConfigFallbacks.clear();
|
|
426
|
+
warnedInheritedFastWireConflicts.clear();
|
|
472
427
|
lastWarningReconciledGeneration = generation;
|
|
473
428
|
return removed;
|
|
474
429
|
}
|
|
475
430
|
|
|
431
|
+
/**
|
|
432
|
+
* Bounds for the opt-in same-target 429 wait-and-retry policy. Single source of truth
|
|
433
|
+
* shared by the config schema, the load-time sanitizer, and the management write
|
|
434
|
+
* boundary. Strict, so an unknown key is rejected at every validation boundary instead
|
|
435
|
+
* of being silently ignored (the load-time sanitizer still degrades unknown keys with a
|
|
436
|
+
* warning before schema validation, so hand-edited configs keep loading).
|
|
437
|
+
*/
|
|
438
|
+
const retryOn429PolicySchema = z.object({
|
|
439
|
+
enabled: z.boolean().optional(),
|
|
440
|
+
attempts: z.number().int().min(1).max(20).optional(),
|
|
441
|
+
intervalMs: z.number().int().min(100).max(600_000).optional(),
|
|
442
|
+
// The effective cap for a single wait is MAX_COOLDOWN_MS (10 min) in key-failover.ts;
|
|
443
|
+
// larger configured values would be dead config.
|
|
444
|
+
maxIntervalMs: z.number().int().min(100).max(600_000).optional(),
|
|
445
|
+
respectRetryAfter: z.boolean().optional(),
|
|
446
|
+
}).strict();
|
|
447
|
+
|
|
448
|
+
const requestPacingRuleSchema = z.object({
|
|
449
|
+
// Keep the RPM-derived timer within the same one-hour bound as minIntervalMs.
|
|
450
|
+
requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
|
|
451
|
+
minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
|
|
452
|
+
}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, {
|
|
453
|
+
message: "request pacing rules need requestsPerMinute or minIntervalMs",
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const requestPacingSchema = z.object({
|
|
457
|
+
enabled: z.boolean(),
|
|
458
|
+
requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
|
|
459
|
+
minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
|
|
460
|
+
models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(),
|
|
461
|
+
}).strict().refine(value => value.enabled === false
|
|
462
|
+
|| value.requestsPerMinute !== undefined
|
|
463
|
+
|| value.minIntervalMs !== undefined
|
|
464
|
+
|| (value.models !== undefined && Object.keys(value.models).length > 0), {
|
|
465
|
+
message: "enabled request pacing needs a provider rule or model override",
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
export function requestPacingConfigError(value: unknown): string | null {
|
|
469
|
+
if (value === undefined) return null;
|
|
470
|
+
const parsed = requestPacingSchema.safeParse(value);
|
|
471
|
+
if (parsed.success) return null;
|
|
472
|
+
return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides";
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const fastWireSchema = z.object({
|
|
476
|
+
kind: z.string(),
|
|
477
|
+
canonicalToWire: z.record(z.string().trim(), z.string().trim()),
|
|
478
|
+
foreignCallerTiers: z.string(),
|
|
479
|
+
betas: z.array(z.string().trim()).optional(),
|
|
480
|
+
}).strict().superRefine((fastWire, ctx) => {
|
|
481
|
+
const error = fastWireDeclarationError({ fastWire });
|
|
482
|
+
if (error) ctx.addIssue({ code: "custom", message: error });
|
|
483
|
+
}).transform(fastWire => fastWire as FastWire);
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Zod schema for one provider entry: known fields are validated strictly while unknown
|
|
487
|
+
* fields pass through (preserved for runtime extensions).
|
|
488
|
+
*/
|
|
476
489
|
const providerConfigSchema = z.object({
|
|
477
490
|
adapter: z.string().min(1),
|
|
478
491
|
baseUrl: z.string().min(1),
|
|
492
|
+
alias: z.string().optional(),
|
|
493
|
+
modelAliases: z.record(z.string(), z.string()).optional(),
|
|
494
|
+
defaultAliases: z.boolean().optional(),
|
|
495
|
+
requestPacing: requestPacingSchema.optional().catch(undefined),
|
|
479
496
|
mcpMaxTools: z.number().int().positive().optional(),
|
|
480
497
|
mcpMaxSchemaBytes: z.number().int().positive().optional(),
|
|
481
498
|
mcpMaxResultBytes: z.number().int().positive().optional(),
|
|
482
499
|
apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
|
|
483
500
|
responsesPath: z.string().min(1).optional(),
|
|
484
501
|
statelessResponses: z.boolean().optional(),
|
|
502
|
+
requiresAdjacentResponsesToolResults: z.boolean().optional(),
|
|
503
|
+
fastWire: fastWireSchema.nullable().optional(),
|
|
504
|
+
supportsServiceTier: z.boolean().optional(),
|
|
505
|
+
modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
|
|
506
|
+
preserveResponsesReasoningContent: z.boolean().optional(),
|
|
507
|
+
decodesNativeCompactionBlobs: z.boolean().optional(),
|
|
485
508
|
allowPrivateNetwork: z.boolean().optional(),
|
|
509
|
+
// The management API accepts `null` as "clear this", so a config written before the POST
|
|
510
|
+
// canonicalization below can hold one on disk. Rejecting it here would send the operator
|
|
511
|
+
// through invalid-config recovery for a value the API told them was fine.
|
|
512
|
+
upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES)
|
|
513
|
+
.nullish()
|
|
514
|
+
.transform(value => value ?? undefined),
|
|
515
|
+
directGeminiWireRenames: z.boolean().optional(),
|
|
516
|
+
noStructuredOutputModels: z.array(z.string().min(1))
|
|
517
|
+
.transform(normalizeNonBlankStringArray)
|
|
518
|
+
.optional(),
|
|
519
|
+
retryOn429: retryOn429PolicySchema.optional(),
|
|
486
520
|
codexAccountMode: z.enum(["pool", "direct"]).optional(),
|
|
521
|
+
// Validated rather than passed through: this schema ends in `.passthrough()`, so an
|
|
522
|
+
// undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be
|
|
523
|
+
// accepted, persisted, and then silently resolved to the `code_mode_only` default — the
|
|
524
|
+
// operator asked for shell mode, got code mode, and was told nothing (#2106).
|
|
525
|
+
codexToolMode: z.enum(["code_mode_only", "shell"]).optional(),
|
|
487
526
|
responsesItemIdRepair: z.object({
|
|
488
527
|
message: z.array(z.string().min(1)).optional(),
|
|
489
528
|
reasoning: z.array(z.string().min(1)).optional(),
|
|
490
529
|
repairMissingTerminalIds: z.boolean().optional(),
|
|
530
|
+
repairInvalidIds: z.boolean().optional(),
|
|
491
531
|
}).strict().optional(),
|
|
532
|
+
responsesSnapshotRepair: z.boolean().optional(),
|
|
492
533
|
}).passthrough();
|
|
493
534
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
const trimmed = name.trim();
|
|
509
|
-
return trimmed === name
|
|
510
|
-
&& PROVIDER_NAME_PATTERN.test(name)
|
|
511
|
-
&& !RESERVED_PROVIDER_NAMES.has(name.toLowerCase());
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
export function hasOwnProvider(providers: Record<string, unknown>, name: string): boolean {
|
|
515
|
-
return Object.prototype.hasOwnProperty.call(providers, name);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
export function providerBaseUrlConfigError(baseUrl: string): string | null {
|
|
519
|
-
try {
|
|
520
|
-
const parsed = new URL(baseUrl.trim());
|
|
521
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "baseUrl must be an http(s) URL";
|
|
522
|
-
if (parsed.username || parsed.password) return "baseUrl must not include embedded credentials";
|
|
523
|
-
if (parsed.search || parsed.hash) return "baseUrl must not include query strings or fragments";
|
|
524
|
-
} catch {
|
|
525
|
-
return "baseUrl must be a valid URL";
|
|
526
|
-
}
|
|
527
|
-
return null;
|
|
528
|
-
}
|
|
535
|
+
export { isValidProviderName, hasOwnProvider } from "./config/provider-name";
|
|
536
|
+
export {
|
|
537
|
+
apiKeyTransportConfigError,
|
|
538
|
+
booleanRecordConfigError,
|
|
539
|
+
modelAdapterRecordConfigError,
|
|
540
|
+
nonBlankStringArrayConfigError,
|
|
541
|
+
normalizeNonBlankStringArray,
|
|
542
|
+
positiveIntegerConfigError,
|
|
543
|
+
positiveIntegerRecordConfigError,
|
|
544
|
+
providerBaseUrlConfigError,
|
|
545
|
+
providerHeadersConfigError,
|
|
546
|
+
reasoningSummaryDeliveryRecordConfigError,
|
|
547
|
+
upstreamHttpVersionConfigError,
|
|
548
|
+
} from "./config/provider-validation";
|
|
529
549
|
|
|
530
550
|
function providerResponsesPathConfigError(responsesPath: string | undefined): string | null {
|
|
531
551
|
if (responsesPath === undefined) return null;
|
|
@@ -539,128 +559,164 @@ function providerResponsesPathConfigError(responsesPath: string | undefined): st
|
|
|
539
559
|
return null;
|
|
540
560
|
}
|
|
541
561
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
if (SENSITIVE_PROVIDER_HEADERS.has(normalized)) return `headers must not include sensitive header "${name}"; use apiKey/authMode instead`;
|
|
549
|
-
if (typeof value !== "string") return `header "${name}" value must be a string`;
|
|
550
|
-
if (/[\r\n]/.test(value)) return `header "${name}" value must not include line breaks`;
|
|
551
|
-
}
|
|
552
|
-
return null;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
|
|
556
|
-
export function apiKeyTransportConfigError(
|
|
557
|
-
provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
|
|
558
|
-
): string | null {
|
|
559
|
-
if (provider.apiKeyTransport === undefined) return null;
|
|
560
|
-
if (provider.apiKeyTransport !== "x-api-key" && provider.apiKeyTransport !== "bearer") {
|
|
561
|
-
return 'apiKeyTransport must be "x-api-key" or "bearer"';
|
|
562
|
-
}
|
|
563
|
-
if (provider.adapter !== "anthropic") {
|
|
564
|
-
return "apiKeyTransport is supported only by the anthropic adapter";
|
|
565
|
-
}
|
|
566
|
-
if (provider.authMode === "oauth" || provider.authMode === "forward" || provider.authMode === "local") {
|
|
567
|
-
return "apiKeyTransport requires Anthropic API-key authentication";
|
|
568
|
-
}
|
|
569
|
-
return null;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null {
|
|
562
|
+
/**
|
|
563
|
+
* Validate `providers.<name>.modelCosts`: a plain object keyed by exact model
|
|
564
|
+
* id, each value a 4-tuple of non-negative finite USD-per-1M-token rates.
|
|
565
|
+
* Returns null when valid/absent, else a human-readable error.
|
|
566
|
+
*/
|
|
567
|
+
export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null {
|
|
573
568
|
if (value === undefined) return null;
|
|
574
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
for (const [
|
|
578
|
-
if (!
|
|
579
|
-
|
|
580
|
-
|
|
569
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
570
|
+
return `${field} must be a plain object keyed by model id`;
|
|
571
|
+
}
|
|
572
|
+
for (const [modelId, entry] of Object.entries(value)) {
|
|
573
|
+
if (!modelId.trim()) return `${field} keys must be nonblank model ids`;
|
|
574
|
+
// Redact secret-shaped model ids and JSON-escape control characters so a
|
|
575
|
+
// malformed write cannot echo a pasted key/secret back through the
|
|
576
|
+
// management API response.
|
|
577
|
+
const safeModelId = JSON.stringify(redactSecretString(modelId));
|
|
578
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
579
|
+
return `${field}.${safeModelId} must be an object with input, output, cacheRead, and cacheWrite (USD per 1M tokens)`;
|
|
581
580
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
589
|
-
return `${field} must be a positive finite integer`;
|
|
590
|
-
}
|
|
591
|
-
return null;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
export function booleanRecordConfigError(value: unknown, field: string): string | null {
|
|
595
|
-
if (value === undefined) return null;
|
|
596
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
597
|
-
const prototype = Object.getPrototypeOf(value);
|
|
598
|
-
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
599
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
600
|
-
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
601
|
-
if (typeof entry !== "boolean") return `${field}.${key} must be a boolean`;
|
|
602
|
-
}
|
|
603
|
-
return null;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
const REASONING_SUMMARY_DELIVERY_SET = new Set<string>(REASONING_SUMMARY_DELIVERY_VALUES);
|
|
607
|
-
|
|
608
|
-
export function reasoningSummaryDeliveryRecordConfigError(
|
|
609
|
-
value: unknown,
|
|
610
|
-
supportsReasoningSummaries: unknown,
|
|
611
|
-
field = "modelReasoningSummaryDelivery",
|
|
612
|
-
): string | null {
|
|
613
|
-
if (value === undefined) return null;
|
|
614
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
615
|
-
const prototype = Object.getPrototypeOf(value);
|
|
616
|
-
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
617
|
-
|
|
618
|
-
const supports = booleanRecordConfigError(supportsReasoningSummaries, "modelSupportsReasoningSummaries") === null
|
|
619
|
-
&& supportsReasoningSummaries && typeof supportsReasoningSummaries === "object"
|
|
620
|
-
? supportsReasoningSummaries as Record<string, boolean>
|
|
621
|
-
: undefined;
|
|
622
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
623
|
-
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
624
|
-
if (typeof entry !== "string" || !REASONING_SUMMARY_DELIVERY_SET.has(entry)) {
|
|
625
|
-
return `${field}.${key} must be one of: ${REASONING_SUMMARY_DELIVERY_VALUES.join(", ")}`;
|
|
581
|
+
const rates = entry as Record<string, unknown>;
|
|
582
|
+
for (const key of COST4_RATE_KEYS) {
|
|
583
|
+
const rate = rates[key];
|
|
584
|
+
if (!isValidCost4Rate(rate)) {
|
|
585
|
+
return `${field}.${safeModelId}.${key} must be a non-negative finite number at most ${MAX_COST4_RATE} (USD per 1M tokens)`;
|
|
586
|
+
}
|
|
626
587
|
}
|
|
627
|
-
|
|
628
|
-
|
|
588
|
+
// Reject unknown fields: a misplaced apiKey/apiKeyPool under a cost row
|
|
589
|
+
// would otherwise be persisted and echoed verbatim by display paths that
|
|
590
|
+
// mask only top-level provider secrets.
|
|
591
|
+
const extraKeys = Object.keys(rates)
|
|
592
|
+
.filter((key) => !(COST4_RATE_KEYS as readonly string[]).includes(key));
|
|
593
|
+
if (extraKeys.length > 0) {
|
|
594
|
+
return `${field}.${safeModelId} has unexpected fields ${JSON.stringify(extraKeys.map(redactSecretString).join(", "))} — only input, output, cacheRead, and cacheWrite are allowed (USD per 1M tokens)`;
|
|
629
595
|
}
|
|
630
596
|
}
|
|
631
597
|
return null;
|
|
632
598
|
}
|
|
633
599
|
|
|
634
600
|
/**
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
* override on a canonical forward provider (where switching wires would drop the
|
|
640
|
-
* caller's forwarded credential). Silently dropping them would leave the user
|
|
641
|
-
* believing an override is in effect.
|
|
601
|
+
* Serialize `providers.<name>.modelCosts` for display: copy ONLY the four
|
|
602
|
+
* numeric rate fields per model and DROP secret-shaped model ids, so a pasted
|
|
603
|
+
* API key in a key position cannot be echoed back by CLI/DTO display paths.
|
|
604
|
+
* The result uses a null prototype so "__proto__" remains an own row.
|
|
642
605
|
*/
|
|
643
|
-
export function
|
|
606
|
+
export function sanitizeModelCostsForDisplay(costs: unknown): Record<string, ProviderCostOverlay> | undefined {
|
|
607
|
+
if (!costs || typeof costs !== "object" || Array.isArray(costs)) return undefined;
|
|
608
|
+
const out = Object.create(null) as Record<string, ProviderCostOverlay>;
|
|
609
|
+
for (const [modelId, entry] of Object.entries(costs)) {
|
|
610
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
611
|
+
const rates = entry as Record<string, unknown>;
|
|
612
|
+
const input = rates.input;
|
|
613
|
+
const output = rates.output;
|
|
614
|
+
const cacheRead = rates.cacheRead;
|
|
615
|
+
const cacheWrite = rates.cacheWrite;
|
|
616
|
+
if (
|
|
617
|
+
isValidCost4Rate(input)
|
|
618
|
+
&& isValidCost4Rate(output)
|
|
619
|
+
&& isValidCost4Rate(cacheRead)
|
|
620
|
+
&& isValidCost4Rate(cacheWrite)
|
|
621
|
+
) {
|
|
622
|
+
// Secret-shaped ids are DROPPED rather than mapped to "[REDACTED]" so
|
|
623
|
+
// distinct rows cannot collapse into one placeholder key.
|
|
624
|
+
if (redactSecretString(modelId) !== modelId) continue;
|
|
625
|
+
out[modelId] = { input, output, cacheRead, cacheWrite };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]);
|
|
632
|
+
|
|
633
|
+
export function modelPreferHostedToolsConfigError(
|
|
644
634
|
value: unknown,
|
|
645
635
|
field: string,
|
|
646
636
|
providerName: string,
|
|
647
|
-
provider: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown },
|
|
637
|
+
provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown; baseUrl?: unknown },
|
|
648
638
|
): string | null {
|
|
649
639
|
if (value === undefined) return null;
|
|
650
640
|
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
651
641
|
const prototype = Object.getPrototypeOf(value);
|
|
652
642
|
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
653
643
|
const entries = Object.entries(value);
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
644
|
+
const registry = getProviderRegistryEntry(providerName);
|
|
645
|
+
// Effective transport: a `preserveCustomDestination` registry row reused under a
|
|
646
|
+
// different endpoint keeps its own adapter AND its own auth at runtime, because
|
|
647
|
+
// `routedProviderConfig()` honors `providerMatchesRegistryTransport()`. Both the
|
|
648
|
+
// wire check below and the forward-auth check here have to start from the same
|
|
649
|
+
// decision, or validation accepts a preference the adapter never applies —
|
|
650
|
+
// `preferConfiguredHostedTools()` runs only on the non-forward branch.
|
|
651
|
+
const registryTransportMatches = typeof provider.baseUrl === "string"
|
|
652
|
+
&& providerMatchesRegistryTransport(providerName, {
|
|
653
|
+
baseUrl: provider.baseUrl,
|
|
654
|
+
adapter: provider.adapter as OcxProviderConfig["adapter"],
|
|
655
|
+
...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
|
|
656
|
+
});
|
|
657
|
+
const effectiveForwardAuth = registryTransportMatches
|
|
658
|
+
? registry?.authKind === "forward"
|
|
659
|
+
: provider.authMode === "forward";
|
|
660
|
+
if (entries.length > 0 && effectiveForwardAuth) {
|
|
661
|
+
return `${field} is not supported on forward-auth Responses providers`;
|
|
662
|
+
}
|
|
663
|
+
const requestedWireFor = (modelId: string): unknown => provider.modelAdapters
|
|
664
|
+
&& typeof provider.modelAdapters === "object"
|
|
665
|
+
&& !Array.isArray(provider.modelAdapters)
|
|
666
|
+
? (provider.modelAdapters as Record<string, unknown>)[modelId]
|
|
667
|
+
: undefined;
|
|
668
|
+
const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => {
|
|
669
|
+
const pinned = pinnedWireAdapter(providerName, modelId);
|
|
670
|
+
if (pinned) return pinned;
|
|
671
|
+
const requestedWire = requestedWireFor(modelId);
|
|
672
|
+
if (typeof requestedWire === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requestedWire)) {
|
|
673
|
+
return requestedWire;
|
|
674
|
+
}
|
|
675
|
+
// No explicit override: fall back to the registry's per-model wire default before
|
|
676
|
+
// the provider-wide adapter, because that is the order `resolveModelAdapter()`
|
|
677
|
+
// uses at request time (src/server/adapter-resolve.ts:38-48). Skipping it rejected
|
|
678
|
+
// preferences the runtime would have honored — DeepSeek routes `deepseek-v4-flash`
|
|
679
|
+
// over native Responses for a Responses inbound while the provider-wide wire stays
|
|
680
|
+
// openai-chat. Hosted-tool preferences only apply to Responses traffic, so the
|
|
681
|
+
// inbound to ask about is "responses".
|
|
682
|
+
const registryDefault = typeof currentWire === "string" && typeof provider.baseUrl === "string"
|
|
683
|
+
? providerModelWireDefault(
|
|
684
|
+
providerName,
|
|
685
|
+
{
|
|
686
|
+
baseUrl: provider.baseUrl,
|
|
687
|
+
adapter: currentWire,
|
|
688
|
+
...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
|
|
689
|
+
},
|
|
690
|
+
modelId,
|
|
691
|
+
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
692
|
+
"responses",
|
|
693
|
+
)
|
|
694
|
+
: undefined;
|
|
695
|
+
return registryDefault ?? currentWire;
|
|
696
|
+
};
|
|
657
697
|
for (const [key, entry] of entries) {
|
|
658
698
|
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
659
|
-
if (
|
|
660
|
-
|
|
699
|
+
if (!Array.isArray(entry)) return `${field}.${key} must be an array`;
|
|
700
|
+
if (entry.length === 0) return `${field}.${key} must include image_generation`;
|
|
701
|
+
for (const tool of entry) {
|
|
702
|
+
if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) {
|
|
703
|
+
return `${field}.${key} supports only image_generation`;
|
|
704
|
+
}
|
|
705
|
+
if (isHostedToolUnsupportedForModel(key, tool)) {
|
|
706
|
+
return `${field}.${key} cannot prefer ${tool}: the model does not support it`;
|
|
707
|
+
}
|
|
661
708
|
}
|
|
662
|
-
|
|
663
|
-
|
|
709
|
+
// Same `registryTransportMatches` decision the forward-auth check above uses:
|
|
710
|
+
// start from the registry adapter only when this config still points at the
|
|
711
|
+
// registry's documented transport.
|
|
712
|
+
const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter;
|
|
713
|
+
let effectiveWire = resolveEffectiveWire(key, baseWire);
|
|
714
|
+
const virtualWireModel = resolveOpenAiVirtualModel(providerName, key)?.wireModelId;
|
|
715
|
+
if (virtualWireModel && virtualWireModel !== key) {
|
|
716
|
+
effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire);
|
|
717
|
+
}
|
|
718
|
+
if (effectiveWire !== "openai-responses") {
|
|
719
|
+
return `${field}.${key} requires the openai-responses wire`;
|
|
664
720
|
}
|
|
665
721
|
}
|
|
666
722
|
return null;
|
|
@@ -712,6 +768,33 @@ const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
|
|
|
712
768
|
}
|
|
713
769
|
}).pipe(z.record(z.string(), z.string()));
|
|
714
770
|
|
|
771
|
+
const CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR =
|
|
772
|
+
"codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers";
|
|
773
|
+
const CODEX_ACCOUNT_PRIORITY_KEY_ERROR =
|
|
774
|
+
"selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys";
|
|
775
|
+
const CODEX_ACCOUNT_PRIORITY_VALUE_ERROR =
|
|
776
|
+
"selection order must be an integer between -100 and 100";
|
|
777
|
+
|
|
778
|
+
const CODEX_ACCOUNT_PIN_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
779
|
+
|
|
780
|
+
const codexAccountPrioritiesSchema = z.custom<Record<string, unknown>>(
|
|
781
|
+
(value): value is Record<string, unknown> => !!value
|
|
782
|
+
&& typeof value === "object"
|
|
783
|
+
&& !Array.isArray(value)
|
|
784
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
|
|
785
|
+
{ error: CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR },
|
|
786
|
+
).superRefine((priorities, ctx) => {
|
|
787
|
+
// Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
|
|
788
|
+
for (const [accountId, priority] of Object.entries(priorities)) {
|
|
789
|
+
if (!isCodexAccountPriorityKey(accountId)) {
|
|
790
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_KEY_ERROR });
|
|
791
|
+
}
|
|
792
|
+
if (parseAccountPriority(priority) === null) {
|
|
793
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_VALUE_ERROR });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}).pipe(z.record(z.string(), z.number().int()));
|
|
797
|
+
|
|
715
798
|
/**
|
|
716
799
|
* Deliberately permissive. A user's config is not ours to invalidate: a strict
|
|
717
800
|
* entry fails the whole parse, and loadConfig's fallback then backs the file up
|
|
@@ -742,9 +825,37 @@ const apiKeyEntrySchema = z.object({
|
|
|
742
825
|
createdAt: z.string().catch(""),
|
|
743
826
|
}).passthrough();
|
|
744
827
|
|
|
828
|
+
/**
|
|
829
|
+
* Durable per-client intent.
|
|
830
|
+
*
|
|
831
|
+
* `.passthrough()` is load-bearing: a binary that only knows `codex` must not
|
|
832
|
+
* erase a key a later version wrote during a field-scoped mutation. And each key
|
|
833
|
+
* degrades on its own — a hand edit of `{"codex": "false", "future": false}`
|
|
834
|
+
* drops `codex` to absent (which reads as ON) and keeps `future`, rather than
|
|
835
|
+
* invalidating the object or, worse, the whole config.
|
|
836
|
+
*/
|
|
837
|
+
const clientIntegrationsSchema = z.object({
|
|
838
|
+
codex: z.boolean().optional().catch(undefined),
|
|
839
|
+
grok: z.boolean().optional().catch(undefined),
|
|
840
|
+
"claude-desktop": z.boolean().optional().catch(undefined),
|
|
841
|
+
}).passthrough();
|
|
842
|
+
|
|
843
|
+
const agentTaskRecoverySchema = z.object({
|
|
844
|
+
enabled: z.boolean().optional(),
|
|
845
|
+
model: z.string().trim().min(1).optional(),
|
|
846
|
+
timeoutMs: z.number().int().min(1_000).max(120_000).optional(),
|
|
847
|
+
cacheEntries: z.number().int().min(1).max(512).optional(),
|
|
848
|
+
}).strict();
|
|
849
|
+
|
|
745
850
|
const configSchema = z.object({
|
|
746
851
|
port: z.number().int().min(0).max(65535).default(10100),
|
|
747
852
|
managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
|
|
853
|
+
// Invalid hand edits disable only this opt-in circuit. Live writes remain strict.
|
|
854
|
+
upstreamHostCircuitThreshold: z.number().int()
|
|
855
|
+
.min(0)
|
|
856
|
+
.max(UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD)
|
|
857
|
+
.optional()
|
|
858
|
+
.catch(undefined),
|
|
748
859
|
appOwnedMemoryBudgetMb: z.number().int()
|
|
749
860
|
.min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
|
|
750
861
|
.max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
|
|
@@ -757,15 +868,34 @@ const configSchema = z.object({
|
|
|
757
868
|
// is safe: startServer() already falls back to 127.0.0.1 for a missing hostname. Write-time
|
|
758
869
|
// rejection lives in validateConfigCandidate() so bad values still surface to the caller.
|
|
759
870
|
hostname: z.string().trim().min(1).optional().catch(undefined),
|
|
871
|
+
// Discriminated on `enabled` so a disabled entry cannot be forced to carry a port, and an
|
|
872
|
+
// enabled one cannot omit it (#1102). A malformed value degrades to undefined rather than
|
|
873
|
+
// failing the whole parse: this is an opt-in convenience surface, and a hand-edit typo here
|
|
874
|
+
// must never reset providers/apiKeys through the backup-and-defaults repair path.
|
|
875
|
+
unauthenticatedLoopbackListener: z.union([
|
|
876
|
+
z.object({ enabled: z.literal(false) }),
|
|
877
|
+
z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }),
|
|
878
|
+
]).optional().catch(undefined),
|
|
760
879
|
providers: z.record(z.string(), providerConfigSchema),
|
|
761
880
|
defaultProvider: z.string().min(1).default("openai"),
|
|
881
|
+
defaultModelAliases: z.boolean().optional(),
|
|
882
|
+
// Future versions remain opaque through passthrough-compatible whole-config saves.
|
|
883
|
+
// Only version 1 grants deletion authority in the rebase path.
|
|
884
|
+
configRebaseProvenance: z.unknown().optional(),
|
|
885
|
+
// A retry can be billable, so absence and malformed hand edits both stay off.
|
|
886
|
+
emptyCompletionRetry: z.boolean().optional().catch(false),
|
|
887
|
+
// A malformed hand edit must not silently stop opening the browser: fall back
|
|
888
|
+
// to undefined, which resolves to the historical auto-open behavior.
|
|
889
|
+
oauthOpenBrowser: z.boolean().optional().catch(undefined),
|
|
762
890
|
openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
|
|
763
|
-
// Invalid hand edits must not discard an otherwise usable config.
|
|
764
|
-
|
|
765
|
-
|
|
891
|
+
// Invalid hand edits must not discard an otherwise usable config.
|
|
892
|
+
googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined),
|
|
893
|
+
clientIntegrations: clientIntegrationsSchema.optional().catch(undefined),
|
|
766
894
|
providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
|
|
767
895
|
contextCapValue: z.number().int().positive().optional(),
|
|
768
896
|
multiAgentGuidanceEnabled: z.boolean().optional(),
|
|
897
|
+
// Invalid optional recovery config must not discard unrelated provider/account state.
|
|
898
|
+
agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined),
|
|
769
899
|
// These selections pre-date schema validation and used to pass through as
|
|
770
900
|
// unknown fields. Invalid hand edits must disable only the optional
|
|
771
901
|
// delegation/native-default feature, not reject the whole config and hide
|
|
@@ -773,9 +903,27 @@ const configSchema = z.object({
|
|
|
773
903
|
injectionModel: z.string().optional().catch(undefined),
|
|
774
904
|
injectionEffort: z.string().optional().catch(undefined),
|
|
775
905
|
syncCodexSubagentDefaults: z.boolean().optional().catch(undefined),
|
|
906
|
+
// Per-primary-model fallback chains. Values must be non-empty string arrays;
|
|
907
|
+
// malformed entries degrade to undefined rather than rejecting the whole config.
|
|
908
|
+
subagentModelFallbackByModel: z.record(
|
|
909
|
+
z.string(),
|
|
910
|
+
z.array(z.string().trim().min(1)).min(1),
|
|
911
|
+
).optional().catch(undefined),
|
|
776
912
|
codexShimAutoRestore: z.boolean().optional(),
|
|
777
913
|
pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
|
|
778
914
|
codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
|
|
915
|
+
// Selection order is a preference, not a safety control like pause: a malformed
|
|
916
|
+
// map degrades to "no ordering" rather than failing the parse, so a hand-edited
|
|
917
|
+
// typo cannot trip the backup-and-defaults repair path and wipe providers or
|
|
918
|
+
// pool accounts. Warning emitted in loadConfig.
|
|
919
|
+
codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined),
|
|
920
|
+
activeCodexAccountPinned: z.string().regex(CODEX_ACCOUNT_PIN_PATTERN).optional().catch(undefined),
|
|
921
|
+
// A malformed hand edit must degrade to false without discarding providers, accounts,
|
|
922
|
+
// or the exact selector map. Live writes remain strict.
|
|
923
|
+
codexAccountPickerEnabled: z.boolean().optional().catch(false),
|
|
924
|
+
// Same degrade-not-reject rule: a malformed hand edit hides Spark rather than discarding the
|
|
925
|
+
// whole config. Hidden is also the default, so `catch(false)` and the default agree.
|
|
926
|
+
showCodexSparkQuota: z.boolean().optional().catch(false),
|
|
779
927
|
// Model ids excluded from the Grok Build managed block (dashboard switches).
|
|
780
928
|
grokExcludedModels: z.array(z.string()).optional(),
|
|
781
929
|
// Invalid values degrade to undefined ("auto") instead of failing the whole
|
|
@@ -828,6 +976,7 @@ const configSchema = z.object({
|
|
|
828
976
|
const configuredProviderNamespaces = new Set([
|
|
829
977
|
COMBO_NAMESPACE,
|
|
830
978
|
OPENAI_CODEX_PROVIDER_ID,
|
|
979
|
+
POLICY_NAMESPACE,
|
|
831
980
|
...Object.keys(config.providers),
|
|
832
981
|
].map(codexProviderNamespaceKey));
|
|
833
982
|
const namespaceTargets = new Set(
|
|
@@ -839,7 +988,7 @@ const configSchema = z.object({
|
|
|
839
988
|
ctx.addIssue({
|
|
840
989
|
code: "custom",
|
|
841
990
|
path: ["codexAccountNamespaces", namespace],
|
|
842
|
-
message: "account selectors must not collide with configured provider or
|
|
991
|
+
message: "account selectors must not collide with configured provider, combo, or routing policy namespaces",
|
|
843
992
|
});
|
|
844
993
|
}
|
|
845
994
|
if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) {
|
|
@@ -855,18 +1004,25 @@ const configSchema = z.object({
|
|
|
855
1004
|
if (!isValidProviderName(name)) {
|
|
856
1005
|
ctx.addIssue({
|
|
857
1006
|
code: "custom",
|
|
858
|
-
path: ["providers", name],
|
|
859
|
-
message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys",
|
|
1007
|
+
path: ["providers", redactSecretString(name)],
|
|
1008
|
+
message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys or routing namespaces (policy)",
|
|
860
1009
|
});
|
|
861
1010
|
}
|
|
862
1011
|
const provider = config.providers[name];
|
|
1012
|
+
if (hasFastWireCapabilityConflict(provider)) {
|
|
1013
|
+
ctx.addIssue({
|
|
1014
|
+
code: "custom",
|
|
1015
|
+
path: ["providers", redactSecretString(name), "fastWire"],
|
|
1016
|
+
message: "fastWire=null conflicts with supportsServiceTier=true",
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
863
1019
|
const openRouterRoutingError = openRouterRoutingConfigError(provider);
|
|
864
1020
|
if (openRouterRoutingError) {
|
|
865
1021
|
ctx.addIssue({
|
|
866
1022
|
code: "custom",
|
|
867
1023
|
path: [
|
|
868
1024
|
"providers",
|
|
869
|
-
name,
|
|
1025
|
+
redactSecretString(name),
|
|
870
1026
|
openRouterRoutingError.startsWith("modelOpenRouterRouting")
|
|
871
1027
|
? "modelOpenRouterRouting"
|
|
872
1028
|
: "openRouterRouting",
|
|
@@ -877,7 +1033,7 @@ const configSchema = z.object({
|
|
|
877
1033
|
if (Object.hasOwn(provider, "virtualModels")) {
|
|
878
1034
|
ctx.addIssue({
|
|
879
1035
|
code: "custom",
|
|
880
|
-
path: ["providers", name, "virtualModels"],
|
|
1036
|
+
path: ["providers", redactSecretString(name), "virtualModels"],
|
|
881
1037
|
message: "virtualModels is registry-only and must not be persisted",
|
|
882
1038
|
});
|
|
883
1039
|
}
|
|
@@ -885,7 +1041,7 @@ const configSchema = z.object({
|
|
|
885
1041
|
if (baseUrlError) {
|
|
886
1042
|
ctx.addIssue({
|
|
887
1043
|
code: "custom",
|
|
888
|
-
path: ["providers", name, "baseUrl"],
|
|
1044
|
+
path: ["providers", redactSecretString(name), "baseUrl"],
|
|
889
1045
|
message: baseUrlError,
|
|
890
1046
|
});
|
|
891
1047
|
} else {
|
|
@@ -893,7 +1049,7 @@ const configSchema = z.object({
|
|
|
893
1049
|
if (destinationError) {
|
|
894
1050
|
ctx.addIssue({
|
|
895
1051
|
code: "custom",
|
|
896
|
-
path: ["providers", name, "baseUrl"],
|
|
1052
|
+
path: ["providers", redactSecretString(name), "baseUrl"],
|
|
897
1053
|
message: destinationError,
|
|
898
1054
|
});
|
|
899
1055
|
}
|
|
@@ -902,7 +1058,7 @@ const configSchema = z.object({
|
|
|
902
1058
|
if (responsesPathError) {
|
|
903
1059
|
ctx.addIssue({
|
|
904
1060
|
code: "custom",
|
|
905
|
-
path: ["providers", name, "responsesPath"],
|
|
1061
|
+
path: ["providers", redactSecretString(name), "responsesPath"],
|
|
906
1062
|
message: responsesPathError,
|
|
907
1063
|
});
|
|
908
1064
|
}
|
|
@@ -910,15 +1066,25 @@ const configSchema = z.object({
|
|
|
910
1066
|
if (headersError) {
|
|
911
1067
|
ctx.addIssue({
|
|
912
1068
|
code: "custom",
|
|
913
|
-
path: ["providers", name, "headers"],
|
|
1069
|
+
path: ["providers", redactSecretString(name), "headers"],
|
|
914
1070
|
message: headersError,
|
|
915
1071
|
});
|
|
916
1072
|
}
|
|
1073
|
+
const modelCostsError = providerModelCostsConfigError((provider as { modelCosts?: unknown }).modelCosts);
|
|
1074
|
+
if (modelCostsError) {
|
|
1075
|
+
ctx.addIssue({
|
|
1076
|
+
code: "custom",
|
|
1077
|
+
// The provider key is caller-controlled and can be token-shaped; redact it
|
|
1078
|
+
// before schemaDiagnosticsError serializes the path (ocx config validate/import).
|
|
1079
|
+
path: ["providers", redactSecretString(name), "modelCosts"],
|
|
1080
|
+
message: modelCostsError,
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
917
1083
|
const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig);
|
|
918
1084
|
if (apiKeyTransportError) {
|
|
919
1085
|
ctx.addIssue({
|
|
920
1086
|
code: "custom",
|
|
921
|
-
path: ["providers", name, "apiKeyTransport"],
|
|
1087
|
+
path: ["providers", redactSecretString(name), "apiKeyTransport"],
|
|
922
1088
|
message: apiKeyTransportError,
|
|
923
1089
|
});
|
|
924
1090
|
}
|
|
@@ -931,10 +1097,23 @@ const configSchema = z.object({
|
|
|
931
1097
|
if (modelAdaptersError) {
|
|
932
1098
|
ctx.addIssue({
|
|
933
1099
|
code: "custom",
|
|
934
|
-
path: ["providers", name, "modelAdapters"],
|
|
1100
|
+
path: ["providers", redactSecretString(name), "modelAdapters"],
|
|
935
1101
|
message: modelAdaptersError,
|
|
936
1102
|
});
|
|
937
1103
|
}
|
|
1104
|
+
const preferHostedToolsError = modelPreferHostedToolsConfigError(
|
|
1105
|
+
(provider as { modelPreferHostedTools?: unknown }).modelPreferHostedTools,
|
|
1106
|
+
"modelPreferHostedTools",
|
|
1107
|
+
name,
|
|
1108
|
+
provider,
|
|
1109
|
+
);
|
|
1110
|
+
if (preferHostedToolsError) {
|
|
1111
|
+
ctx.addIssue({
|
|
1112
|
+
code: "custom",
|
|
1113
|
+
path: ["providers", redactSecretString(name), "modelPreferHostedTools"],
|
|
1114
|
+
message: preferHostedToolsError,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
938
1117
|
const maxInputError = positiveIntegerRecordConfigError(
|
|
939
1118
|
(provider as { modelMaxInputTokens?: unknown }).modelMaxInputTokens,
|
|
940
1119
|
"modelMaxInputTokens",
|
|
@@ -942,10 +1121,21 @@ const configSchema = z.object({
|
|
|
942
1121
|
if (maxInputError) {
|
|
943
1122
|
ctx.addIssue({
|
|
944
1123
|
code: "custom",
|
|
945
|
-
path: ["providers", name, "modelMaxInputTokens"],
|
|
1124
|
+
path: ["providers", redactSecretString(name), "modelMaxInputTokens"],
|
|
946
1125
|
message: maxInputError,
|
|
947
1126
|
});
|
|
948
1127
|
}
|
|
1128
|
+
const autoCompactError = modelAutoCompactTokenLimitsConfigError(
|
|
1129
|
+
(provider as { modelAutoCompactTokenLimits?: unknown }).modelAutoCompactTokenLimits,
|
|
1130
|
+
{ requireNativeIds: name === OPENAI_CODEX_PROVIDER_ID },
|
|
1131
|
+
);
|
|
1132
|
+
if (autoCompactError) {
|
|
1133
|
+
ctx.addIssue({
|
|
1134
|
+
code: "custom",
|
|
1135
|
+
path: ["providers", redactSecretString(name), "modelAutoCompactTokenLimits"],
|
|
1136
|
+
message: autoCompactError,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
949
1139
|
const reasoningSummariesError = booleanRecordConfigError(
|
|
950
1140
|
(provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries,
|
|
951
1141
|
"modelSupportsReasoningSummaries",
|
|
@@ -953,10 +1143,32 @@ const configSchema = z.object({
|
|
|
953
1143
|
if (reasoningSummariesError) {
|
|
954
1144
|
ctx.addIssue({
|
|
955
1145
|
code: "custom",
|
|
956
|
-
path: ["providers", name, "modelSupportsReasoningSummaries"],
|
|
1146
|
+
path: ["providers", redactSecretString(name), "modelSupportsReasoningSummaries"],
|
|
957
1147
|
message: reasoningSummariesError,
|
|
958
1148
|
});
|
|
959
1149
|
}
|
|
1150
|
+
const verbositySupportError = booleanRecordConfigError(
|
|
1151
|
+
(provider as { modelSupportsVerbosity?: unknown }).modelSupportsVerbosity,
|
|
1152
|
+
"modelSupportsVerbosity",
|
|
1153
|
+
);
|
|
1154
|
+
if (verbositySupportError) {
|
|
1155
|
+
ctx.addIssue({
|
|
1156
|
+
code: "custom",
|
|
1157
|
+
path: ["providers", redactSecretString(name), "modelSupportsVerbosity"],
|
|
1158
|
+
message: verbositySupportError,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
const serviceTierModelsError = booleanRecordConfigError(
|
|
1162
|
+
(provider as { modelSupportsServiceTier?: unknown }).modelSupportsServiceTier,
|
|
1163
|
+
"modelSupportsServiceTier",
|
|
1164
|
+
);
|
|
1165
|
+
if (serviceTierModelsError) {
|
|
1166
|
+
ctx.addIssue({
|
|
1167
|
+
code: "custom",
|
|
1168
|
+
path: ["providers", redactSecretString(name), "modelSupportsServiceTier"],
|
|
1169
|
+
message: serviceTierModelsError,
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
960
1172
|
const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError(
|
|
961
1173
|
(provider as { modelReasoningSummaryDelivery?: unknown }).modelReasoningSummaryDelivery,
|
|
962
1174
|
(provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries,
|
|
@@ -964,7 +1176,7 @@ const configSchema = z.object({
|
|
|
964
1176
|
if (reasoningSummaryDeliveryError) {
|
|
965
1177
|
ctx.addIssue({
|
|
966
1178
|
code: "custom",
|
|
967
|
-
path: ["providers", name, "modelReasoningSummaryDelivery"],
|
|
1179
|
+
path: ["providers", redactSecretString(name), "modelReasoningSummaryDelivery"],
|
|
968
1180
|
message: reasoningSummaryDeliveryError,
|
|
969
1181
|
});
|
|
970
1182
|
}
|
|
@@ -975,7 +1187,7 @@ const configSchema = z.object({
|
|
|
975
1187
|
if (defaultMaxOutputError) {
|
|
976
1188
|
ctx.addIssue({
|
|
977
1189
|
code: "custom",
|
|
978
|
-
path: ["providers", name, "defaultMaxOutputTokens"],
|
|
1190
|
+
path: ["providers", redactSecretString(name), "defaultMaxOutputTokens"],
|
|
979
1191
|
message: defaultMaxOutputError,
|
|
980
1192
|
});
|
|
981
1193
|
}
|
|
@@ -986,10 +1198,21 @@ const configSchema = z.object({
|
|
|
986
1198
|
if (maxOutputError) {
|
|
987
1199
|
ctx.addIssue({
|
|
988
1200
|
code: "custom",
|
|
989
|
-
path: ["providers", name, "modelMaxOutputTokens"],
|
|
1201
|
+
path: ["providers", redactSecretString(name), "modelMaxOutputTokens"],
|
|
990
1202
|
message: maxOutputError,
|
|
991
1203
|
});
|
|
992
1204
|
}
|
|
1205
|
+
const structuredOutputOptOutError = nonBlankStringArrayConfigError(
|
|
1206
|
+
(provider as { noStructuredOutputModels?: unknown }).noStructuredOutputModels,
|
|
1207
|
+
"noStructuredOutputModels",
|
|
1208
|
+
);
|
|
1209
|
+
if (structuredOutputOptOutError) {
|
|
1210
|
+
ctx.addIssue({
|
|
1211
|
+
code: "custom",
|
|
1212
|
+
path: ["providers", redactSecretString(name), "noStructuredOutputModels"],
|
|
1213
|
+
message: structuredOutputOptOutError,
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
993
1216
|
if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) {
|
|
994
1217
|
// Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider.
|
|
995
1218
|
// Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them.
|
|
@@ -1001,7 +1224,7 @@ const configSchema = z.object({
|
|
|
1001
1224
|
if (!canonicalOpenAiShape) {
|
|
1002
1225
|
ctx.addIssue({
|
|
1003
1226
|
code: "custom",
|
|
1004
|
-
path: ["providers", name, "codexAccountMode"],
|
|
1227
|
+
path: ["providers", redactSecretString(name), "codexAccountMode"],
|
|
1005
1228
|
message: "codexAccountMode is valid only on the canonical built-in openai provider",
|
|
1006
1229
|
});
|
|
1007
1230
|
}
|
|
@@ -1045,6 +1268,27 @@ const configSchema = z.object({
|
|
|
1045
1268
|
}
|
|
1046
1269
|
}
|
|
1047
1270
|
}
|
|
1271
|
+
const routingProfiles = (config as { routingProfiles?: unknown }).routingProfiles;
|
|
1272
|
+
if (routingProfiles !== undefined) {
|
|
1273
|
+
if (!routingProfiles || typeof routingProfiles !== "object" || Array.isArray(routingProfiles)) {
|
|
1274
|
+
ctx.addIssue({ code: "custom", path: ["routingProfiles"], message: "routingProfiles must be an object" });
|
|
1275
|
+
} else {
|
|
1276
|
+
for (const [id, raw] of Object.entries(routingProfiles as Record<string, unknown>)) {
|
|
1277
|
+
for (const issue of routingProfileIssues(id, raw, {
|
|
1278
|
+
providers: config.providers,
|
|
1279
|
+
combos: combos as Record<string, import("./types").OcxComboConfig> | undefined,
|
|
1280
|
+
routingProfiles: routingProfiles as Record<string, import("./types").OcxRoutingProfileConfig>,
|
|
1281
|
+
codexAccountNamespaces: accountNamespaces,
|
|
1282
|
+
}, { excludeProfileId: id })) {
|
|
1283
|
+
ctx.addIssue({
|
|
1284
|
+
code: "custom",
|
|
1285
|
+
path: ["routingProfiles", id, ...issue.path],
|
|
1286
|
+
message: issue.message,
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1048
1292
|
});
|
|
1049
1293
|
|
|
1050
1294
|
/**
|
|
@@ -1058,32 +1302,6 @@ const configSchema = z.object({
|
|
|
1058
1302
|
*/
|
|
1059
1303
|
export const DEFAULT_SUBAGENT_MODELS = ["gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4-mini"];
|
|
1060
1304
|
|
|
1061
|
-
export function getConfigDir(): string {
|
|
1062
|
-
return resolveConfigDir();
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
export function getConfigPath(): string {
|
|
1066
|
-
return resolveConfigPath();
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
export function getPidPath(): string {
|
|
1070
|
-
return resolvePidPath();
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
export function getRuntimePortPath(): string {
|
|
1074
|
-
return resolveRuntimePortPath();
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
export function hardenConfigDir(): void {
|
|
1078
|
-
const dir = getConfigDir();
|
|
1079
|
-
if (existsSync(dir)) {
|
|
1080
|
-
try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
|
|
1081
|
-
if (process.platform === "win32") {
|
|
1082
|
-
hardenSecretDir(dir, { required: false });
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
1305
|
export function hardenExistingSecret(path: string): void {
|
|
1088
1306
|
if (existsSync(path)) {
|
|
1089
1307
|
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
@@ -1105,6 +1323,144 @@ function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void
|
|
|
1105
1323
|
}
|
|
1106
1324
|
}
|
|
1107
1325
|
|
|
1326
|
+
/**
|
|
1327
|
+
* Load-time degradation for `retryOn429` (loadConfig only): one hand-edited invalid optional
|
|
1328
|
+
* field (e.g. `attempts: 0` or a string) must not trip the whole provider schema and hide every
|
|
1329
|
+
* provider/key behind a default config. Invalid fields are dropped with a warning; the management
|
|
1330
|
+
* write boundary still rejects invalid policies explicitly.
|
|
1331
|
+
*/
|
|
1332
|
+
function sanitizeRetryOn429ForLoad(parsed: unknown): void {
|
|
1333
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
1334
|
+
const root = parsed as Record<string, unknown>;
|
|
1335
|
+
const providers = root.providers;
|
|
1336
|
+
if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
|
|
1337
|
+
for (const [name, provider] of Object.entries(providers as Record<string, unknown>)) {
|
|
1338
|
+
// This sanitizer runs BEFORE schema validation, so the provider name is untrusted: redact
|
|
1339
|
+
// secret-shaped names and JSON-escape control characters before it reaches any warning.
|
|
1340
|
+
const safeProviderName = JSON.stringify(redactSecretString(name));
|
|
1341
|
+
if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue;
|
|
1342
|
+
const p = provider as Record<string, unknown>;
|
|
1343
|
+
const policy = p.retryOn429;
|
|
1344
|
+
if (policy === undefined) continue;
|
|
1345
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
1346
|
+
delete p.retryOn429;
|
|
1347
|
+
// Never serialize the value: an accidental `retryOn429: "sk-..."` would leak the secret.
|
|
1348
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 (${typeof policy}) is invalid — ignoring the policy`);
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
const policyRecord = policy as Record<string, unknown>;
|
|
1352
|
+
// An explicitly present but invalid master switch must not silently default to ENABLED:
|
|
1353
|
+
// drop the whole policy so a hand-edit that tried to disable retries stays disabled.
|
|
1354
|
+
if ("enabled" in policyRecord && typeof policyRecord.enabled !== "boolean") {
|
|
1355
|
+
delete p.retryOn429;
|
|
1356
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.enabled (${typeof policyRecord.enabled}) is invalid — ignoring the whole policy`);
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
// Field checks derive from the shared policy schema so the bounds cannot drift
|
|
1360
|
+
// between the load-time sanitizer, the config schema, and the write boundary.
|
|
1361
|
+
const policyShape = retryOn429PolicySchema.shape;
|
|
1362
|
+
const hadPolicyEntries = Object.keys(policyRecord).length > 0;
|
|
1363
|
+
const cleaned: Record<string, unknown> = {};
|
|
1364
|
+
for (const [key, fieldSchema] of Object.entries(policyShape)) {
|
|
1365
|
+
const value = policyRecord[key];
|
|
1366
|
+
if (value === undefined) continue;
|
|
1367
|
+
if (fieldSchema.safeParse(value).success) cleaned[key] = value;
|
|
1368
|
+
// Log only the received type, never the value (provider config can hold secrets).
|
|
1369
|
+
else console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${key} (${typeof value}) is invalid — ignoring the field`);
|
|
1370
|
+
}
|
|
1371
|
+
const knownKeys = new Set(Object.keys(policyShape));
|
|
1372
|
+
for (const key of Object.keys(policyRecord)) {
|
|
1373
|
+
if (!knownKeys.has(key)) {
|
|
1374
|
+
// Redact the field NAME before logging: a malformed hand-edit can place a secret in a
|
|
1375
|
+
// property name (`retryOn429: { "sk-...": true }`). Ordinary typos (e.g. `attempt`)
|
|
1376
|
+
// stay readable, secret-shaped names become [REDACTED]. JSON-escape afterwards so a
|
|
1377
|
+
// control-character property name (newline/ANSI) can never forge a log line.
|
|
1378
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${JSON.stringify(redactSecretString(key))} is not a recognized field — ignoring it`);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (hadPolicyEntries && Object.keys(cleaned).length === 0) {
|
|
1382
|
+
// Every supplied field was invalid: drop the whole policy. Persisting `{}` here would
|
|
1383
|
+
// opt IN to retries with defaults, which is the opposite of what a malformed
|
|
1384
|
+
// disable-oriented edit (`retryOn429: { enabled: "false" }`, `attempts: 0`) asked for.
|
|
1385
|
+
delete p.retryOn429;
|
|
1386
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 has no valid fields left — removing the policy (an empty policy would enable retries with defaults)`);
|
|
1387
|
+
} else {
|
|
1388
|
+
// Preserve an intentionally empty `retryOn429: {}` (presence = opt-in with defaults).
|
|
1389
|
+
p.retryOn429 = cleaned;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* Management write-boundary validation for `retryOn429` (fail closed). Unlike the
|
|
1396
|
+
* lenient load-time sanitizer, invalid values and unknown keys are rejected outright so
|
|
1397
|
+
* a POST/PATCH cannot persist a policy the proxy would then silently degrade. Reuses the
|
|
1398
|
+
* shared policy schema. Never echoes values, and secret-shaped unknown field names are
|
|
1399
|
+
* redacted (a malformed write can place a secret in a property name).
|
|
1400
|
+
*/
|
|
1401
|
+
export function retryOn429PolicyConfigError(policy: unknown): string | null {
|
|
1402
|
+
if (policy === undefined) return null;
|
|
1403
|
+
const result = retryOn429PolicySchema.safeParse(policy);
|
|
1404
|
+
if (result.success) return null;
|
|
1405
|
+
const first = result.error.issues[0];
|
|
1406
|
+
if (!first) return "retryOn429 is invalid";
|
|
1407
|
+
if (first.code === "unrecognized_keys") {
|
|
1408
|
+
const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", ");
|
|
1409
|
+
return `retryOn429 has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`;
|
|
1410
|
+
}
|
|
1411
|
+
if (first.path.length === 0) return `retryOn429 is invalid (${first.message})`;
|
|
1412
|
+
const field = String(first.path[first.path.length - 1]);
|
|
1413
|
+
return `retryOn429.${field} is invalid (${first.message})`;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Load-time degradation for `providers.<name>.modelCosts`, mirroring
|
|
1418
|
+
* {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row
|
|
1419
|
+
* must not fail the whole config parse — that would back up config.json and
|
|
1420
|
+
* fall back to defaults, dropping otherwise valid providers and the default
|
|
1421
|
+
* route for a typo in a non-runtime display field. Invalid rows are dropped
|
|
1422
|
+
* with a warning; strict rejection stays at the management/write boundary
|
|
1423
|
+
* (providerManagementConfigError).
|
|
1424
|
+
*/
|
|
1425
|
+
function sanitizeModelCostsForLoad(parsed: unknown): void {
|
|
1426
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
1427
|
+
const root = parsed as Record<string, unknown>;
|
|
1428
|
+
const providers = root.providers;
|
|
1429
|
+
if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
|
|
1430
|
+
for (const [name, provider] of Object.entries(providers as Record<string, unknown>)) {
|
|
1431
|
+
// Runs before schema validation, so the provider name is untrusted: redact
|
|
1432
|
+
// secret-shaped names and JSON-escape control characters for the warning.
|
|
1433
|
+
const safeProviderName = JSON.stringify(redactSecretString(name));
|
|
1434
|
+
if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue;
|
|
1435
|
+
const p = provider as Record<string, unknown>;
|
|
1436
|
+
const costs = p.modelCosts;
|
|
1437
|
+
if (costs === undefined) continue;
|
|
1438
|
+
if (!costs || typeof costs !== "object" || Array.isArray(costs)) {
|
|
1439
|
+
delete p.modelCosts;
|
|
1440
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts (${typeof costs}) is invalid — ignoring the overlay`);
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
const costsRecord = costs as Record<string, unknown>;
|
|
1444
|
+
const hadEntries = Object.keys(costsRecord).length > 0;
|
|
1445
|
+
let kept = 0;
|
|
1446
|
+
for (const [modelId, entry] of Object.entries(costsRecord)) {
|
|
1447
|
+
// Reuse the shared per-row shape contract so the load-time sanitizer
|
|
1448
|
+
// cannot drift from the schema and the write boundary.
|
|
1449
|
+
if (providerModelCostsConfigError({ [modelId]: entry }) === null) {
|
|
1450
|
+
kept++;
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1453
|
+
delete costsRecord[modelId];
|
|
1454
|
+
// Redact the model id: a hand-edit can place a secret in a key name.
|
|
1455
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts.${JSON.stringify(redactSecretString(modelId))} is invalid — ignoring the row`);
|
|
1456
|
+
}
|
|
1457
|
+
if (hadEntries && kept === 0) {
|
|
1458
|
+
delete p.modelCosts;
|
|
1459
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts has no valid rows left — removing the overlay`);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1108
1464
|
/**
|
|
1109
1465
|
* Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind
|
|
1110
1466
|
* falls back to loopback, which is the safe direction but not what the file asked for —
|
|
@@ -1118,6 +1474,32 @@ function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void {
|
|
|
1118
1474
|
}
|
|
1119
1475
|
}
|
|
1120
1476
|
|
|
1477
|
+
/**
|
|
1478
|
+
* Companion to {@link warnDegradedStreamMode} for a malformed selection-order map.
|
|
1479
|
+
* Priority is a preference, so the schema drops the whole map rather than failing
|
|
1480
|
+
* the parse — say so once, otherwise the pool silently reverts to flat ordering.
|
|
1481
|
+
*/
|
|
1482
|
+
function degradedCodexAccountPriorityWarnings(rawParsed: unknown, validated: OcxConfig): string[] {
|
|
1483
|
+
const record = rawConfigRecord(rawParsed);
|
|
1484
|
+
const warnings: string[] = [];
|
|
1485
|
+
// The pin degrades silently otherwise, which reads as the manual selection simply
|
|
1486
|
+
// not having survived the restart.
|
|
1487
|
+
if (record?.activeCodexAccountPinned !== undefined && validated.activeCodexAccountPinned === undefined) {
|
|
1488
|
+
warnings.push("activeCodexAccountPinned is not a valid account id — the manually selected account is no longer pinned");
|
|
1489
|
+
}
|
|
1490
|
+
const raw = record?.codexAccountPriorities;
|
|
1491
|
+
if (raw !== undefined && validated.codexAccountPriorities === undefined) {
|
|
1492
|
+
warnings.push("codexAccountPriorities is invalid (expected account ids mapped to integers between -100 and 100) — account selection order is disabled");
|
|
1493
|
+
}
|
|
1494
|
+
return warnings;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function warnDegradedCodexAccountPriorities(rawParsed: unknown, validated: OcxConfig): void {
|
|
1498
|
+
for (const warning of degradedCodexAccountPriorityWarnings(rawParsed, validated)) {
|
|
1499
|
+
console.warn(`⚠️ config.json ${warning}`);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1121
1503
|
/**
|
|
1122
1504
|
* The apiKeys schema salvages entry by entry rather than failing the parse, so a
|
|
1123
1505
|
* dropped key is otherwise invisible — and it will not be re-saved by the next
|
|
@@ -1230,20 +1612,69 @@ function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCod
|
|
|
1230
1612
|
if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) {
|
|
1231
1613
|
delete normalized.subagentEffort;
|
|
1232
1614
|
}
|
|
1615
|
+
// A hand-authored config never passes through the management validator, so coerce here too.
|
|
1616
|
+
// A malformed classifierFallbacks (a bare string, or an array with non-string entries) would
|
|
1617
|
+
// otherwise reach the resolver unchecked.
|
|
1618
|
+
if (Object.hasOwn(normalized, "classifierModel")) {
|
|
1619
|
+
const value = typeof normalized.classifierModel === "string" ? normalized.classifierModel.trim() : "";
|
|
1620
|
+
if (value.length > 0) normalized.classifierModel = value;
|
|
1621
|
+
else delete normalized.classifierModel;
|
|
1622
|
+
}
|
|
1623
|
+
if (Object.hasOwn(normalized, "classifierFallbacks")) {
|
|
1624
|
+
const raw = normalized.classifierFallbacks;
|
|
1625
|
+
const kept = Array.isArray(raw)
|
|
1626
|
+
? raw.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map(entry => entry.trim())
|
|
1627
|
+
: [];
|
|
1628
|
+
if (kept.length > 0) normalized.classifierFallbacks = kept;
|
|
1629
|
+
else delete normalized.classifierFallbacks;
|
|
1630
|
+
}
|
|
1233
1631
|
return normalized as OcxConfig["claudeCode"];
|
|
1234
1632
|
}
|
|
1235
1633
|
|
|
1236
|
-
function normalizeClaudeSubagentEffort(config: OcxConfig,
|
|
1237
|
-
|
|
1238
|
-
|
|
1634
|
+
function normalizeClaudeSubagentEffort(config: OcxConfig, _rawParsed: unknown): OcxConfig {
|
|
1635
|
+
// Unconditional. This used to short-circuit when `subagentEffort` was absent or already valid,
|
|
1636
|
+
// which meant a config whose ONLY defect was elsewhere in `claudeCode` was never normalized.
|
|
1637
|
+
// The specialized subagentEffort WARNING is a separate concern and stays exactly as it is.
|
|
1638
|
+
if (!config.claudeCode) return config;
|
|
1239
1639
|
return { ...config, claudeCode: normalizePersistedClaudeCode(config.claudeCode) };
|
|
1240
1640
|
}
|
|
1241
1641
|
|
|
1242
|
-
function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
|
|
1243
|
-
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
1244
|
-
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
1245
|
-
console.warn(`⚠️ config.json claudeCode.subagentEffort is invalid (expected ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}) — ignoring it. Other settings were preserved.`);
|
|
1246
|
-
}
|
|
1642
|
+
function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
|
|
1643
|
+
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
1644
|
+
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
1645
|
+
console.warn(`⚠️ config.json claudeCode.subagentEffort is invalid (expected ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}) — ignoring it. Other settings were preserved.`);
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function malformedUpstreamHostCircuitThresholdWarning(rawParsed: unknown): string | null {
|
|
1650
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1651
|
+
if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
|
|
1652
|
+
const threshold = raw.upstreamHostCircuitThreshold;
|
|
1653
|
+
if (threshold === undefined) return null;
|
|
1654
|
+
if (typeof threshold === "number"
|
|
1655
|
+
&& Number.isInteger(threshold)
|
|
1656
|
+
&& threshold >= 0
|
|
1657
|
+
&& threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
|
|
1658
|
+
return `upstreamHostCircuitThreshold ignored: expected an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function warnDegradedUpstreamHostCircuitThreshold(rawParsed: unknown): void {
|
|
1662
|
+
const warning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
|
|
1663
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function malformedAgentTaskRecoveryWarning(rawParsed: unknown): string | null {
|
|
1667
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1668
|
+
if (!raw || !Object.hasOwn(raw, "agentTaskRecovery")) return null;
|
|
1669
|
+
const result = agentTaskRecoverySchema.safeParse(raw.agentTaskRecovery);
|
|
1670
|
+
if (result.success) return null;
|
|
1671
|
+
const field = result.error.issues[0]?.path.join(".");
|
|
1672
|
+
return `agentTaskRecovery${field ? `.${field}` : ""} ignored: invalid experimental recovery configuration`;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function warnDegradedAgentTaskRecovery(rawParsed: unknown): void {
|
|
1676
|
+
const warning = malformedAgentTaskRecoveryWarning(rawParsed);
|
|
1677
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
1247
1678
|
}
|
|
1248
1679
|
|
|
1249
1680
|
type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";
|
|
@@ -1275,6 +1706,18 @@ function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField
|
|
|
1275
1706
|
return `${field} ignored: expected ${expected}`;
|
|
1276
1707
|
}
|
|
1277
1708
|
|
|
1709
|
+
function malformedCodexAccountPickerWarning(rawParsed: unknown): string | null {
|
|
1710
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1711
|
+
if (!raw || !Object.hasOwn(raw, "codexAccountPickerEnabled")) return null;
|
|
1712
|
+
if (typeof raw.codexAccountPickerEnabled === "boolean") return null;
|
|
1713
|
+
return "codexAccountPickerEnabled ignored: expected a boolean";
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function warnDegradedCodexAccountPicker(rawParsed: unknown): void {
|
|
1717
|
+
const warning = malformedCodexAccountPickerWarning(rawParsed);
|
|
1718
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1278
1721
|
function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null {
|
|
1279
1722
|
if (config.syncCodexSubagentDefaults !== true) return null;
|
|
1280
1723
|
const malformed = malformedNativeSubagentFields(rawParsed);
|
|
@@ -1304,6 +1747,61 @@ function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig)
|
|
|
1304
1747
|
}
|
|
1305
1748
|
}
|
|
1306
1749
|
|
|
1750
|
+
/**
|
|
1751
|
+
* Registry metadata can gain service-tier capability after a config was written. An explicit
|
|
1752
|
+
* `fastWire: null` remains authoritative on load and on whole-document writes; rejecting either
|
|
1753
|
+
* would discard or lock access to unrelated providers and API keys. Direct contradictions within
|
|
1754
|
+
* one provider row remain schema errors through the outer config refinement, where the dynamic
|
|
1755
|
+
* provider name can be redacted before it reaches diagnostics.
|
|
1756
|
+
*/
|
|
1757
|
+
function inheritedFastWireConflictProviderNames(
|
|
1758
|
+
config: Pick<OcxConfig, "providers">,
|
|
1759
|
+
): string[] {
|
|
1760
|
+
const conflicts: string[] = [];
|
|
1761
|
+
for (const [name, provider] of Object.entries(config.providers)) {
|
|
1762
|
+
if (provider.fastWire !== null || provider.supportsServiceTier === false) continue;
|
|
1763
|
+
const registry = providerMatchesRegistryTransport(name, provider)
|
|
1764
|
+
? getProviderRegistryEntry(name)
|
|
1765
|
+
: undefined;
|
|
1766
|
+
if (!registry) continue;
|
|
1767
|
+
const effectiveProviderCapability = provider.supportsServiceTier ?? registry.supportsServiceTier;
|
|
1768
|
+
const effectiveModelCapabilities = {
|
|
1769
|
+
...(registryModelServiceTierCapabilityApplies(registry, provider)
|
|
1770
|
+
? registry.modelSupportsServiceTier ?? {}
|
|
1771
|
+
: {}),
|
|
1772
|
+
...(provider.modelSupportsServiceTier ?? {}),
|
|
1773
|
+
};
|
|
1774
|
+
if (
|
|
1775
|
+
effectiveProviderCapability === true
|
|
1776
|
+
|| Object.values(effectiveModelCapabilities).some(value => value === true)
|
|
1777
|
+
) {
|
|
1778
|
+
conflicts.push(name);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
return conflicts;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function inheritedFastWireConflictWarning(name: string): string {
|
|
1785
|
+
return `providers.${redactSecretString(name)}.fastWire=null overrides service-tier capability inherited from the matching registry entry`;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function warnInheritedFastWireConflicts(configPath: string, config: OcxConfig): void {
|
|
1789
|
+
const names = inheritedFastWireConflictProviderNames(config);
|
|
1790
|
+
if (names.length === 0 || warnedInheritedFastWireConflicts.has(configPath)) return;
|
|
1791
|
+
warnedInheritedFastWireConflicts.add(configPath);
|
|
1792
|
+
console.warn(
|
|
1793
|
+
`⚠️ config.json ${names.map(inheritedFastWireConflictWarning).join("; ")}. `
|
|
1794
|
+
+ "The persisted providers and API keys were preserved.",
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/**
|
|
1799
|
+
* Load and validate config.json into an OcxConfig. Missing files reset to
|
|
1800
|
+
* defaults and clear stale overlays. Broken existing files also fall back to
|
|
1801
|
+
* default routing (after backup), but keep the last-good cost-overlay registry
|
|
1802
|
+
* until a valid config or a genuinely missing file is observed. A partially-
|
|
1803
|
+
* invalid config is merged with defaults so providers and pool accounts survive.
|
|
1804
|
+
*/
|
|
1307
1805
|
export function loadConfig(): OcxConfig {
|
|
1308
1806
|
const dir = getConfigDir();
|
|
1309
1807
|
const configPath = getConfigPath();
|
|
@@ -1311,20 +1809,28 @@ export function loadConfig(): OcxConfig {
|
|
|
1311
1809
|
hardenExistingSecret(configPath);
|
|
1312
1810
|
hardenExistingSecret(join(dir, "auth.json"));
|
|
1313
1811
|
if (!existsSync(configPath)) {
|
|
1314
|
-
return getDefaultConfig();
|
|
1812
|
+
return withRefreshedCostOverlays(getDefaultConfig());
|
|
1315
1813
|
}
|
|
1316
1814
|
try {
|
|
1317
1815
|
const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
|
|
1318
1816
|
const parsed = JSON.parse(raw);
|
|
1817
|
+
sanitizeAliasesForLoad(parsed);
|
|
1818
|
+
sanitizeRetryOn429ForLoad(parsed);
|
|
1819
|
+
sanitizeModelCostsForLoad(parsed);
|
|
1319
1820
|
const result = configSchema.safeParse(parsed);
|
|
1320
1821
|
if (result.success) {
|
|
1321
1822
|
const config = normalizeApiKeyIds(result.data as OcxConfig);
|
|
1823
|
+
warnInheritedFastWireConflicts(configPath, config);
|
|
1322
1824
|
warnDegradedStreamMode(parsed, config);
|
|
1323
1825
|
warnDegradedHostname(parsed, config);
|
|
1324
1826
|
warnDegradedApiKeys(parsed, config);
|
|
1827
|
+
warnDegradedCodexAccountPriorities(parsed, config);
|
|
1325
1828
|
warnDegradedClaudeSubagentEffort(parsed);
|
|
1326
1829
|
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1327
|
-
|
|
1830
|
+
warnDegradedCodexAccountPicker(parsed);
|
|
1831
|
+
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
1832
|
+
warnDegradedAgentTaskRecovery(parsed);
|
|
1833
|
+
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
|
|
1328
1834
|
}
|
|
1329
1835
|
// Schema validation failed — merge defaults into the raw object instead of
|
|
1330
1836
|
// discarding it entirely, so pool accounts and providers survive a missing
|
|
@@ -1339,11 +1845,37 @@ export function loadConfig(): OcxConfig {
|
|
|
1339
1845
|
if (retryResult.success) {
|
|
1340
1846
|
warnConfigRepaired(configPath, result.error);
|
|
1341
1847
|
const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
|
|
1848
|
+
warnInheritedFastWireConflicts(configPath, config);
|
|
1342
1849
|
warnDegradedHostname(parsed, config);
|
|
1343
1850
|
warnDegradedApiKeys(parsed, config);
|
|
1851
|
+
warnDegradedCodexAccountPriorities(parsed, config);
|
|
1344
1852
|
warnDegradedClaudeSubagentEffort(parsed);
|
|
1345
1853
|
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1346
|
-
|
|
1854
|
+
warnDegradedCodexAccountPicker(parsed);
|
|
1855
|
+
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
1856
|
+
warnDegradedAgentTaskRecovery(parsed);
|
|
1857
|
+
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
|
|
1858
|
+
}
|
|
1859
|
+
// Still failing, but if every complaint is about one or more named entries
|
|
1860
|
+
// in an independent section, drop exactly those and keep the rest. Falling
|
|
1861
|
+
// back to defaults here would silently retire the operator's providers,
|
|
1862
|
+
// keys and prices over a mistake in one routing profile.
|
|
1863
|
+
const salvaged = salvageConfigCandidate(merged, retryResult.error);
|
|
1864
|
+
if (salvaged) {
|
|
1865
|
+
{
|
|
1866
|
+
warnDroppedConfigSections(configPath, salvaged.dropped, salvaged.issues);
|
|
1867
|
+
const config = normalizeApiKeyIds(salvaged.parsed);
|
|
1868
|
+
warnInheritedFastWireConflicts(configPath, config);
|
|
1869
|
+
warnDegradedHostname(parsed, config);
|
|
1870
|
+
warnDegradedApiKeys(parsed, config);
|
|
1871
|
+
warnDegradedCodexAccountPriorities(parsed, config);
|
|
1872
|
+
warnDegradedClaudeSubagentEffort(parsed);
|
|
1873
|
+
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1874
|
+
warnDegradedCodexAccountPicker(parsed);
|
|
1875
|
+
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
1876
|
+
warnDegradedAgentTaskRecovery(parsed);
|
|
1877
|
+
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
|
|
1878
|
+
}
|
|
1347
1879
|
}
|
|
1348
1880
|
// Merge couldn't fix it — truly broken config
|
|
1349
1881
|
warnAndBackupInvalidConfig(configPath, result.error);
|
|
@@ -1354,6 +1886,49 @@ export function loadConfig(): OcxConfig {
|
|
|
1354
1886
|
}
|
|
1355
1887
|
}
|
|
1356
1888
|
|
|
1889
|
+
/** Hand-edited alias mistakes disable only the bad alias; providers and routing survive. */
|
|
1890
|
+
function sanitizeAliasesForLoad(raw: unknown): void {
|
|
1891
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
1892
|
+
const root = raw as Record<string, unknown>;
|
|
1893
|
+
if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return;
|
|
1894
|
+
const providers = root.providers as Record<string, Record<string, unknown>>;
|
|
1895
|
+
const providerNames = new Set(Object.keys(providers).map(name => name.toLowerCase()));
|
|
1896
|
+
const claimedProviders = new Set<string>();
|
|
1897
|
+
const comboAliases = new Set(Object.values((root.combos as Record<string, { alias?: unknown }> | undefined) ?? {})
|
|
1898
|
+
.map(combo => typeof combo?.alias === "string" ? combo.alias.toLowerCase() : "").filter(Boolean));
|
|
1899
|
+
const accountNamespaces = new Set(Object.keys((root.codexAccountNamespaces as Record<string, unknown> | undefined) ?? {}).map(name => name.toLowerCase()));
|
|
1900
|
+
for (const provider of Object.values(providers)) {
|
|
1901
|
+
const alias = provider.alias;
|
|
1902
|
+
if (typeof alias !== "string" || !isValidProviderName(alias)
|
|
1903
|
+
|| providerNames.has(alias.toLowerCase()) || claimedProviders.has(alias.toLowerCase())
|
|
1904
|
+
|| comboAliases.has(alias.toLowerCase()) || accountNamespaces.has(alias.toLowerCase())) {
|
|
1905
|
+
if (alias !== undefined) console.warn("Ignoring invalid or colliding provider alias in config.json");
|
|
1906
|
+
delete provider.alias;
|
|
1907
|
+
} else claimedProviders.add(alias.toLowerCase());
|
|
1908
|
+
if (!provider.modelAliases || typeof provider.modelAliases !== "object" || Array.isArray(provider.modelAliases)) {
|
|
1909
|
+
if (provider.modelAliases !== undefined) delete provider.modelAliases;
|
|
1910
|
+
continue;
|
|
1911
|
+
}
|
|
1912
|
+
const aliases = provider.modelAliases as Record<string, unknown>;
|
|
1913
|
+
const nativeIds = new Set((Array.isArray(provider.models) ? provider.models : []).filter((id): id is string => typeof id === "string").map(id => id.toLowerCase()));
|
|
1914
|
+
const claimed = new Set<string>();
|
|
1915
|
+
for (const [id, value] of Object.entries(aliases)) {
|
|
1916
|
+
const lower = typeof value === "string" ? value.toLowerCase() : "";
|
|
1917
|
+
if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value) || claimed.has(lower)
|
|
1918
|
+
|| nativeIds.has(lower) || comboAliases.has(lower) || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) {
|
|
1919
|
+
console.warn(`Ignoring invalid or colliding model alias for ${id} in config.json`);
|
|
1920
|
+
delete aliases[id];
|
|
1921
|
+
} else claimed.add(lower);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/** Refresh the user cost-overlay registry from `config` and return it unchanged. */
|
|
1927
|
+
function withRefreshedCostOverlays(config: OcxConfig): OcxConfig {
|
|
1928
|
+
refreshUserCostOverlays(config);
|
|
1929
|
+
return config;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1357
1932
|
export type ConfigDiagnostics = {
|
|
1358
1933
|
config: OcxConfig;
|
|
1359
1934
|
source: "default" | "file" | "fallback";
|
|
@@ -1387,10 +1962,18 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
|
|
|
1387
1962
|
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
1388
1963
|
const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
|
|
1389
1964
|
const warnings = configPlaceholderWarnings(normalized);
|
|
1965
|
+
warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning));
|
|
1966
|
+
warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized));
|
|
1390
1967
|
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
1391
1968
|
warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`);
|
|
1392
1969
|
}
|
|
1393
1970
|
warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning));
|
|
1971
|
+
const pickerWarning = malformedCodexAccountPickerWarning(rawParsed);
|
|
1972
|
+
if (pickerWarning) warnings.push(pickerWarning);
|
|
1973
|
+
const hostCircuitWarning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
|
|
1974
|
+
if (hostCircuitWarning) warnings.push(hostCircuitWarning);
|
|
1975
|
+
const recoveryWarning = malformedAgentTaskRecoveryWarning(rawParsed);
|
|
1976
|
+
if (recoveryWarning) warnings.push(recoveryWarning);
|
|
1394
1977
|
if (syncDisabledReason) {
|
|
1395
1978
|
warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`);
|
|
1396
1979
|
}
|
|
@@ -1460,39 +2043,193 @@ function appOwnedMemoryBudgetError(value: unknown): string | null {
|
|
|
1460
2043
|
return null;
|
|
1461
2044
|
}
|
|
1462
2045
|
|
|
2046
|
+
function upstreamHostCircuitThresholdError(value: unknown): string | null {
|
|
2047
|
+
const raw = rawConfigRecord(value);
|
|
2048
|
+
if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
|
|
2049
|
+
const threshold = raw.upstreamHostCircuitThreshold;
|
|
2050
|
+
if (threshold === undefined) return null;
|
|
2051
|
+
if (typeof threshold === "number"
|
|
2052
|
+
&& Number.isInteger(threshold)
|
|
2053
|
+
&& threshold >= 0
|
|
2054
|
+
&& threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
|
|
2055
|
+
return `schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
function agentTaskRecoveryError(value: unknown): string | null {
|
|
2059
|
+
const raw = rawConfigRecord(value);
|
|
2060
|
+
if (!raw || !Object.hasOwn(raw, "agentTaskRecovery") || raw.agentTaskRecovery === undefined) return null;
|
|
2061
|
+
const result = agentTaskRecoverySchema.safeParse(raw.agentTaskRecovery);
|
|
2062
|
+
if (result.success) return null;
|
|
2063
|
+
const issue = result.error.issues[0];
|
|
2064
|
+
const field = issue?.path.join(".");
|
|
2065
|
+
return `schema_invalid: agentTaskRecovery${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a
|
|
2070
|
+
* malformed selection-order map to undefined, which on a write would drop every entry the
|
|
2071
|
+
* user had accumulated and still report success. A load-time degrade leaves the raw map in
|
|
2072
|
+
* the file to be repaired by hand; a degraded write erases it. One bad `ocx config set`
|
|
2073
|
+
* must not cost the whole map, so a live caller is told instead.
|
|
2074
|
+
*/
|
|
2075
|
+
function codexAccountPrioritiesError(value: unknown): string | null {
|
|
2076
|
+
const raw = rawConfigRecord(value);
|
|
2077
|
+
if (!raw) return null;
|
|
2078
|
+
if (raw.codexAccountPriorities !== undefined) {
|
|
2079
|
+
const parsed = codexAccountPrioritiesSchema.safeParse(raw.codexAccountPriorities);
|
|
2080
|
+
if (!parsed.success) {
|
|
2081
|
+
return schemaDiagnosticsError(parsed.error).replace("schema_invalid: ", "schema_invalid: codexAccountPriorities.");
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
// Tested as a string rather than coerced: `String(123)` matches the id pattern, so a
|
|
2085
|
+
// coercing guard waves a non-string pin through to the schema, where `.catch(undefined)`
|
|
2086
|
+
// drops it and reports the write as a success — the exact silent-degrade this guards.
|
|
2087
|
+
const pin = raw.activeCodexAccountPinned;
|
|
2088
|
+
if (pin !== undefined && (typeof pin !== "string" || !CODEX_ACCOUNT_PIN_PATTERN.test(pin))) {
|
|
2089
|
+
return "schema_invalid: activeCodexAccountPinned: must be an account id";
|
|
2090
|
+
}
|
|
2091
|
+
return null;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
1463
2094
|
function googleAntigravityStaticCatalogVersionError(value: unknown): string | null {
|
|
1464
2095
|
const raw = rawConfigRecord(value);
|
|
1465
2096
|
if (!raw || !Object.hasOwn(raw, "googleAntigravityStaticCatalogVersion")) return null;
|
|
1466
2097
|
const version = raw.googleAntigravityStaticCatalogVersion;
|
|
1467
|
-
if (version === undefined || version === 1) return null;
|
|
1468
|
-
return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1 or omitted";
|
|
2098
|
+
if (version === undefined || version === 1 || version === 2) return null;
|
|
2099
|
+
return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1, 2, or omitted";
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
function codexAccountPickerEnabledError(value: unknown): string | null {
|
|
2103
|
+
const raw = rawConfigRecord(value);
|
|
2104
|
+
if (!raw) return null;
|
|
2105
|
+
const descriptor = Object.getOwnPropertyDescriptor(raw, "codexAccountPickerEnabled");
|
|
2106
|
+
if (!descriptor) {
|
|
2107
|
+
return "codexAccountPickerEnabled" in raw
|
|
2108
|
+
? "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted"
|
|
2109
|
+
: null;
|
|
2110
|
+
}
|
|
2111
|
+
if (!("value" in descriptor)) {
|
|
2112
|
+
return "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted";
|
|
2113
|
+
}
|
|
2114
|
+
const enabled = descriptor.value;
|
|
2115
|
+
if (enabled === undefined || typeof enabled === "boolean") return null;
|
|
2116
|
+
return "schema_invalid: codexAccountPickerEnabled: must be a boolean or omitted";
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function emptyCompletionRetryError(value: unknown): string | null {
|
|
2120
|
+
const raw = rawConfigRecord(value);
|
|
2121
|
+
if (!raw || !Object.hasOwn(raw, "emptyCompletionRetry")) return null;
|
|
2122
|
+
const enabled = raw.emptyCompletionRetry;
|
|
2123
|
+
if (enabled === undefined || typeof enabled === "boolean") return null;
|
|
2124
|
+
return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted";
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
function oauthOpenBrowserError(value: unknown): string | null {
|
|
2128
|
+
const raw = rawConfigRecord(value);
|
|
2129
|
+
if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null;
|
|
2130
|
+
const enabled = raw.oauthOpenBrowser;
|
|
2131
|
+
if (enabled === undefined || typeof enabled === "boolean") return null;
|
|
2132
|
+
return "schema_invalid: oauthOpenBrowser: must be a boolean or omitted";
|
|
1469
2133
|
}
|
|
1470
2134
|
|
|
1471
2135
|
/** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
|
|
2136
|
+
/**
|
|
2137
|
+
* Reject a loopback-listener port that collides with the proxy port (#1102).
|
|
2138
|
+
*
|
|
2139
|
+
* The schema can only check the shape of each field on its own; the two ports being distinct
|
|
2140
|
+
* is a relationship between them. Letting the pair through would surface as a startup failure
|
|
2141
|
+
* after the public listener already bound, which reads like an unrelated port conflict.
|
|
2142
|
+
*
|
|
2143
|
+
* This is write-time only, matching `blankHostnameError`: a live caller can be told the value
|
|
2144
|
+
* is wrong, whereas a hand-edited config on the read path degrades to undefined rather than
|
|
2145
|
+
* resetting the whole file.
|
|
2146
|
+
*/
|
|
2147
|
+
function loopbackListenerPortError(value: unknown): string | null {
|
|
2148
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
2149
|
+
const listener = (value as Record<string, unknown>).unauthenticatedLoopbackListener;
|
|
2150
|
+
if (listener === undefined) return null;
|
|
2151
|
+
if (!listener || typeof listener !== "object" || Array.isArray(listener)) {
|
|
2152
|
+
return "schema_invalid: unauthenticatedLoopbackListener: must be an object or omitted";
|
|
2153
|
+
}
|
|
2154
|
+
const entry = listener as Record<string, unknown>;
|
|
2155
|
+
// `enabled` must be a real boolean. The schema's `.catch(undefined)` would otherwise DELETE
|
|
2156
|
+
// a `"true"` string entry and report success, leaving an operator convinced they enabled an
|
|
2157
|
+
// unauthenticated listener that is in fact off. Load-time still degrades quietly — a hand
|
|
2158
|
+
// edit must not reset the file — but a live caller gets told.
|
|
2159
|
+
if (typeof entry.enabled !== "boolean") {
|
|
2160
|
+
return "schema_invalid: unauthenticatedLoopbackListener.enabled: must be a boolean";
|
|
2161
|
+
}
|
|
2162
|
+
if (entry.enabled !== true) return null;
|
|
2163
|
+
const listenerPort = entry.port;
|
|
2164
|
+
if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) {
|
|
2165
|
+
return "schema_invalid: unauthenticatedLoopbackListener.port: must be an integer port when enabled";
|
|
2166
|
+
}
|
|
2167
|
+
const proxyPort = (value as Record<string, unknown>).port;
|
|
2168
|
+
if (typeof proxyPort === "number" && proxyPort === listenerPort) {
|
|
2169
|
+
return "schema_invalid: unauthenticatedLoopbackListener.port: must differ from the proxy port";
|
|
2170
|
+
}
|
|
2171
|
+
return null;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
1472
2174
|
export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
|
|
1473
2175
|
const boundaryError = blankHostnameError(value)
|
|
1474
2176
|
?? claudeSubagentEffortError(value)
|
|
1475
2177
|
?? appOwnedMemoryBudgetError(value)
|
|
1476
|
-
??
|
|
2178
|
+
?? upstreamHostCircuitThresholdError(value)
|
|
2179
|
+
?? agentTaskRecoveryError(value)
|
|
2180
|
+
?? googleAntigravityStaticCatalogVersionError(value)
|
|
2181
|
+
?? codexAccountPrioritiesError(value)
|
|
2182
|
+
?? codexAccountPickerEnabledError(value)
|
|
2183
|
+
?? emptyCompletionRetryError(value)
|
|
2184
|
+
?? oauthOpenBrowserError(value)
|
|
2185
|
+
?? loopbackListenerPortError(value);
|
|
1477
2186
|
if (boundaryError) return { ok: false, error: boundaryError };
|
|
1478
2187
|
const result = configSchema.safeParse(value);
|
|
1479
|
-
if (result.success)
|
|
2188
|
+
if (result.success) {
|
|
2189
|
+
const config = normalizeApiKeyIds(result.data as OcxConfig);
|
|
2190
|
+
return { ok: true, config };
|
|
2191
|
+
}
|
|
1480
2192
|
return { ok: false, error: schemaDiagnosticsError(result.error) };
|
|
1481
2193
|
}
|
|
1482
2194
|
|
|
1483
2195
|
function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
|
|
1484
2196
|
try {
|
|
1485
2197
|
const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
|
|
2198
|
+
// Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the
|
|
2199
|
+
// schema and send the caller a default-config fallback (the config command could then
|
|
2200
|
+
// persist that fallback over the user's providers/keys).
|
|
2201
|
+
sanitizeRetryOn429ForLoad(parsed);
|
|
2202
|
+
sanitizeModelCostsForLoad(parsed);
|
|
1486
2203
|
const result = configSchema.safeParse(parsed);
|
|
1487
2204
|
if (result.success) {
|
|
1488
2205
|
return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed);
|
|
1489
2206
|
}
|
|
1490
2207
|
|
|
1491
|
-
const
|
|
2208
|
+
const merged = mergeConfigDefaults(parsed);
|
|
2209
|
+
const retryResult = configSchema.safeParse(merged);
|
|
1492
2210
|
if (retryResult.success) {
|
|
1493
2211
|
return validFileConfigDiagnostics(normalizeApiKeyIds(retryResult.data as OcxConfig), parsed);
|
|
1494
2212
|
}
|
|
1495
2213
|
|
|
2214
|
+
// #1785: one invalid routing profile must not make diagnostics report the built-in
|
|
2215
|
+
// defaults AS the config, because a later config write persists those defaults over the
|
|
2216
|
+
// operator's providers, keys and prices.
|
|
2217
|
+
//
|
|
2218
|
+
// The failure is still reported. `source` stays "fallback" and `error` keeps the real
|
|
2219
|
+
// schema message -- diagnostics is the surface that tells callers the file is invalid,
|
|
2220
|
+
// and every consumer that must refuse an invalid config (provider reload, catalog sync,
|
|
2221
|
+
// cost reconcile, codex admission) gates on exactly those two fields. Only `config`
|
|
2222
|
+
// changes: it carries the salvaged document instead of factory defaults, so a caller
|
|
2223
|
+
// that ignores the error and writes it back preserves what the operator configured.
|
|
2224
|
+
const salvaged = salvageConfigCandidate(merged, retryResult.error);
|
|
2225
|
+
if (salvaged) {
|
|
2226
|
+
return {
|
|
2227
|
+
config: normalizeApiKeyIds(salvaged.parsed),
|
|
2228
|
+
source: "fallback",
|
|
2229
|
+
error: schemaDiagnosticsError(result.error),
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
|
|
1496
2233
|
return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) };
|
|
1497
2234
|
} catch {
|
|
1498
2235
|
return { config: getDefaultConfig(), source: "fallback", error: "invalid_json" };
|
|
@@ -1519,6 +2256,54 @@ export function readConfigDiagnostics(): ConfigDiagnostics {
|
|
|
1519
2256
|
return readConfigFileSnapshot().diagnostics;
|
|
1520
2257
|
}
|
|
1521
2258
|
|
|
2259
|
+
/**
|
|
2260
|
+
* The persisted config, plus a digest of the EXACT bytes it was parsed from.
|
|
2261
|
+
*
|
|
2262
|
+
* A union rather than a nullable digest, because `{ kind: "read" }` with no
|
|
2263
|
+
* digest is a state that cannot occur — and a state that cannot occur should
|
|
2264
|
+
* not be a state that can be written down. Refusing it at runtime is a check
|
|
2265
|
+
* somebody eventually forgets; making it unrepresentable is not.
|
|
2266
|
+
*
|
|
2267
|
+
* Why a byte digest at all: the Codex write lock compares an authority snapshot
|
|
2268
|
+
* taken before the lock against one taken while holding it, and its config
|
|
2269
|
+
* component used to hash the PARSED object. Two files that differ only in
|
|
2270
|
+
* whitespace or key order parse identically, so a non-cooperating writer could
|
|
2271
|
+
* rewrite the file between admission and commit and the comparison would see
|
|
2272
|
+
* nothing. Hashing what was actually read closes that.
|
|
2273
|
+
*
|
|
2274
|
+
* `readConfigFileSnapshot` stays private on purpose. Its `raw` carries provider
|
|
2275
|
+
* API keys and admission tokens, and `privacy:scan` reads tracked source text,
|
|
2276
|
+
* not runtime values — so it would not catch a caller that logged or serialized
|
|
2277
|
+
* that string. The digest travels; the bytes do not.
|
|
2278
|
+
*/
|
|
2279
|
+
export type ConfigAdmissionSnapshot =
|
|
2280
|
+
| Readonly<{ kind: "read"; diagnostics: ConfigDiagnostics; contentSha256: string }>
|
|
2281
|
+
| Readonly<{ kind: "unreadable"; diagnostics: ConfigDiagnostics; contentSha256: null }>;
|
|
2282
|
+
|
|
2283
|
+
export function readConfigAdmissionSnapshot(): ConfigAdmissionSnapshot {
|
|
2284
|
+
let bytes: Buffer;
|
|
2285
|
+
try {
|
|
2286
|
+
// ONE read. Hashing the file and then reading it again to parse would leave
|
|
2287
|
+
// a window for the two to disagree, which is the exact hazard this exists
|
|
2288
|
+
// to detect — the check would become a second chance to be wrong.
|
|
2289
|
+
bytes = readFileSync(getConfigPath());
|
|
2290
|
+
} catch (error) {
|
|
2291
|
+
return {
|
|
2292
|
+
kind: "unreadable",
|
|
2293
|
+
diagnostics: isMissingPathError(error)
|
|
2294
|
+
? { config: getDefaultConfig(), source: "default", error: null }
|
|
2295
|
+
: { config: getDefaultConfig(), source: "fallback", error: "invalid_json" },
|
|
2296
|
+
contentSha256: null,
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
return {
|
|
2300
|
+
kind: "read",
|
|
2301
|
+
// Decoded from the same buffer that was hashed, not re-read from disk.
|
|
2302
|
+
diagnostics: configDiagnosticsFromRaw(bytes.toString("utf-8")),
|
|
2303
|
+
contentSha256: createHash("sha256").update(bytes).digest("hex"),
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
|
|
1522
2307
|
const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite";
|
|
1523
2308
|
const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const;
|
|
1524
2309
|
let warnedConfigMutationDirectoryAcl = false;
|
|
@@ -1567,6 +2352,7 @@ function configMutationDatabasePath(): string {
|
|
|
1567
2352
|
}
|
|
1568
2353
|
|
|
1569
2354
|
let configMutationLockDepth = 0;
|
|
2355
|
+
let configMutationDatabase: Database | null = null;
|
|
1570
2356
|
|
|
1571
2357
|
/**
|
|
1572
2358
|
* Serialize synchronous config and Codex credential-generation commits across processes with an
|
|
@@ -1593,7 +2379,11 @@ export function withConfigMutationLockSync<T>(fn: () => T): T {
|
|
|
1593
2379
|
try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ }
|
|
1594
2380
|
database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
|
|
1595
2381
|
transactionOpen = true;
|
|
2382
|
+
initializeConfigGeneration(database);
|
|
1596
2383
|
} catch (cause) {
|
|
2384
|
+
if (transactionOpen) {
|
|
2385
|
+
try { database?.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
|
|
2386
|
+
}
|
|
1597
2387
|
try { database?.close(); } catch { /* acquisition already failed */ }
|
|
1598
2388
|
const code = cause && typeof cause === "object" && "code" in cause
|
|
1599
2389
|
? String((cause as { code?: unknown }).code)
|
|
@@ -1605,6 +2395,7 @@ export function withConfigMutationLockSync<T>(fn: () => T): T {
|
|
|
1605
2395
|
}
|
|
1606
2396
|
|
|
1607
2397
|
configMutationLockDepth = 1;
|
|
2398
|
+
configMutationDatabase = database;
|
|
1608
2399
|
try {
|
|
1609
2400
|
const value = fn();
|
|
1610
2401
|
database.exec("COMMIT");
|
|
@@ -1618,19 +2409,154 @@ export function withConfigMutationLockSync<T>(fn: () => T): T {
|
|
|
1618
2409
|
throw error;
|
|
1619
2410
|
} finally {
|
|
1620
2411
|
configMutationLockDepth = 0;
|
|
2412
|
+
configMutationDatabase = null;
|
|
1621
2413
|
try { database.close(); } catch { /* the OS lock is released with the handle */ }
|
|
1622
2414
|
}
|
|
1623
2415
|
}
|
|
1624
2416
|
|
|
1625
|
-
function
|
|
2417
|
+
function bumpGenerationForCooperatingConfigWrite(): void {
|
|
2418
|
+
if (!configMutationDatabase) {
|
|
2419
|
+
throw new Error("A cooperating config write requires the config mutation transaction.");
|
|
2420
|
+
}
|
|
2421
|
+
bumpCurrentConfigGeneration(configMutationDatabase);
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
export const readConfigGeneration: ReadConfigGeneration = () => {
|
|
2425
|
+
try {
|
|
2426
|
+
return readConfigGenerationAtPath(configMutationDatabasePath());
|
|
2427
|
+
} catch {
|
|
2428
|
+
return { kind: "unavailable", reason: "database" };
|
|
2429
|
+
}
|
|
2430
|
+
};
|
|
2431
|
+
|
|
2432
|
+
export function observeConfigGeneration(): ConfigGenerationObservation {
|
|
2433
|
+
return observeConfigGenerationAtPath(join(getConfigDir(), CONFIG_MUTATION_DB_FILENAME));
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/**
|
|
2437
|
+
* Read the generation from the transaction that is open RIGHT NOW.
|
|
2438
|
+
*
|
|
2439
|
+
* The observer cannot do this job. On the very first acquisition the
|
|
2440
|
+
* `BEGIN IMMEDIATE` that creates the table has not committed yet, so a separate
|
|
2441
|
+
* read-only connection cannot read a generation from it — measured, not
|
|
2442
|
+
* assumed. A caller that compared a pre-lock observation against an observer
|
|
2443
|
+
* re-read would therefore refuse every first write as stale.
|
|
2444
|
+
*
|
|
2445
|
+
* Throwing when no transaction is open is deliberate. Being called outside the
|
|
2446
|
+
* lock is broken plumbing, and returning a typed "unavailable" would let that
|
|
2447
|
+
* bug arrive disguised as an environmental failure — retried forever, on a
|
|
2448
|
+
* machine where nothing is wrong.
|
|
2449
|
+
*/
|
|
2450
|
+
export function readConfigGenerationInCurrentMutationTransaction(): ConfigGeneration {
|
|
2451
|
+
if (configMutationLockDepth < 1 || !configMutationDatabase) {
|
|
2452
|
+
throw new Error(
|
|
2453
|
+
"readConfigGenerationInCurrentMutationTransaction requires an open config mutation transaction.",
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
2456
|
+
return readConfigGenerationInTransaction(configMutationDatabase);
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
export const bumpConfigGeneration: BumpConfigGeneration = expected => {
|
|
2460
|
+
try {
|
|
2461
|
+
return bumpConfigGenerationAtPath(configMutationDatabasePath(), expected);
|
|
2462
|
+
} catch {
|
|
2463
|
+
return { kind: "unavailable", reason: "database" };
|
|
2464
|
+
}
|
|
2465
|
+
};
|
|
2466
|
+
|
|
2467
|
+
function configGenerationFailureReason(error: unknown): "busy" | "database" {
|
|
2468
|
+
const cause = error instanceof ConfigMutationLockError ? error.cause : error;
|
|
2469
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
2470
|
+
? String((cause as { code?: unknown }).code)
|
|
2471
|
+
: "";
|
|
2472
|
+
const message = cause instanceof Error ? cause.message : "";
|
|
2473
|
+
return code === "SQLITE_BUSY"
|
|
2474
|
+
|| code === "SQLITE_LOCKED"
|
|
2475
|
+
|| /database (?:is|table is) locked/i.test(message)
|
|
2476
|
+
? "busy"
|
|
2477
|
+
: "database";
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync = (
|
|
2481
|
+
expected,
|
|
2482
|
+
commit,
|
|
2483
|
+
) => {
|
|
2484
|
+
let callbackThrew = false;
|
|
2485
|
+
let callbackError: unknown;
|
|
2486
|
+
try {
|
|
2487
|
+
return withConfigMutationLockSync(() => {
|
|
2488
|
+
const database = configMutationDatabase;
|
|
2489
|
+
if (!database) throw new Error("Config mutation transaction database is unavailable.");
|
|
2490
|
+
const current = readConfigGenerationInTransaction(database);
|
|
2491
|
+
if (current.value !== expected.value) return { kind: "conflict", current };
|
|
2492
|
+
try {
|
|
2493
|
+
return { kind: "matched", generation: current, value: commit() };
|
|
2494
|
+
} catch (error) {
|
|
2495
|
+
callbackThrew = true;
|
|
2496
|
+
callbackError = error;
|
|
2497
|
+
throw error;
|
|
2498
|
+
}
|
|
2499
|
+
});
|
|
2500
|
+
} catch (error) {
|
|
2501
|
+
if (callbackThrew && error === callbackError) throw error;
|
|
2502
|
+
return { kind: "unavailable", reason: configGenerationFailureReason(error) };
|
|
2503
|
+
}
|
|
2504
|
+
};
|
|
2505
|
+
|
|
2506
|
+
/**
|
|
2507
|
+
* Atomic config.json write WITHOUT the mutation lock; callers must hold
|
|
2508
|
+
* `withConfigMutationLockSync`. Returns true when bytes changed. Refreshes the
|
|
2509
|
+
* cost-overlay registry from the persisted config so runtime estimates follow
|
|
2510
|
+
* every save path.
|
|
2511
|
+
*/
|
|
2512
|
+
function persistConfigUnlocked(config: OcxConfig): boolean {
|
|
1626
2513
|
const configPath = getConfigPath();
|
|
1627
|
-
|
|
2514
|
+
// External editors can add provider rows the live config deliberately does
|
|
2515
|
+
// not route with yet; merge them at the serialization boundary so an
|
|
2516
|
+
// unrelated in-process save cannot erase the provider or its overlay.
|
|
2517
|
+
// Provider preservation reads symbol-keyed live-owner state, which structuredClone
|
|
2518
|
+
// intentionally drops. Resolve that ownership before projecting JSON provenance.
|
|
2519
|
+
const provenanceProjection = projectConfigRebaseProvenance(config);
|
|
2520
|
+
const persisted = withPreservedDiskOnlyProviders(config);
|
|
2521
|
+
if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance;
|
|
2522
|
+
else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
|
|
2523
|
+
const bytes = JSON.stringify(persisted, null, 2) + "\n";
|
|
2524
|
+
let unchanged = false;
|
|
2525
|
+
try {
|
|
2526
|
+
unchanged = readFileSync(configPath, "utf8") === bytes;
|
|
2527
|
+
} catch (error) {
|
|
2528
|
+
if (!isMissingPathError(error)) throw error;
|
|
2529
|
+
}
|
|
2530
|
+
// Keep the runtime overlay registry in sync with EVERY persist path,
|
|
2531
|
+
// including byte-identical saves: a cooperating CLI process may have written
|
|
2532
|
+
// the same bytes (e.g. before a proxy notification), and Logs/Usage must
|
|
2533
|
+
// adopt the overlay without waiting for a changed save or restart.
|
|
2534
|
+
if (unchanged) {
|
|
2535
|
+
refreshUserCostOverlays(persisted);
|
|
2536
|
+
return false;
|
|
2537
|
+
}
|
|
2538
|
+
atomicWriteFile(configPath, bytes);
|
|
2539
|
+
// For changed saves, refresh only AFTER the write succeeded so a failed
|
|
2540
|
+
// write cannot leave estimates reflecting configuration never persisted.
|
|
2541
|
+
refreshUserCostOverlays(persisted);
|
|
2542
|
+
return true;
|
|
1628
2543
|
}
|
|
1629
2544
|
|
|
2545
|
+
/** Persist `config` to config.json under the config-mutation lock. */
|
|
1630
2546
|
export function saveConfig(config: OcxConfig): void {
|
|
1631
2547
|
// Keep the real-home assertion ahead of even lock-directory preparation.
|
|
1632
2548
|
assertNotRealHomeUnderTest(getConfigDir());
|
|
1633
|
-
withConfigMutationLockSync(() =>
|
|
2549
|
+
withConfigMutationLockSync(() => {
|
|
2550
|
+
const withProvenance = projectCustomModelCatalogMigration(
|
|
2551
|
+
readRawConfigJson(),
|
|
2552
|
+
projectConfigRebaseProvenance(config),
|
|
2553
|
+
);
|
|
2554
|
+
if (persistConfigUnlocked(withProvenance)) bumpGenerationForCooperatingConfigWrite();
|
|
2555
|
+
adoptCustomModelCatalogMigration(config, withProvenance);
|
|
2556
|
+
if (withProvenance.configRebaseProvenance === undefined) delete config.configRebaseProvenance;
|
|
2557
|
+
else config.configRebaseProvenance = structuredClone(withProvenance.configRebaseProvenance);
|
|
2558
|
+
clearPendingConfigTopLevelDeletions(config);
|
|
2559
|
+
});
|
|
1634
2560
|
}
|
|
1635
2561
|
|
|
1636
2562
|
export type PersistedConfigMutation<T> = {
|
|
@@ -1710,7 +2636,11 @@ export function mutatePersistedConfig<T>(
|
|
|
1710
2636
|
continue;
|
|
1711
2637
|
}
|
|
1712
2638
|
|
|
1713
|
-
|
|
2639
|
+
const projected = projectCustomModelCatalogMigration(
|
|
2640
|
+
commitBase.diagnostics.config,
|
|
2641
|
+
confirmedConfig,
|
|
2642
|
+
);
|
|
2643
|
+
if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite();
|
|
1714
2644
|
return { status: "committed", value: confirmed.value };
|
|
1715
2645
|
}
|
|
1716
2646
|
return { status: "unavailable", reason: "conflict" };
|
|
@@ -1738,7 +2668,12 @@ export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolea
|
|
|
1738
2668
|
* against, or a later stale save would masquerade as "our own change".
|
|
1739
2669
|
*/
|
|
1740
2670
|
const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
|
|
1741
|
-
|
|
2671
|
+
/**
|
|
2672
|
+
* Full live-config baseline used to rebase unrelated cooperating writes. The
|
|
2673
|
+
* Claude subtree and the bound listener fields remain on their dedicated
|
|
2674
|
+
* reconciliation paths below.
|
|
2675
|
+
*/
|
|
2676
|
+
const liveConfigBaseline = new WeakMap<OcxConfig, OcxConfig>();
|
|
1742
2677
|
/**
|
|
1743
2678
|
* The live config retains the address of the socket Bun actually opened, while
|
|
1744
2679
|
* this map retains the operator's desired address for the next process start.
|
|
@@ -1755,9 +2690,28 @@ const persistedLiveServerBinding = new WeakMap<OcxConfig, PersistedServerBinding
|
|
|
1755
2690
|
* save, which is the case the guard exists for.
|
|
1756
2691
|
*/
|
|
1757
2692
|
export function armClaudeCodeBaseline(config: OcxConfig): void {
|
|
2693
|
+
liveConfigBaseline.set(config, structuredClone(config));
|
|
1758
2694
|
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
1759
2695
|
}
|
|
1760
2696
|
|
|
2697
|
+
/**
|
|
2698
|
+
* Adopt one schema-validated provider that was read from the authoritative disk
|
|
2699
|
+
* config into a long-lived server config without rebasing any unrelated field.
|
|
2700
|
+
* Updating the matching baseline row keeps a later guarded save from treating the
|
|
2701
|
+
* adopted provider as an unsaved live edit that should defeat a newer disk change.
|
|
2702
|
+
*/
|
|
2703
|
+
export function adoptPersistedProviderIntoLiveConfig(
|
|
2704
|
+
config: OcxConfig,
|
|
2705
|
+
name: string,
|
|
2706
|
+
provider: OcxProviderConfig,
|
|
2707
|
+
persistedConfig?: OcxConfig,
|
|
2708
|
+
): void {
|
|
2709
|
+
config.providers[name] = structuredClone(provider);
|
|
2710
|
+
const baseline = liveConfigBaseline.get(config);
|
|
2711
|
+
if (baseline) baseline.providers[name] = structuredClone(provider);
|
|
2712
|
+
if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
1761
2715
|
/** Test seam only: is this instance armed? */
|
|
1762
2716
|
export function claudeCodeBaselineArmed(config: OcxConfig): boolean {
|
|
1763
2717
|
return claudeCodeBaseline.has(config);
|
|
@@ -1802,20 +2756,80 @@ function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue {
|
|
|
1802
2756
|
return value === MISSING_CONFIG_VALUE ? value : structuredClone(value);
|
|
1803
2757
|
}
|
|
1804
2758
|
|
|
2759
|
+
type IndexedCustomModels = {
|
|
2760
|
+
order: string[];
|
|
2761
|
+
byId: Map<string, Record<string, unknown>>;
|
|
2762
|
+
};
|
|
2763
|
+
|
|
2764
|
+
function indexCustomModels(value: ConfigMergeValue): IndexedCustomModels | null {
|
|
2765
|
+
if (!Array.isArray(value)) return null;
|
|
2766
|
+
const order: string[] = [];
|
|
2767
|
+
const byId = new Map<string, Record<string, unknown>>();
|
|
2768
|
+
for (const item of value) {
|
|
2769
|
+
if (!isPlainConfigRecord(item) || typeof item.id !== "string" || item.id.length === 0 || byId.has(item.id)) {
|
|
2770
|
+
return null;
|
|
2771
|
+
}
|
|
2772
|
+
order.push(item.id);
|
|
2773
|
+
byId.set(item.id, item);
|
|
2774
|
+
}
|
|
2775
|
+
return { order, byId };
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
/**
|
|
2779
|
+
* Merge custom-model rows by their stable id instead of treating the array as
|
|
2780
|
+
* one opaque value. A row changed only on disk is adopted, a row changed only
|
|
2781
|
+
* in the live config is retained, and disjoint edits to the same row recurse
|
|
2782
|
+
* through the normal three-way object merge. A newer persisted row deletion
|
|
2783
|
+
* wins over a stale live edit to that row.
|
|
2784
|
+
*/
|
|
2785
|
+
function reconcileCustomModels(
|
|
2786
|
+
baseline: ConfigMergeValue,
|
|
2787
|
+
live: ConfigMergeValue,
|
|
2788
|
+
persisted: ConfigMergeValue,
|
|
2789
|
+
): ConfigMergeValue | null {
|
|
2790
|
+
const baselineRows = indexCustomModels(baseline);
|
|
2791
|
+
const liveRows = indexCustomModels(live);
|
|
2792
|
+
const persistedRows = indexCustomModels(persisted);
|
|
2793
|
+
if (!baselineRows || !liveRows || !persistedRows) return null;
|
|
2794
|
+
|
|
2795
|
+
const order = [...liveRows.order, ...persistedRows.order.filter(id => !liveRows.byId.has(id))];
|
|
2796
|
+
const merged: Array<Record<string, unknown>> = [];
|
|
2797
|
+
for (const id of order) {
|
|
2798
|
+
const baselineRow = baselineRows.byId.get(id) ?? MISSING_CONFIG_VALUE;
|
|
2799
|
+
const persistedRow = persistedRows.byId.get(id) ?? MISSING_CONFIG_VALUE;
|
|
2800
|
+
const row = baselineRow !== MISSING_CONFIG_VALUE && persistedRow === MISSING_CONFIG_VALUE
|
|
2801
|
+
? MISSING_CONFIG_VALUE
|
|
2802
|
+
: reconcileConfigValue(
|
|
2803
|
+
baselineRow,
|
|
2804
|
+
liveRows.byId.get(id) ?? MISSING_CONFIG_VALUE,
|
|
2805
|
+
persistedRow,
|
|
2806
|
+
);
|
|
2807
|
+
if (row !== MISSING_CONFIG_VALUE) merged.push(row as Record<string, unknown>);
|
|
2808
|
+
}
|
|
2809
|
+
return merged;
|
|
2810
|
+
}
|
|
2811
|
+
|
|
1805
2812
|
function reconcileConfigRecord(
|
|
1806
2813
|
live: Record<string, unknown>,
|
|
1807
2814
|
baseline: Record<string, unknown>,
|
|
1808
2815
|
persisted: Record<string, unknown>,
|
|
1809
2816
|
skippedKeys?: ReadonlySet<string>,
|
|
2817
|
+
persistedDeletionsWin = false,
|
|
1810
2818
|
): void {
|
|
1811
2819
|
const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]);
|
|
1812
2820
|
for (const key of keys) {
|
|
1813
2821
|
if (skippedKeys?.has(key)) continue;
|
|
1814
|
-
const
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
2822
|
+
const baselineValue = ownConfigValue(baseline, key);
|
|
2823
|
+
const liveValue = ownConfigValue(live, key);
|
|
2824
|
+
const persistedValue = ownConfigValue(persisted, key);
|
|
2825
|
+
const merged = persistedDeletionsWin
|
|
2826
|
+
&& baselineValue !== MISSING_CONFIG_VALUE
|
|
2827
|
+
&& persistedValue === MISSING_CONFIG_VALUE
|
|
2828
|
+
? MISSING_CONFIG_VALUE
|
|
2829
|
+
: key === "customModels"
|
|
2830
|
+
? reconcileCustomModels(baselineValue, liveValue, persistedValue)
|
|
2831
|
+
?? reconcileConfigValue(baselineValue, liveValue, persistedValue)
|
|
2832
|
+
: reconcileConfigValue(baselineValue, liveValue, persistedValue, key === "providers");
|
|
1819
2833
|
if (merged === MISSING_CONFIG_VALUE) delete live[key];
|
|
1820
2834
|
else live[key] = merged;
|
|
1821
2835
|
}
|
|
@@ -1825,6 +2839,7 @@ function reconcileConfigValue(
|
|
|
1825
2839
|
baseline: ConfigMergeValue,
|
|
1826
2840
|
live: ConfigMergeValue,
|
|
1827
2841
|
persisted: ConfigMergeValue,
|
|
2842
|
+
persistedChildDeletionsWin = false,
|
|
1828
2843
|
): ConfigMergeValue {
|
|
1829
2844
|
const liveChanged = !deepEqual(live, baseline);
|
|
1830
2845
|
const persistedChanged = !deepEqual(persisted, baseline);
|
|
@@ -1854,6 +2869,8 @@ function reconcileConfigValue(
|
|
|
1854
2869
|
live,
|
|
1855
2870
|
isPlainConfigRecord(baseline) ? baseline : {},
|
|
1856
2871
|
persisted,
|
|
2872
|
+
undefined,
|
|
2873
|
+
persistedChildDeletionsWin,
|
|
1857
2874
|
);
|
|
1858
2875
|
}
|
|
1859
2876
|
// Same-leaf conflicts prefer the pending live management mutation.
|
|
@@ -1893,6 +2910,10 @@ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline
|
|
|
1893
2910
|
else config.claudeCode = structuredClone(persisted.claudeCode);
|
|
1894
2911
|
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
1895
2912
|
}
|
|
2913
|
+
// The reconciliation may have adopted a providers.<name>.modelCosts edit made
|
|
2914
|
+
// by a cooperating process while the OAuth login was pending; keep the overlay
|
|
2915
|
+
// registry (and the usage-cache overlay version) in sync with the live config.
|
|
2916
|
+
refreshUserCostOverlays(config);
|
|
1896
2917
|
}
|
|
1897
2918
|
|
|
1898
2919
|
/** The literal file, with no schema merge or default injection. */
|
|
@@ -1937,20 +2958,57 @@ function readPersistedServerBinding(
|
|
|
1937
2958
|
*
|
|
1938
2959
|
* Conflict policy, chosen deliberately:
|
|
1939
2960
|
* - disk changed, we did not → their hand edit wins;
|
|
1940
|
-
* - disk changed AND we changed →
|
|
1941
|
-
*
|
|
2961
|
+
* - disk changed AND we changed → disjoint fields are merged, while a same-leaf
|
|
2962
|
+
* conflict keeps the live value;
|
|
2963
|
+
* - a provider or custom-model row deleted on disk stays deleted even if stale
|
|
2964
|
+
* live state edited that same row;
|
|
1942
2965
|
* - file missing/unreadable → save what we have, no throw.
|
|
1943
2966
|
*
|
|
1944
|
-
*
|
|
1945
|
-
*
|
|
1946
|
-
* guarantee.
|
|
2967
|
+
* Custom-model rows are merged by their stable `id`, preserving independent
|
|
2968
|
+
* edits and deletions across stale whole-config saves.
|
|
1947
2969
|
*/
|
|
1948
2970
|
export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
|
|
1949
2971
|
withConfigMutationLockSync(() => {
|
|
1950
2972
|
const bindingBaseline = persistedLiveServerBinding.get(config);
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
2973
|
+
// One authoritative pre-write read feeds both the live-config reconciliation and
|
|
2974
|
+
// custom-model deletion migration. A second read could observe different bytes.
|
|
2975
|
+
const onDisk = readRawConfigJson();
|
|
2976
|
+
const baseline = liveConfigBaseline.get(config);
|
|
2977
|
+
if (baseline && onDisk !== undefined) {
|
|
2978
|
+
const persistedDiagnostics = configDiagnosticsFromRaw(JSON.stringify(onDisk));
|
|
2979
|
+
if (persistedDiagnostics.source === "file") {
|
|
2980
|
+
const deletedKeys = configRebaseDeletionKeys(config);
|
|
2981
|
+
const provenanceExists = configHasRebaseProvenance(config);
|
|
2982
|
+
// Only keys this live config is actually known to have diverged on may be
|
|
2983
|
+
// rebased. The baseline is captured once when the server arms it, so any key
|
|
2984
|
+
// that appeared on disk afterwards — through saveConfig(), a hand edit, or
|
|
2985
|
+
// another process — is absent from the baseline as well as from the live
|
|
2986
|
+
// config. Reconciling those keys reads "live never changed this" and adopts
|
|
2987
|
+
// the disk value, which resurrects a field the live writer had deliberately
|
|
2988
|
+
// deleted (#1462 regression: PUT /api/grok/selection with an empty list).
|
|
2989
|
+
// Restrict the merge to keys the baseline knew about, plus keys the live
|
|
2990
|
+
// config still carries; a key that exists only on disk is left to the
|
|
2991
|
+
// ordinary whole-config write below.
|
|
2992
|
+
const rebaseableKeys = new Set([
|
|
2993
|
+
...Object.keys(baseline as unknown as Record<string, unknown>),
|
|
2994
|
+
...Object.keys(config as unknown as Record<string, unknown>),
|
|
2995
|
+
...(provenanceExists
|
|
2996
|
+
? Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)
|
|
2997
|
+
: []),
|
|
2998
|
+
]);
|
|
2999
|
+
const skipped = new Set(["hostname", "port", "claudeCode", CONFIG_REBASE_PROVENANCE_KEY]);
|
|
3000
|
+
for (const key of Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)) {
|
|
3001
|
+
if (!rebaseableKeys.has(key)) skipped.add(key);
|
|
3002
|
+
}
|
|
3003
|
+
reconcileConfigRecord(
|
|
3004
|
+
config as unknown as Record<string, unknown>,
|
|
3005
|
+
baseline as unknown as Record<string, unknown>,
|
|
3006
|
+
persistedDiagnostics.config as unknown as Record<string, unknown>,
|
|
3007
|
+
skipped,
|
|
3008
|
+
);
|
|
3009
|
+
for (const key of deletedKeys) delete (config as unknown as Record<string, unknown>)[key];
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
1954
3012
|
if (claudeCodeBaseline.has(config)) {
|
|
1955
3013
|
if (onDisk !== undefined) {
|
|
1956
3014
|
const baseline = claudeCodeBaseline.get(config);
|
|
@@ -1962,21 +3020,35 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
|
|
|
1962
3020
|
}
|
|
1963
3021
|
}
|
|
1964
3022
|
}
|
|
3023
|
+
const provenanceProjection = projectConfigRebaseProvenance(config);
|
|
3024
|
+
const projectedConfig = projectCustomModelCatalogMigration(
|
|
3025
|
+
onDisk,
|
|
3026
|
+
config,
|
|
3027
|
+
);
|
|
3028
|
+
if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance;
|
|
3029
|
+
else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
|
|
1965
3030
|
const persistedBinding = bindingBaseline && onDisk
|
|
1966
3031
|
? readPersistedServerBinding(onDisk, bindingBaseline)
|
|
1967
3032
|
: bindingBaseline;
|
|
1968
3033
|
if (persistedBinding) {
|
|
1969
|
-
const persistedConfig: OcxConfig = { ...
|
|
3034
|
+
const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port };
|
|
1970
3035
|
if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
|
|
1971
3036
|
else persistedConfig.hostname = persistedBinding.hostname;
|
|
1972
|
-
persistConfigUnlocked(persistedConfig);
|
|
3037
|
+
if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite();
|
|
1973
3038
|
persistedLiveServerBinding.set(config, persistedBinding);
|
|
1974
3039
|
} else {
|
|
1975
|
-
persistConfigUnlocked(
|
|
3040
|
+
if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite();
|
|
1976
3041
|
}
|
|
3042
|
+
adoptCustomModelCatalogMigration(config, projectedConfig);
|
|
1977
3043
|
if (claudeCodeBaseline.has(config)) {
|
|
1978
3044
|
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
1979
3045
|
}
|
|
3046
|
+
if (liveConfigBaseline.has(config)) {
|
|
3047
|
+
if (projectedConfig.configRebaseProvenance === undefined) delete config.configRebaseProvenance;
|
|
3048
|
+
else config.configRebaseProvenance = structuredClone(projectedConfig.configRebaseProvenance);
|
|
3049
|
+
liveConfigBaseline.set(config, structuredClone(projectedConfig));
|
|
3050
|
+
}
|
|
3051
|
+
clearPendingConfigTopLevelDeletions(config);
|
|
1980
3052
|
});
|
|
1981
3053
|
}
|
|
1982
3054
|
|
|
@@ -2005,6 +3077,7 @@ export function getDefaultConfig(): OcxConfig {
|
|
|
2005
3077
|
// Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice.
|
|
2006
3078
|
return {
|
|
2007
3079
|
port: 10100,
|
|
3080
|
+
emptyCompletionRetry: false,
|
|
2008
3081
|
managementUsageMaxReadBytes: 64 * 1024 * 1024,
|
|
2009
3082
|
appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024),
|
|
2010
3083
|
// Fresh/re-initialized configs are already written in the current three-tier
|
|
@@ -2051,92 +3124,22 @@ export function applyProxyEnv(config: OcxConfig): void {
|
|
|
2051
3124
|
const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
|
|
2052
3125
|
const entries = existing.split(",").map(s => s.trim()).filter(Boolean);
|
|
2053
3126
|
const seen = new Set(entries.map(e => e.toLowerCase()));
|
|
2054
|
-
|
|
2055
|
-
|
|
3127
|
+
// Configured entries first, then loopback: loopback is unconditional, so appending it last
|
|
3128
|
+
// keeps it present even when the operator lists a loopback host themselves.
|
|
3129
|
+
const raw = config.noProxy;
|
|
3130
|
+
const configured = (Array.isArray(raw) ? raw : (resolveEnvValue(raw) ?? "").split(","))
|
|
3131
|
+
.map(entry => entry.trim())
|
|
3132
|
+
.filter(Boolean);
|
|
3133
|
+
for (const host of [...configured, "localhost", "127.0.0.1", "::1", "[::1]"]) {
|
|
3134
|
+
const key = host.toLowerCase();
|
|
3135
|
+
if (!seen.has(key)) {
|
|
2056
3136
|
entries.push(host);
|
|
2057
|
-
seen.add(
|
|
3137
|
+
seen.add(key);
|
|
2058
3138
|
}
|
|
2059
3139
|
}
|
|
2060
3140
|
process.env.NO_PROXY = entries.join(",");
|
|
2061
3141
|
}
|
|
2062
3142
|
|
|
2063
|
-
export function writePid(pid: number): void {
|
|
2064
|
-
const dir = getConfigDir();
|
|
2065
|
-
if (!existsSync(dir)) {
|
|
2066
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
2067
|
-
} else {
|
|
2068
|
-
hardenConfigDir();
|
|
2069
|
-
}
|
|
2070
|
-
atomicWriteFile(getPidPath(), String(pid));
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
export type RuntimePortState = {
|
|
2074
|
-
pid: number;
|
|
2075
|
-
port: number;
|
|
2076
|
-
hostname?: string;
|
|
2077
|
-
};
|
|
2078
|
-
|
|
2079
|
-
function isValidRuntimePortState(value: unknown): value is RuntimePortState {
|
|
2080
|
-
if (!value || typeof value !== "object") return false;
|
|
2081
|
-
const state = value as Record<string, unknown>;
|
|
2082
|
-
const hostnameOk = state.hostname === undefined || typeof state.hostname === "string";
|
|
2083
|
-
return Number.isSafeInteger(state.pid)
|
|
2084
|
-
&& Number(state.pid) > 0
|
|
2085
|
-
&& Number.isInteger(state.port)
|
|
2086
|
-
&& Number(state.port) > 0
|
|
2087
|
-
&& Number(state.port) <= 65535
|
|
2088
|
-
&& hostnameOk;
|
|
2089
|
-
}
|
|
2090
|
-
|
|
2091
|
-
export function writeRuntimePort(state: RuntimePortState): void {
|
|
2092
|
-
const dir = getConfigDir();
|
|
2093
|
-
if (!existsSync(dir)) {
|
|
2094
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
2095
|
-
} else {
|
|
2096
|
-
hardenConfigDir();
|
|
2097
|
-
}
|
|
2098
|
-
atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n");
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
export function readPid(): number | null {
|
|
2102
|
-
const pidPath = getPidPath();
|
|
2103
|
-
if (!existsSync(pidPath)) return null;
|
|
2104
|
-
try {
|
|
2105
|
-
const raw = readFileSync(pidPath, "utf-8").trim();
|
|
2106
|
-
const pid = parsePidFile(raw);
|
|
2107
|
-
if (pid === null) return null;
|
|
2108
|
-
try {
|
|
2109
|
-
process.kill(pid, 0);
|
|
2110
|
-
return isLikelyOcxStartProcess(pid) ? pid : null;
|
|
2111
|
-
} catch (e: unknown) {
|
|
2112
|
-
if ((e as NodeJS.ErrnoException).code === "EPERM") {
|
|
2113
|
-
return isLikelyOcxStartProcess(pid) ? pid : null;
|
|
2114
|
-
}
|
|
2115
|
-
return null;
|
|
2116
|
-
}
|
|
2117
|
-
} catch {
|
|
2118
|
-
return null;
|
|
2119
|
-
}
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
export function readRuntimePort(expectedPid?: number): RuntimePortState | null {
|
|
2123
|
-
try {
|
|
2124
|
-
const parsed = JSON.parse(readFileSync(getRuntimePortPath(), "utf-8"));
|
|
2125
|
-
if (!isValidRuntimePortState(parsed)) return null;
|
|
2126
|
-
if (expectedPid !== undefined && parsed.pid !== expectedPid) return null;
|
|
2127
|
-
return parsed;
|
|
2128
|
-
} catch {
|
|
2129
|
-
return null;
|
|
2130
|
-
}
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
export function removePid(expectedPid?: number): void {
|
|
2134
|
-
if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return;
|
|
2135
|
-
try {
|
|
2136
|
-
unlinkSync(getPidPath());
|
|
2137
|
-
} catch { /* ignore */ }
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
3143
|
function warnConfigRepaired(configPath: string, error: z.ZodError): void {
|
|
2141
3144
|
if (warnedConfigFallbacks.has(configPath)) return;
|
|
2142
3145
|
warnedConfigFallbacks.add(configPath);
|
|
@@ -2144,191 +3147,192 @@ function warnConfigRepaired(configPath: string, error: z.ZodError): void {
|
|
|
2144
3147
|
console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`);
|
|
2145
3148
|
}
|
|
2146
3149
|
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
3150
|
+
/**
|
|
3151
|
+
* Sections whose entries are independent of one another, so one bad entry is
|
|
3152
|
+
* safe to drop without changing what the rest mean.
|
|
3153
|
+
*
|
|
3154
|
+
* Both are validated entry-by-entry in the `superRefine` above, which raises
|
|
3155
|
+
* every finding as a *document*-level issue. That is what made a single routing
|
|
3156
|
+
* candidate naming a disabled provider discard the operator's whole config —
|
|
3157
|
+
* all eleven providers, every API key, and the entire `modelCosts` table —
|
|
3158
|
+
* while the proxy carried on serving from built-in defaults and reporting
|
|
3159
|
+
* healthy.
|
|
3160
|
+
*/
|
|
3161
|
+
const SALVAGEABLE_CONFIG_SECTIONS = ["routingProfiles", "combos"] as const;
|
|
2154
3162
|
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
3163
|
+
/**
|
|
3164
|
+
* Drop just the named entries a parse failure blamed, so the rest of the
|
|
3165
|
+
* document survives.
|
|
3166
|
+
*
|
|
3167
|
+
* Returns `null` when the failure was not confined to those sections — the
|
|
3168
|
+
* caller then keeps its existing behaviour rather than guessing.
|
|
3169
|
+
*
|
|
3170
|
+
* The whole entry goes, not the individual offending candidate. A routing
|
|
3171
|
+
* profile that quietly loses one candidate still routes, just not where the
|
|
3172
|
+
* operator said it should, and a policy that silently changed shape is a worse
|
|
3173
|
+
* outcome than one that is plainly absent. Absent is also the loud option: a
|
|
3174
|
+
* dry-run against it answers `unknown_profile`, which — paired with the warning
|
|
3175
|
+
* this emits — points at the real mistake.
|
|
3176
|
+
*/
|
|
3177
|
+
function dropInvalidConfigSections(
|
|
3178
|
+
parsed: unknown,
|
|
3179
|
+
error: z.ZodError,
|
|
3180
|
+
): { candidate: Record<string, unknown>; dropped: string[] } | null {
|
|
3181
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
3182
|
+
|
|
3183
|
+
const doomed = new Map<string, Set<string>>();
|
|
3184
|
+
for (const issue of error.issues) {
|
|
3185
|
+
if (isUnsalvageableIssue(issue)) return null;
|
|
3186
|
+
const [section, id] = issue.path;
|
|
3187
|
+
if (typeof section !== "string" || typeof id !== "string") return null;
|
|
3188
|
+
if (!(SALVAGEABLE_CONFIG_SECTIONS as readonly string[]).includes(section)) return null;
|
|
3189
|
+
// A complaint about the container itself ("combos must be an object") is
|
|
3190
|
+
// not about one entry, so there is nothing selective to drop.
|
|
3191
|
+
if (issue.path.length < 2) return null;
|
|
3192
|
+
let ids = doomed.get(section);
|
|
3193
|
+
if (!ids) doomed.set(section, ids = new Set());
|
|
3194
|
+
ids.add(id);
|
|
3195
|
+
}
|
|
3196
|
+
if (doomed.size === 0) return null;
|
|
3197
|
+
|
|
3198
|
+
const candidate: Record<string, unknown> = { ...(parsed as Record<string, unknown>) };
|
|
3199
|
+
const dropped: string[] = [];
|
|
3200
|
+
for (const [section, ids] of doomed) {
|
|
3201
|
+
const current = candidate[section];
|
|
3202
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) return null;
|
|
3203
|
+
const kept: Record<string, unknown> = {};
|
|
3204
|
+
for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
|
|
3205
|
+
if (ids.has(key)) dropped.push(`${section}.${key}`);
|
|
3206
|
+
else kept[key] = value;
|
|
3207
|
+
}
|
|
3208
|
+
candidate[section] = kept;
|
|
3209
|
+
}
|
|
3210
|
+
return dropped.length > 0 ? { candidate, dropped } : null;
|
|
2160
3211
|
}
|
|
2161
3212
|
|
|
2162
3213
|
/**
|
|
2163
|
-
*
|
|
2164
|
-
*
|
|
2165
|
-
*
|
|
3214
|
+
* Salvage until the document parses, not just once.
|
|
3215
|
+
*
|
|
3216
|
+
* One pass is not enough because the sections depend on each other: routing
|
|
3217
|
+
* profiles are validated against the combo map, so dropping an invalid combo can
|
|
3218
|
+
* expose a profile that referenced it. A single-pass salvage sees that second
|
|
3219
|
+
* failure and gives up, discarding the whole config -- the exact outcome this
|
|
3220
|
+
* code exists to prevent.
|
|
3221
|
+
*
|
|
3222
|
+
* `rawDocument` is the operator's document before defaults were merged in. When
|
|
3223
|
+
* supplied, the same entries are deleted from it too, so a diagnostics caller can
|
|
3224
|
+
* still tell an absent optional setting from one we injected.
|
|
2166
3225
|
*/
|
|
2167
|
-
export function removePidIfValueIs(snapshot: number | null): void {
|
|
2168
|
-
if (!existsSync(getPidPath())) return;
|
|
2169
|
-
if (readPidFileValue() !== snapshot) return;
|
|
2170
|
-
try {
|
|
2171
|
-
unlinkSync(getPidPath());
|
|
2172
|
-
} catch { /* ignore */ }
|
|
2173
|
-
}
|
|
2174
3226
|
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
export function sweepDeadOcxStartProcessCache(maxProbes = 64): number {
|
|
2223
|
-
const pids: number[] = [];
|
|
2224
|
-
let removed = 0;
|
|
2225
|
-
for (const pid of ocxStartProcessCache.keys()) {
|
|
2226
|
-
if (Number.isSafeInteger(pid) && pid > 0) pids.push(pid);
|
|
2227
|
-
else if (ocxStartProcessCache.delete(pid)) removed += 1;
|
|
2228
|
-
}
|
|
2229
|
-
if (pids.length === 0 || maxProbes <= 0) {
|
|
2230
|
-
ocxStartProcessSweepCursor = 0;
|
|
2231
|
-
return removed;
|
|
2232
|
-
}
|
|
2233
|
-
const probeCount = Math.min(Math.floor(maxProbes), pids.length);
|
|
2234
|
-
const start = ocxStartProcessSweepCursor % pids.length;
|
|
2235
|
-
for (let offset = 0; offset < probeCount; offset += 1) {
|
|
2236
|
-
const pid = pids[(start + offset) % pids.length]!;
|
|
2237
|
-
try {
|
|
2238
|
-
ocxStartProcessProbe(pid);
|
|
2239
|
-
} catch (error) {
|
|
2240
|
-
if ((error as NodeJS.ErrnoException).code !== "ESRCH") continue;
|
|
2241
|
-
if (ocxStartProcessCache.delete(pid)) removed += 1;
|
|
3227
|
+
/**
|
|
3228
|
+
* Findings that must never be salvaged away.
|
|
3229
|
+
*
|
|
3230
|
+
* Salvage removes the entry a finding blamed, which is right for an ordinary
|
|
3231
|
+
* validation mistake and wrong for a namespace collision: the collision is a
|
|
3232
|
+
* *relationship* between a combo/profile and a Codex account selector, and it is
|
|
3233
|
+
* reported on the combo. Dropping that combo makes the document parse and quietly
|
|
3234
|
+
* admits the account selector the schema just refused, turning a hard admission
|
|
3235
|
+
* boundary into a config that loads. Refuse the whole document instead.
|
|
3236
|
+
*/
|
|
3237
|
+
const UNSALVAGEABLE_ISSUE_MESSAGES: readonly string[] = [
|
|
3238
|
+
CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
|
|
3239
|
+
];
|
|
3240
|
+
|
|
3241
|
+
function isUnsalvageableIssue(issue: z.ZodIssue): boolean {
|
|
3242
|
+
return UNSALVAGEABLE_ISSUE_MESSAGES.some(message => issue.message.includes(message));
|
|
3243
|
+
}
|
|
3244
|
+
function salvageConfigCandidate(
|
|
3245
|
+
merged: unknown,
|
|
3246
|
+
initialError: z.ZodError,
|
|
3247
|
+
rawDocument?: unknown,
|
|
3248
|
+
): {
|
|
3249
|
+
candidate: Record<string, unknown>;
|
|
3250
|
+
rawCandidate: unknown;
|
|
3251
|
+
parsed: OcxConfig;
|
|
3252
|
+
dropped: string[];
|
|
3253
|
+
issues: z.ZodIssue[];
|
|
3254
|
+
} | null {
|
|
3255
|
+
let candidate: unknown = merged;
|
|
3256
|
+
let rawCandidate: unknown = rawDocument;
|
|
3257
|
+
let error = initialError;
|
|
3258
|
+
const dropped: string[] = [];
|
|
3259
|
+
const issues: z.ZodIssue[] = [];
|
|
3260
|
+
// Bounded by construction: every pass must remove at least one entry, and there
|
|
3261
|
+
// are only so many entries to remove.
|
|
3262
|
+
const budget = countSalvageableEntries(merged) + 1;
|
|
3263
|
+
for (let pass = 0; pass < budget; pass++) {
|
|
3264
|
+
const step = dropInvalidConfigSections(candidate, error);
|
|
3265
|
+
if (!step || step.dropped.length === 0) return null;
|
|
3266
|
+
dropped.push(...step.dropped);
|
|
3267
|
+
issues.push(...error.issues);
|
|
3268
|
+
candidate = step.candidate;
|
|
3269
|
+
rawCandidate = deleteEntryPaths(rawCandidate, step.dropped);
|
|
3270
|
+
const result = configSchema.safeParse(candidate);
|
|
3271
|
+
if (result.success) {
|
|
3272
|
+
return { candidate: step.candidate, rawCandidate, parsed: result.data as OcxConfig, dropped, issues };
|
|
2242
3273
|
}
|
|
3274
|
+
error = result.error;
|
|
2243
3275
|
}
|
|
2244
|
-
|
|
2245
|
-
return removed;
|
|
3276
|
+
return null;
|
|
2246
3277
|
}
|
|
2247
3278
|
|
|
2248
|
-
|
|
2249
|
-
return
|
|
3279
|
+
function countSalvageableEntries(document: unknown): number {
|
|
3280
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) return 0;
|
|
3281
|
+
let total = 0;
|
|
3282
|
+
for (const section of SALVAGEABLE_CONFIG_SECTIONS) {
|
|
3283
|
+
const value = (document as Record<string, unknown>)[section];
|
|
3284
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
3285
|
+
total += Object.keys(value as Record<string, unknown>).length;
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
return total;
|
|
2250
3289
|
}
|
|
2251
3290
|
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
if (
|
|
2255
|
-
const
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
3291
|
+
/** Delete `section.id` entries from a copy of the raw document. */
|
|
3292
|
+
function deleteEntryPaths(document: unknown, entryPaths: readonly string[]): unknown {
|
|
3293
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) return document;
|
|
3294
|
+
const next: Record<string, unknown> = { ...(document as Record<string, unknown>) };
|
|
3295
|
+
for (const entryPath of entryPaths) {
|
|
3296
|
+
const separator = entryPath.indexOf(".");
|
|
3297
|
+
if (separator <= 0) continue;
|
|
3298
|
+
const section = entryPath.slice(0, separator);
|
|
3299
|
+
const id = entryPath.slice(separator + 1);
|
|
3300
|
+
const container = next[section];
|
|
3301
|
+
if (!container || typeof container !== "object" || Array.isArray(container)) continue;
|
|
3302
|
+
const kept: Record<string, unknown> = { ...(container as Record<string, unknown>) };
|
|
3303
|
+
delete kept[id];
|
|
3304
|
+
next[section] = kept;
|
|
3305
|
+
}
|
|
3306
|
+
return next;
|
|
2260
3307
|
}
|
|
2261
3308
|
|
|
2262
3309
|
/**
|
|
2263
|
-
*
|
|
2264
|
-
*
|
|
2265
|
-
*
|
|
3310
|
+
* Entry ids are operator-chosen and can be token-shaped, so nothing dynamic reaches
|
|
3311
|
+
* the log unredacted. Static section names stay readable -- they are the part that
|
|
3312
|
+
* tells the operator where to look.
|
|
2266
3313
|
*/
|
|
2267
|
-
|
|
2268
|
-
const
|
|
2269
|
-
if (
|
|
2270
|
-
|
|
2271
|
-
process.kill(pid, 0);
|
|
2272
|
-
return pid;
|
|
2273
|
-
} catch (e: unknown) {
|
|
2274
|
-
if ((e as NodeJS.ErrnoException).code === "EPERM") return pid;
|
|
2275
|
-
return null;
|
|
2276
|
-
}
|
|
3314
|
+
function redactEntryPath(entryPath: string): string {
|
|
3315
|
+
const separator = entryPath.indexOf(".");
|
|
3316
|
+
if (separator <= 0) return redactSecretString(entryPath);
|
|
3317
|
+
return entryPath.slice(0, separator) + "." + redactSecretString(entryPath.slice(separator + 1));
|
|
2277
3318
|
}
|
|
2278
3319
|
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
* it was asked about, so a pidfile rewrite between discovery and verification can
|
|
2284
|
-
* never swap in a different process (TOCTOU guard).
|
|
2285
|
-
*/
|
|
2286
|
-
export function verifyPidIdentity(candidatePid: number): number | null {
|
|
2287
|
-
try {
|
|
2288
|
-
process.kill(candidatePid, 0);
|
|
2289
|
-
} catch (e: unknown) {
|
|
2290
|
-
if ((e as NodeJS.ErrnoException).code !== "EPERM") return null;
|
|
2291
|
-
}
|
|
2292
|
-
return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null;
|
|
3320
|
+
function redactIssuePath(path: readonly PropertyKey[]): string {
|
|
3321
|
+
return path
|
|
3322
|
+
.map((segment, index) => (index === 0 && typeof segment === "string" ? segment : redactSecretString(String(segment))))
|
|
3323
|
+
.join(".");
|
|
2293
3324
|
}
|
|
2294
3325
|
|
|
2295
|
-
function
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, ""));
|
|
2306
|
-
const value = match?.[1]?.trim();
|
|
2307
|
-
if (value) return value;
|
|
2308
|
-
} catch {
|
|
2309
|
-
/* WMIC missing or failed — fall through */
|
|
2310
|
-
}
|
|
2311
|
-
const output = execFileSync("powershell.exe", [
|
|
2312
|
-
"-NoProfile",
|
|
2313
|
-
"-NoLogo",
|
|
2314
|
-
"-NonInteractive",
|
|
2315
|
-
"-WindowStyle",
|
|
2316
|
-
"Hidden",
|
|
2317
|
-
"-Command",
|
|
2318
|
-
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
|
|
2319
|
-
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
|
|
2320
|
-
return output.trim() || undefined;
|
|
2321
|
-
}
|
|
2322
|
-
const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
|
|
2323
|
-
encoding: "utf-8",
|
|
2324
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
2325
|
-
timeout: 1000,
|
|
2326
|
-
windowsHide: true,
|
|
2327
|
-
});
|
|
2328
|
-
return output.trim() || undefined;
|
|
2329
|
-
} catch {
|
|
2330
|
-
return undefined;
|
|
2331
|
-
}
|
|
3326
|
+
function warnDroppedConfigSections(configPath: string, dropped: string[], issues: readonly z.ZodIssue[]): void {
|
|
3327
|
+
if (warnedConfigFallbacks.has(configPath)) return;
|
|
3328
|
+
warnedConfigFallbacks.add(configPath);
|
|
3329
|
+
const reasons = issues
|
|
3330
|
+
.map(issue => `${redactIssuePath(issue.path)}: ${redactSecretString(issue.message)}`)
|
|
3331
|
+
.join("; ");
|
|
3332
|
+
console.error(
|
|
3333
|
+
`opencodex config at ${configPath}: dropped [${dropped.map(redactEntryPath).join(", ")}] and loaded the rest — ${reasons}. `
|
|
3334
|
+
+ "Everything else in your config, including providers and modelCosts, is preserved.",
|
|
3335
|
+
);
|
|
2332
3336
|
}
|
|
2333
3337
|
|
|
2334
3338
|
function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
|