@bitkyc08/opencodex 2.58.0 → 2.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -10
- package/gui/dist/assets/index-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/crusoe.svg +1 -0
- package/gui/dist/provider-icons/opper.svg +3 -0
- package/package.json +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +34 -1
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-request.ts +8 -5
- package/src/adapters/cursor/request-builder.ts +21 -4
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +45 -2
- package/src/adapters/devin/cloud-direct/chat.ts +14 -3
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
- package/src/adapters/devin.ts +125 -26
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +55 -4
- package/src/adapters/google-http.ts +57 -11
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +60 -10
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +10 -11
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +25 -4
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +28 -35
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +35 -4
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +26 -6
- package/src/cli/account-orca-import.ts +63 -0
- package/src/cli/account.ts +32 -4
- package/src/cli/capabilities.ts +40 -0
- package/src/cli/claude.ts +29 -1
- package/src/cli/codex-cli-update.ts +97 -2
- package/src/cli/dispatch.ts +57 -3
- package/src/cli/doctor.ts +218 -4
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +95 -22
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +11 -1
- package/src/cli/runtime-api.ts +44 -0
- package/src/cli/start-args.ts +94 -0
- package/src/cli/system-command.ts +2 -0
- package/src/client/machine-api.ts +6 -5
- package/src/client/machine-listener.ts +16 -3
- package/src/client/runtime.ts +26 -2
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +146 -5
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +44 -10
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +16 -12
- package/src/codex/catalog/build-entries.ts +25 -4
- package/src/codex/catalog/derive-entry.ts +8 -1
- package/src/codex/catalog/effort.ts +10 -6
- package/src/codex/catalog/gather-capture.ts +22 -2
- package/src/codex/catalog/model-hints.ts +66 -33
- package/src/codex/catalog/parsing.ts +90 -5
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +41 -26
- package/src/codex/catalog/routed-gather.ts +61 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +24 -167
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject.ts +3 -2
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +31 -0
- package/src/codex/main-account-hard-lock.ts +2 -1
- package/src/codex/main-account.ts +10 -3
- package/src/codex/main-device-reauth.ts +17 -9
- package/src/codex/model-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +80 -2
- package/src/codex/observed-model-denials.ts +230 -0
- package/src/codex/orca-auth-source.ts +94 -0
- package/src/codex/orca-import.ts +219 -0
- package/src/codex/prompt-text-probe.ts +289 -16
- package/src/codex/quota-401-recovery.ts +12 -0
- package/src/codex/quota-types.ts +65 -0
- package/src/codex/quota.ts +24 -19
- package/src/codex/routing/cooldown-math.ts +8 -47
- package/src/codex/routing/pin-drain.ts +57 -0
- package/src/codex/routing.ts +20 -16
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +114 -2
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +125 -5
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +43 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +33 -0
- package/src/config/pending-teardown.ts +8 -0
- package/src/config/process-state.ts +36 -3
- package/src/config/provider-relative-send-path.ts +16 -0
- package/src/config/proxy-env.ts +31 -7
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +25 -0
- package/src/config/schema/leaf-validators.ts +76 -17
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +410 -258
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +82 -15
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-outbound.ts +53 -5
- package/src/lib/proxy-env.ts +70 -3
- package/src/lib/request-execution-budget.ts +11 -3
- package/src/lib/response-body-inactivity.ts +193 -0
- package/src/lib/retry-delay.ts +69 -0
- package/src/lib/socks5-fetch.ts +741 -0
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +332 -35
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +41 -27
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/oauth/orcarouter.ts +15 -2
- package/src/oauth/store.ts +8 -0
- package/src/providers/codex-capacity.ts +9 -0
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +327 -25
- package/src/providers/model-rename-migration.ts +56 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +131 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +87 -21
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/responses/apply-patch-envelope.ts +32 -11
- package/src/responses/bridge-search-replay-cache.ts +152 -0
- package/src/responses/code-mode-helper-compat.ts +26 -16
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +9 -2
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +13 -5
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +30 -5
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +24 -25
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +48 -7
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +136 -34
- package/src/server/request-log.ts +80 -3
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +74 -30
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +63 -30
- package/src/server/responses/compact.ts +89 -18
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +90 -24
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +16 -15
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +41 -15
- package/src/server/responses/passthrough-dispatch.ts +145 -53
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +96 -17
- package/src/server/responses/request-send-budget.ts +89 -8
- package/src/server/responses/request-sidecar-auth.ts +17 -9
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +15 -12
- package/src/server/responses/run-turn-execution.ts +45 -8
- package/src/server/responses/sidecar-execution.ts +19 -2
- package/src/server/responses/ws-upstream.ts +16 -28
- package/src/server/responses-custom-tool-repair.ts +29 -56
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +37 -10
- package/src/server/system-env-shell.ts +5 -1
- package/src/server/system-env.ts +7 -1
- package/src/server/workflow-refusal.ts +56 -2
- package/src/service/cli.ts +16 -6
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +111 -6
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +9 -1
- package/src/types/tools.ts +14 -0
- package/src/types/wire.ts +9 -1
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
- package/gui/dist/assets/index-C5-RdDmD.css +0 -1
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { booleanRecordConfigError } from "../../config/provider-validation";
|
|
2
2
|
import type { OcxConfig } from "../../types";
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Provider-management validation that belongs to the provider editor, not the
|
|
6
|
-
* request-authentication/CORS boundary. Keeping this here lets the editor
|
|
7
|
-
* evolve its capability schema without widening the auth-cors security surface.
|
|
8
|
-
*/
|
|
4
|
+
/** Provider-management validation shared by provider editor write paths. */
|
|
9
5
|
export function providerServiceTierConfigError(name: unknown, provider: unknown): string | null {
|
|
10
6
|
if (typeof name !== "string" || !provider || typeof provider !== "object" || Array.isArray(provider)) {
|
|
11
7
|
return null;
|
|
@@ -17,7 +13,20 @@ export function providerServiceTierConfigError(name: unknown, provider: unknown)
|
|
|
17
13
|
return error ? `provider ${name} ${error}` : null;
|
|
18
14
|
}
|
|
19
15
|
|
|
20
|
-
function
|
|
16
|
+
export function providerCatalogCapabilityConfigError(name: unknown, provider: unknown): string | null {
|
|
17
|
+
const serviceTierError = providerServiceTierConfigError(name, provider);
|
|
18
|
+
if (serviceTierError) return serviceTierError;
|
|
19
|
+
if (typeof name !== "string" || !provider || typeof provider !== "object" || Array.isArray(provider)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const error = booleanRecordConfigError(
|
|
23
|
+
(provider as { modelSuppressSyntheticMax?: unknown }).modelSuppressSyntheticMax,
|
|
24
|
+
"modelSuppressSyntheticMax",
|
|
25
|
+
);
|
|
26
|
+
return error ? `provider ${name} ${error}` : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function publicBooleanRecord(value: unknown): Record<string, boolean> | undefined {
|
|
21
30
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
22
31
|
const entries = Object.entries(value).filter(([model, supported]) =>
|
|
23
32
|
model.trim().length > 0 && typeof supported === "boolean",
|
|
@@ -40,9 +49,28 @@ export function withProviderServiceTierDTO(dto: unknown, config: OcxConfig): unk
|
|
|
40
49
|
for (const [name, provider] of Object.entries(config.providers)) {
|
|
41
50
|
const dtoProvider = providers[name];
|
|
42
51
|
if (!dtoProvider || typeof dtoProvider !== "object" || Array.isArray(dtoProvider)) continue;
|
|
43
|
-
const capabilities =
|
|
52
|
+
const capabilities = publicBooleanRecord(provider.modelSupportsServiceTier);
|
|
44
53
|
if (capabilities === undefined) continue;
|
|
45
54
|
projectedProviders[name] = { ...(dtoProvider as Record<string, unknown>), modelSupportsServiceTier: capabilities };
|
|
46
55
|
}
|
|
47
56
|
return { ...root, providers: projectedProviders };
|
|
48
57
|
}
|
|
58
|
+
|
|
59
|
+
/** Project all catalog-only model capability maps across the dashboard config boundary. */
|
|
60
|
+
export function withProviderCatalogCapabilityDTO(dto: unknown, config: OcxConfig): unknown {
|
|
61
|
+
const projected = withProviderServiceTierDTO(dto, config);
|
|
62
|
+
if (!projected || typeof projected !== "object" || Array.isArray(projected)) return projected;
|
|
63
|
+
const root = projected as { providers?: unknown };
|
|
64
|
+
if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return projected;
|
|
65
|
+
|
|
66
|
+
const providers = root.providers as Record<string, unknown>;
|
|
67
|
+
const projectedProviders: Record<string, unknown> = { ...providers };
|
|
68
|
+
for (const [name, provider] of Object.entries(config.providers)) {
|
|
69
|
+
const dtoProvider = providers[name];
|
|
70
|
+
if (!dtoProvider || typeof dtoProvider !== "object" || Array.isArray(dtoProvider)) continue;
|
|
71
|
+
const capabilities = publicBooleanRecord(provider.modelSuppressSyntheticMax);
|
|
72
|
+
if (capabilities === undefined) continue;
|
|
73
|
+
projectedProviders[name] = { ...(dtoProvider as Record<string, unknown>), modelSuppressSyntheticMax: capabilities };
|
|
74
|
+
}
|
|
75
|
+
return { ...root, providers: projectedProviders };
|
|
76
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { modelCapabilitiesConfigError, mergeModelCapabilities } from "../../config/provider-validation";
|
|
2
|
+
import { DECLARABLE_HOSTED_TOOL_TYPES } from "../../responses/hosted-tool-policy";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { readFileSync } from "node:fs";
|
|
4
5
|
import { isDeepStrictEqual } from "node:util";
|
|
@@ -103,7 +104,7 @@ import {
|
|
|
103
104
|
type ProviderEditorConfigDTO,
|
|
104
105
|
type ProviderEditorProviderDTO,
|
|
105
106
|
} from "../auth-cors";
|
|
106
|
-
import {
|
|
107
|
+
import { providerCatalogCapabilityConfigError } from "./provider-capability-config";
|
|
107
108
|
import { providerEmptyToolOutputConfigError } from "../../config/provider-validation";
|
|
108
109
|
import { applySystemEnvToggle } from "../system-env";
|
|
109
110
|
import {
|
|
@@ -264,7 +265,7 @@ function providerEditorCandidate(
|
|
|
264
265
|
// the seed check must only pin the canonical transport/auth keys.
|
|
265
266
|
const providerError = providerManagementConfigError(name, transportCandidate, { allowOperatorOverlays: true })
|
|
266
267
|
?? providerEmptyToolOutputConfigError(name, transportCandidate)
|
|
267
|
-
??
|
|
268
|
+
?? providerCatalogCapabilityConfigError(name, transportCandidate);
|
|
268
269
|
if (providerError) return { ok: false, status: 400, error: providerError, code: "invalid_provider" };
|
|
269
270
|
providers[name] = merged;
|
|
270
271
|
}
|
|
@@ -516,7 +517,14 @@ function applyProviderPatchFields(
|
|
|
516
517
|
delete next.modelContextWindows;
|
|
517
518
|
} else {
|
|
518
519
|
if (!isPlainRecord(value)) return { error: "modelContextWindows must be a plain object or null" };
|
|
519
|
-
|
|
520
|
+
// A prototype-named model id must survive the merge: assigning
|
|
521
|
+
// "__proto__" on an ordinary object invokes the inherited setter instead
|
|
522
|
+
// of creating an own property, so the PATCH would report success while
|
|
523
|
+
// silently dropping that override.
|
|
524
|
+
const windows: Record<string, number> = Object.assign(
|
|
525
|
+
Object.create(null),
|
|
526
|
+
next.modelContextWindows ?? {},
|
|
527
|
+
);
|
|
520
528
|
for (const [model, window] of Object.entries(value)) {
|
|
521
529
|
if (!model.trim()) return { error: "modelContextWindows keys must be nonblank model ids" };
|
|
522
530
|
if (window === null) {
|
|
@@ -639,6 +647,29 @@ function applyProviderPatchFields(
|
|
|
639
647
|
}
|
|
640
648
|
touched = true;
|
|
641
649
|
}
|
|
650
|
+
if (Object.hasOwn(rawBody, "modelSuppressSyntheticMax")) {
|
|
651
|
+
const value = rawBody.modelSuppressSyntheticMax;
|
|
652
|
+
if (value === null) {
|
|
653
|
+
delete next.modelSuppressSyntheticMax;
|
|
654
|
+
} else {
|
|
655
|
+
if (!isPlainRecord(value)) return { error: "modelSuppressSyntheticMax must be a plain object or null" };
|
|
656
|
+
const capabilities: Record<string, boolean> = { ...(next.modelSuppressSyntheticMax ?? {}) };
|
|
657
|
+
for (const [model, suppressed] of Object.entries(value)) {
|
|
658
|
+
if (!model.trim()) return { error: "modelSuppressSyntheticMax keys must be nonblank model ids" };
|
|
659
|
+
if (suppressed === null) {
|
|
660
|
+
delete capabilities[model];
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (typeof suppressed !== "boolean") {
|
|
664
|
+
return { error: "modelSuppressSyntheticMax values must be booleans or null" };
|
|
665
|
+
}
|
|
666
|
+
capabilities[model] = suppressed;
|
|
667
|
+
}
|
|
668
|
+
if (Object.keys(capabilities).length > 0) next.modelSuppressSyntheticMax = capabilities;
|
|
669
|
+
else delete next.modelSuppressSyntheticMax;
|
|
670
|
+
}
|
|
671
|
+
touched = true;
|
|
672
|
+
}
|
|
642
673
|
if (Object.hasOwn(rawBody, "noStructuredOutputModels")) {
|
|
643
674
|
const value = rawBody.noStructuredOutputModels;
|
|
644
675
|
if (value === null) {
|
|
@@ -665,6 +696,26 @@ function applyProviderPatchFields(
|
|
|
665
696
|
}
|
|
666
697
|
touched = true;
|
|
667
698
|
}
|
|
699
|
+
if (Object.hasOwn(rawBody, "unsupportedHostedTools")) {
|
|
700
|
+
const value = rawBody.unsupportedHostedTools;
|
|
701
|
+
if (value === null) {
|
|
702
|
+
delete next.unsupportedHostedTools;
|
|
703
|
+
} else {
|
|
704
|
+
const error = nonBlankStringArrayConfigError(value, "unsupportedHostedTools");
|
|
705
|
+
if (error) return { error };
|
|
706
|
+
const tools = normalizeNonBlankStringArray(value as string[]);
|
|
707
|
+
const unknownTool = tools.find(tool => !DECLARABLE_HOSTED_TOOL_TYPES.has(tool));
|
|
708
|
+
if (unknownTool !== undefined) {
|
|
709
|
+
return {
|
|
710
|
+
error: `unsupportedHostedTools must name only hosted tool types: `
|
|
711
|
+
+ `${[...DECLARABLE_HOSTED_TOOL_TYPES].join(", ")}`,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
if (tools.length > 0) next.unsupportedHostedTools = tools;
|
|
715
|
+
else delete next.unsupportedHostedTools;
|
|
716
|
+
}
|
|
717
|
+
touched = true;
|
|
718
|
+
}
|
|
668
719
|
if (Object.hasOwn(rawBody, "retainModels")) {
|
|
669
720
|
const value = rawBody.retainModels;
|
|
670
721
|
if (value === null) {
|
|
@@ -864,8 +915,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
864
915
|
modelPinnedReasoningEfforts: p.modelPinnedReasoningEfforts,
|
|
865
916
|
modelAutoCompactTokenLimits: p.modelAutoCompactTokenLimits,
|
|
866
917
|
modelSupportsServiceTier: p.modelSupportsServiceTier,
|
|
918
|
+
modelSuppressSyntheticMax: p.modelSuppressSyntheticMax,
|
|
867
919
|
noStructuredOutputModels: p.noStructuredOutputModels,
|
|
868
920
|
noJsonSchemaModels: p.noJsonSchemaModels,
|
|
921
|
+
unsupportedHostedTools: p.unsupportedHostedTools,
|
|
869
922
|
retainModels: p.retainModels,
|
|
870
923
|
omitReasoningEffortWithToolsModels: p.omitReasoningEffortWithToolsModels,
|
|
871
924
|
upstreamHttpVersion: p.upstreamHttpVersion,
|
|
@@ -1093,8 +1146,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1093
1146
|
if (rawProvider.upstreamWebsocket !== undefined && typeof rawProvider.upstreamWebsocket !== "boolean") {
|
|
1094
1147
|
return jsonResponse({ error: "upstreamWebsocket must be a boolean" }, 400);
|
|
1095
1148
|
}
|
|
1096
|
-
const
|
|
1097
|
-
if (
|
|
1149
|
+
const catalogCapabilityError = providerCatalogCapabilityConfigError(name, transportCandidate);
|
|
1150
|
+
if (catalogCapabilityError) return jsonResponse({ error: catalogCapabilityError }, 400);
|
|
1098
1151
|
const prov = stripCodexRuntimeProviderFields(transportCandidate as unknown as OcxProviderConfig);
|
|
1099
1152
|
// PATCH already clears on null; POST persisted the body as submitted, so a `null` here
|
|
1100
1153
|
// reached disk and the next loadConfig() refused it. Canonicalize to absent, which is what
|
|
@@ -1257,19 +1310,18 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1257
1310
|
|| Object.hasOwn(body.provider, "modelPinnedReasoningEfforts")
|
|
1258
1311
|
|| latest?.pinnedReasoningEffort !== undefined || latest?.modelPinnedReasoningEfforts !== undefined;
|
|
1259
1312
|
// New registration also edits discovery/disabled-model state; stage those
|
|
1260
|
-
// side effects with the
|
|
1261
|
-
|
|
1313
|
+
// side effects with the registration draft instead of mutating live state
|
|
1314
|
+
// before validation.
|
|
1315
|
+
const registrationDraft = !latest ? {
|
|
1262
1316
|
...config,
|
|
1263
1317
|
...(config.modelDiscovery === undefined ? {} : { modelDiscovery: structuredClone(config.modelDiscovery) }),
|
|
1264
1318
|
} : undefined;
|
|
1265
1319
|
initializeProviderModelSelection(name, prov, latest, registrationDraft ?? config);
|
|
1266
1320
|
const candidate = stripRegistryOnlyStaticHeaders(name, prov);
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
if (!validation.ok) return jsonResponse({ error: validation.error }, 400);
|
|
1272
|
-
}
|
|
1321
|
+
const draft = { ...(registrationDraft ?? config), providers: { ...config.providers, [name]: candidate },
|
|
1322
|
+
...(body.setDefault === true ? { defaultProvider: name } : {}) };
|
|
1323
|
+
const validation = validateConfigCandidate(draft);
|
|
1324
|
+
if (!validation.ok) return jsonResponse({ error: validation.error }, 400);
|
|
1273
1325
|
const previous = Object.getOwnPropertyDescriptor(config.providers, name);
|
|
1274
1326
|
const rollback = pinsOwned ? captureConfigTopLevelRollback(config, ["defaultProvider", "modelDiscovery", "disabledModels"]) : undefined;
|
|
1275
1327
|
try {
|
|
@@ -1397,8 +1449,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1397
1449
|
?? providerEmptyToolOutputConfigError(name, next);
|
|
1398
1450
|
if (providerError) return jsonResponse({ error: providerError }, 400);
|
|
1399
1451
|
if (!canonicalBudgetOnly) {
|
|
1400
|
-
const
|
|
1401
|
-
if (
|
|
1452
|
+
const catalogCapabilityError = providerCatalogCapabilityConfigError(name, next);
|
|
1453
|
+
if (catalogCapabilityError) return jsonResponse({ error: catalogCapabilityError }, 400);
|
|
1402
1454
|
// Same DNS gate as POST and re-enable: the canonical built-in OpenAI forward
|
|
1403
1455
|
// provider may resolve through Clash/Mihomo fake-IP DNS (198.18.0.0/15), so the
|
|
1404
1456
|
// ordinary PATCH must not reject the very same destination the provider was
|
|
@@ -1444,9 +1496,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1444
1496
|
return;
|
|
1445
1497
|
}
|
|
1446
1498
|
if (!canonicalBudgetOnly) {
|
|
1447
|
-
const
|
|
1448
|
-
if (
|
|
1449
|
-
replayError =
|
|
1499
|
+
const catalogCapabilityError = providerCatalogCapabilityConfigError(name, replay.next);
|
|
1500
|
+
if (catalogCapabilityError) {
|
|
1501
|
+
replayError = catalogCapabilityError;
|
|
1450
1502
|
return;
|
|
1451
1503
|
}
|
|
1452
1504
|
}
|
|
@@ -38,8 +38,17 @@ export type ExemptionReason =
|
|
|
38
38
|
| "test-seam"
|
|
39
39
|
/** The CLI reaches the same data through a local transport instead of HTTP. */
|
|
40
40
|
| "local-transport"
|
|
41
|
+
/** Machine scrape target whose HTTP exposition is the operator contract. */
|
|
42
|
+
| "scrape-target"
|
|
41
43
|
/** Older clients use this alias; the current CLI drives its declared replacement. */
|
|
42
44
|
| "compatibility-alias"
|
|
45
|
+
/**
|
|
46
|
+
* A read-only POST whose purpose is to bind an interactive confirmation to the mutation that
|
|
47
|
+
* immediately follows it. There is no standalone thing for a CLI to do with one: the plan is
|
|
48
|
+
* only meaningful to the caller that is about to commit it, and a scripted caller drives the
|
|
49
|
+
* mutation directly.
|
|
50
|
+
*/
|
|
51
|
+
| "interactive-preview"
|
|
43
52
|
/** Unreachable in the live dispatch order; delete rather than expose. */
|
|
44
53
|
| "dead"
|
|
45
54
|
/**
|
|
@@ -159,6 +168,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
159
168
|
{ method: "GET", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
|
|
160
169
|
{ method: "DELETE", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside history deletion uses the dashboard journal cleanup; the CLI has history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
|
|
161
170
|
{ method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
|
|
171
|
+
{ method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/preview", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode", exempt: { reason: "interactive-preview", why: "Plans one Aside profile's change so a dashboard confirmation can show what would change and bind to it. Read-only, and useful only to the caller about to commit; a scripted caller drives the profile toggle or restore directly." } },
|
|
162
172
|
{ method: "DELETE", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside profile history deletion uses the dashboard journal cleanup; the CLI has scoped history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
|
|
163
173
|
{ method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/restore", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" },
|
|
164
174
|
// server/management/codex-prompt-routes
|
|
@@ -197,6 +207,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
197
207
|
{ method: "GET", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: false },
|
|
198
208
|
{ method: "DELETE", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Retiring one rollback row is a dashboard-local cleanup; the CLI verb that would drive it is owed by a later work-phase and is not implemented here.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
|
|
199
209
|
{ method: "POST", path: "/api/client-integrations/restore", module: "server/management/integration-routes", mutates: true },
|
|
210
|
+
{ method: "POST", path: "/api/client-integrations/preview", module: "server/management/integration-routes", mutates: false, exempt: { reason: "interactive-preview", why: "Plans an apply, overwrite or disable so a dashboard confirmation can show what would change and bind to it. Read-only, and useful only to the caller about to commit; a scripted caller drives PUT /api/client-integrations/{clientId} directly." } },
|
|
211
|
+
{ method: "POST", path: "/api/client-integrations/restore/preview", module: "server/management/integration-routes", mutates: false, exempt: { reason: "interactive-preview", why: "Plans an undo so drift is shown before a restore rather than discovered by a rejected mutation. Read-only, and useful only to the caller about to commit; a scripted caller drives POST /api/client-integrations/restore directly." } },
|
|
200
212
|
// server/management/lab-automation-routes
|
|
201
213
|
{ method: "GET", path: "/api/lab/automation", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } },
|
|
202
214
|
{ method: "GET", path: "/api/lab/automation/runs", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } },
|
|
@@ -234,6 +246,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
234
246
|
{ method: "POST", path: "/api/storage/trash/restore", module: "server/management/logs-usage-routes", mutates: true },
|
|
235
247
|
{ method: "PUT", path: "/api/debug", module: "server/management/logs-usage-routes", mutates: true },
|
|
236
248
|
{ method: "PUT", path: "/api/storage/cleanup-policy", module: "server/management/logs-usage-routes", mutates: true },
|
|
249
|
+
// server/management/metrics-routes
|
|
250
|
+
{ method: "GET", path: "/api/metrics", module: "server/management/metrics-routes", mutates: false, exempt: { reason: "scrape-target", why: "This machine scrape target exposes authenticated text exposition for monitoring systems; a CLI JSON verb would be a different contract." } },
|
|
237
251
|
// server/management/model-routes
|
|
238
252
|
{ method: "GET", path: "/api/aliases", module: "server/management/model-routes", mutates: false },
|
|
239
253
|
{ method: "GET", path: "/api/catalog", module: "server/management/model-routes", mutates: false },
|
|
@@ -245,12 +245,19 @@ export function requestLogDto(
|
|
|
245
245
|
* share the same fetch, the same per-provider cache (dedups Codex's frequent /v1/models polling),
|
|
246
246
|
* and the same stale fallback when a provider blips, instead of a parallel uncached copy.
|
|
247
247
|
*/
|
|
248
|
-
export async function fetchAllModels(
|
|
248
|
+
export async function fetchAllModels(
|
|
249
|
+
config: OcxConfig,
|
|
250
|
+
/** Filled with each provider's content revision as of the moment its rows were chosen. */
|
|
251
|
+
providerContentRevisions?: Map<string, string>,
|
|
252
|
+
): Promise<CatalogModel[]> {
|
|
249
253
|
const { gatherRoutedModels } = await import("../../codex/catalog");
|
|
250
254
|
const baseline = captureInitialSelectionBaseline(config);
|
|
251
|
-
if (!baseline) return gatherRoutedModels(config);
|
|
255
|
+
if (!baseline) return gatherRoutedModels(config, providerContentRevisions ? { providerContentRevisions } : undefined);
|
|
252
256
|
const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = [];
|
|
253
|
-
const models = await gatherRoutedModels(config, {
|
|
257
|
+
const models = await gatherRoutedModels(config, {
|
|
258
|
+
providerModelOutcomes: outcomes,
|
|
259
|
+
...(providerContentRevisions ? { providerContentRevisions } : {}),
|
|
260
|
+
});
|
|
254
261
|
finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models),
|
|
255
262
|
outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider));
|
|
256
263
|
return models;
|
|
@@ -36,6 +36,7 @@ import { isServiceViable } from "../../service";
|
|
|
36
36
|
import { readRuntimePort } from "../../config/process-state";
|
|
37
37
|
import { withProcessRuntimeProvenance } from "../../lib/bun-runtime";
|
|
38
38
|
import { selfLaunchArgv } from "../../lib/self-launch-argv";
|
|
39
|
+
import { spendLedgerRestartEnvironment } from "../../lib/spend-ledger-owner";
|
|
39
40
|
import {
|
|
40
41
|
MEMORY_DRAIN_RESTART_MS,
|
|
41
42
|
REPLACEMENT_READY_TIMEOUT_MS,
|
|
@@ -225,8 +226,12 @@ function spawnDetachedStart(
|
|
|
225
226
|
return new Promise<void>((resolve, reject) => {
|
|
226
227
|
let child: ReturnType<typeof spawn>;
|
|
227
228
|
try {
|
|
228
|
-
const
|
|
229
|
-
delete
|
|
229
|
+
const sourceEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
230
|
+
delete sourceEnv.OCX_SERVICE;
|
|
231
|
+
const env = spendLedgerRestartEnvironment(
|
|
232
|
+
sourceEnv,
|
|
233
|
+
waitForHealthBeforeParentExit ? undefined : process.pid,
|
|
234
|
+
);
|
|
230
235
|
child = spawn(process.execPath, launchArgs, {
|
|
231
236
|
detached: true,
|
|
232
237
|
stdio: "ignore",
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from "../../lib/codex-restart-contract";
|
|
40
40
|
import { jsonResponse } from "../auth-cors";
|
|
41
41
|
import { getInspectionCounters } from "../relay";
|
|
42
|
+
import { spendLedgerDiagnosticsSnapshot } from "../../lib/spend-reservation-ledger";
|
|
42
43
|
import type {
|
|
43
44
|
performCodexRestart,
|
|
44
45
|
readCodexAppServerState,
|
|
@@ -60,6 +61,7 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
60
61
|
version,
|
|
61
62
|
uptime: process.uptime(),
|
|
62
63
|
pid: process.pid,
|
|
64
|
+
spendLedger: spendLedgerDiagnosticsSnapshot(),
|
|
63
65
|
});
|
|
64
66
|
}
|
|
65
67
|
if (url.pathname === "/api/system/memory" && req.method === "GET") {
|
|
@@ -66,6 +66,7 @@ const pinnedAggregates = new Set<RetainedUsageAggregate>();
|
|
|
66
66
|
let baseFlight: Promise<UsageAggregateResult> | null = null;
|
|
67
67
|
const filteredFlights = new Map<string, Promise<UsageAggregateResult>>();
|
|
68
68
|
const retainedFilteredAggregates = new Map<string, RetainedUsageAggregate>();
|
|
69
|
+
const MAX_CONCURRENT_FILTERED_AGGREGATES = 4;
|
|
69
70
|
|
|
70
71
|
function currentTimeZone(): string {
|
|
71
72
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
@@ -283,6 +284,9 @@ export async function getFilteredUsageAggregate(filter: {
|
|
|
283
284
|
]);
|
|
284
285
|
const existing = filteredFlights.get(key);
|
|
285
286
|
if (existing) return existing;
|
|
287
|
+
if (filteredFlights.size >= MAX_CONCURRENT_FILTERED_AGGREGATES) {
|
|
288
|
+
throw new Error("too many concurrent filtered usage aggregates");
|
|
289
|
+
}
|
|
286
290
|
|
|
287
291
|
const flight = refreshFilteredAggregate(key, normalizedFilter, fixedWindow);
|
|
288
292
|
filteredFlights.set(key, flight);
|
|
@@ -63,6 +63,7 @@ import { handleLogsUsageRoutes } from "./management/logs-usage-routes";
|
|
|
63
63
|
import { handleStorageLogGuardRoutes } from "./management/storage-log-guard-routes";
|
|
64
64
|
import { handleRequestHistoryRoutes } from "./management/request-history-routes";
|
|
65
65
|
import { handleRoutingAnalyticsRoutes } from "./management/routing-analytics-routes";
|
|
66
|
+
import { handleMetricsRoutes } from "./management/metrics-routes";
|
|
66
67
|
import { handleProviderRoutes } from "./management/provider-routes";
|
|
67
68
|
import { handleModelRoutes } from "./management/model-routes";
|
|
68
69
|
import { handleAgentSettingsRoutes } from "./management/agent-settings-routes";
|
|
@@ -276,6 +277,7 @@ export async function handleManagementAPI(
|
|
|
276
277
|
?? (await handleQuotaResetRoutesOnDemand(ctx))
|
|
277
278
|
?? (await handleWorkflowBudgetRoutesOnDemand(ctx))
|
|
278
279
|
?? (await handleGrokCouponRoutesOnDemand(ctx))
|
|
280
|
+
?? handleMetricsRoutes(ctx)
|
|
279
281
|
?? (await handleRoutingAnalyticsRoutes(ctx))
|
|
280
282
|
?? (await handleRoutingProfileRoutesOnDemand(ctx))
|
|
281
283
|
?? (await handleProviderRoutes(ctx))
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
writeFileSync,
|
|
13
13
|
} from "node:fs";
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
|
-
import { adminApiTokenFilePath } from "../lib/admin-secrets";
|
|
15
|
+
import { adminApiTokenFilePath, opencodeCatalogToken } from "../lib/admin-secrets";
|
|
16
16
|
import {
|
|
17
17
|
LOCAL_MANAGEMENT_CAPABILITY_HEADER,
|
|
18
18
|
LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER,
|
|
@@ -297,6 +297,7 @@ export function createManagementSessionControl(state: ManagementAuthState): Mana
|
|
|
297
297
|
*/
|
|
298
298
|
export type ManagementPrincipal =
|
|
299
299
|
| "admin-token"
|
|
300
|
+
| "opencode-catalog-token"
|
|
300
301
|
| "gui-session"
|
|
301
302
|
| "gui-pair-capability"
|
|
302
303
|
| "local-read-capability"
|
|
@@ -484,6 +485,16 @@ function requestManagementCredential(req: Request): string | null {
|
|
|
484
485
|
|| null;
|
|
485
486
|
}
|
|
486
487
|
|
|
488
|
+
function isOpencodeCatalogRequest(req: Request): boolean {
|
|
489
|
+
if (req.method !== "GET") return false;
|
|
490
|
+
try {
|
|
491
|
+
const url = new URL(req.url);
|
|
492
|
+
return url.pathname === "/api/models" && url.search === "";
|
|
493
|
+
} catch {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
487
498
|
function resolveManagementAdmission(
|
|
488
499
|
req: Request,
|
|
489
500
|
state: ManagementAuthState,
|
|
@@ -500,6 +511,9 @@ function resolveManagementAdmission(
|
|
|
500
511
|
else if (state.available) {
|
|
501
512
|
const actual = requestManagementCredential(req);
|
|
502
513
|
if (actual && equalSecret(actual, state.token)) principal = "admin-token";
|
|
514
|
+
else if (actual && isOpencodeCatalogRequest(req) && equalSecret(actual, opencodeCatalogToken(state.token))) {
|
|
515
|
+
principal = "opencode-catalog-token";
|
|
516
|
+
}
|
|
503
517
|
else if (config && authorizeGuiSessionRequest(req, config, state).ok) principal = "gui-session";
|
|
504
518
|
}
|
|
505
519
|
if (principal) admittedManagementRequests.set(req, principal);
|
|
@@ -21,6 +21,12 @@ export interface HealthzIdentity {
|
|
|
21
21
|
uptime?: unknown;
|
|
22
22
|
pid?: unknown;
|
|
23
23
|
port?: unknown;
|
|
24
|
+
/**
|
|
25
|
+
* Which listener answered: the standalone/hub server omits it, and the connected-client
|
|
26
|
+
* machine listener reports `"client"` (src/client/machine-listener.ts). It is the only
|
|
27
|
+
* on-the-wire way to tell those two apart, because both bind `config.port ?? 10100`.
|
|
28
|
+
*/
|
|
29
|
+
role?: unknown;
|
|
24
30
|
restartCapability?: unknown;
|
|
25
31
|
providerReloadCapability?: unknown;
|
|
26
32
|
guiPairCapability?: unknown;
|
|
@@ -63,6 +69,22 @@ export const SERVICE_STOP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> =
|
|
|
63
69
|
attempts: 3,
|
|
64
70
|
};
|
|
65
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Probe budget for a decision whose wrong answer starts a DUPLICATE proxy (#5004).
|
|
74
|
+
*
|
|
75
|
+
* `start` used the 750ms single-attempt default for both the pre-bind owner probe and
|
|
76
|
+
* (implicitly) the busy-port question behind the ephemeral hop. On Windows that answered
|
|
77
|
+
* "nothing is listening" for a proxy the previous command had just refused to shadow, and
|
|
78
|
+
* the hop then spawned a second instance that took over this home's pid/runtime records
|
|
79
|
+
* and re-pointed Codex at itself. A single unanswered probe is not evidence of absence
|
|
80
|
+
* when the failure mode is a duplicate instance, so the start path borrows the numbers
|
|
81
|
+
* the stop path already uses for the mirror-image decision.
|
|
82
|
+
*/
|
|
83
|
+
export const START_OWNERSHIP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> = {
|
|
84
|
+
timeoutMs: 1500,
|
|
85
|
+
attempts: 3,
|
|
86
|
+
};
|
|
87
|
+
|
|
66
88
|
export interface LiveProxy {
|
|
67
89
|
pid: number | null;
|
|
68
90
|
port: number;
|
|
@@ -78,6 +100,16 @@ export interface LiveProxy {
|
|
|
78
100
|
* healthz body predates the field.
|
|
79
101
|
*/
|
|
80
102
|
version?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Role the live listener reported on `/healthz`, when it reported one: `"client"` for the
|
|
105
|
+
* connected-client machine listener, absent for a standalone or hub proxy.
|
|
106
|
+
*
|
|
107
|
+
* Liveness deliberately still ACCEPTS a client-role listener — see `isOpencodexHealthz`.
|
|
108
|
+
* The role is carried so a caller that needs a management plane (src/cli/runtime-api.ts)
|
|
109
|
+
* can refuse one, while `stop` and orphan cleanup keep finding the process they must act
|
|
110
|
+
* on. Absent for a proxy whose healthz body predates the field.
|
|
111
|
+
*/
|
|
112
|
+
role?: string;
|
|
81
113
|
}
|
|
82
114
|
|
|
83
115
|
/**
|
|
@@ -101,6 +133,13 @@ export function probeHostname(hostname: string | undefined): string {
|
|
|
101
133
|
* `service: "opencodex"` marker, plus the legacy `{status, version, uptime}` trio so a
|
|
102
134
|
* still-running pre-identity proxy (e.g. right after `ocx update`) is not mistaken for a
|
|
103
135
|
* foreign server and shadow-started over.
|
|
136
|
+
*
|
|
137
|
+
* The connected-client machine listener answers with the same `service: "opencodex"` marker
|
|
138
|
+
* and an extra `role: "client"`, and it is accepted here on purpose. Liveness answers "is one
|
|
139
|
+
* of our processes listening on this port", which is exactly what `ocx stop`, orphan cleanup,
|
|
140
|
+
* and duplicate-start avoidance need: rejecting the client role would make those paths blind to
|
|
141
|
+
* a real opencodex process and let them shadow-start over it. Callers that additionally need a
|
|
142
|
+
* management plane discriminate on `LiveProxy.role` instead of narrowing this predicate.
|
|
104
143
|
*/
|
|
105
144
|
export function isOpencodexHealthz(body: HealthzIdentity | null): boolean {
|
|
106
145
|
if (!body) return false;
|
|
@@ -114,7 +153,7 @@ export async function proxyIdentityAt(
|
|
|
114
153
|
port: number,
|
|
115
154
|
opts: { hostname?: string; expectedPid?: number } = {},
|
|
116
155
|
io: LivenessIo = {},
|
|
117
|
-
): Promise<{ pid: number | null; version?: string } | null> {
|
|
156
|
+
): Promise<{ pid: number | null; version?: string; role?: string } | null> {
|
|
118
157
|
const fetchFn = io.fetchFn ?? directLocalHttpFetch;
|
|
119
158
|
const sleepFn = io.sleepFn ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
|
|
120
159
|
const nowFn = io.nowFn ?? Date.now;
|
|
@@ -139,7 +178,14 @@ export async function proxyIdentityAt(
|
|
|
139
178
|
if (opts.expectedPid !== undefined && pid !== null && pid !== opts.expectedPid) return null;
|
|
140
179
|
// Guarded the same way `pid` is: a non-string version is absent, not coerced.
|
|
141
180
|
const version = typeof body?.version === "string" ? body.version : undefined;
|
|
142
|
-
|
|
181
|
+
// Same guard for the role, for the same reason: absent on a standalone/hub proxy and on
|
|
182
|
+
// a legacy body, and never coerced from a non-string.
|
|
183
|
+
const role = typeof body?.role === "string" ? body.role : undefined;
|
|
184
|
+
return {
|
|
185
|
+
pid,
|
|
186
|
+
...(version === undefined ? {} : { version }),
|
|
187
|
+
...(role === undefined ? {} : { role }),
|
|
188
|
+
};
|
|
143
189
|
} catch {
|
|
144
190
|
// Transport failure (timeout / refused) — retry while budget remains; a proxy that
|
|
145
191
|
// has only just begun listening can miss a single short probe (#764).
|
|
@@ -203,6 +249,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
|
|
|
203
249
|
hostname: runtime.hostname,
|
|
204
250
|
source: "runtime",
|
|
205
251
|
...(identity.version === undefined ? {} : { version: identity.version }),
|
|
252
|
+
...(identity.role === undefined ? {} : { role: identity.role }),
|
|
206
253
|
};
|
|
207
254
|
}
|
|
208
255
|
}
|
|
@@ -226,6 +273,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
|
|
|
226
273
|
hostname: record.hostname,
|
|
227
274
|
source: "runtime",
|
|
228
275
|
...(identity.version === undefined ? {} : { version: identity.version }),
|
|
276
|
+
...(identity.role === undefined ? {} : { role: identity.role }),
|
|
229
277
|
};
|
|
230
278
|
}
|
|
231
279
|
}
|
|
@@ -241,11 +289,58 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
|
|
|
241
289
|
hostname: config.hostname,
|
|
242
290
|
source: "config",
|
|
243
291
|
...(identity.version === undefined ? {} : { version: identity.version }),
|
|
292
|
+
...(identity.role === undefined ? {} : { role: identity.role }),
|
|
244
293
|
};
|
|
245
294
|
}
|
|
246
295
|
return null;
|
|
247
296
|
}
|
|
248
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Loopback addresses to ask about a port whose holder must be identified before a
|
|
300
|
+
* decision that is destructive when it answers "nobody".
|
|
301
|
+
*
|
|
302
|
+
* A listener and a probe can disagree about what "loopback" means, and on Windows they
|
|
303
|
+
* do. `startServer` canonicalizes a `localhost` bind to 127.0.0.1 precisely because
|
|
304
|
+
* Windows resolves the name IPv6-first (src/server/index.ts), while `probeHostname`
|
|
305
|
+
* hands the literal name back and leaves the family choice to the resolver. A probe that
|
|
306
|
+
* lands on `::1` while the listener holds `127.0.0.1` reports an empty port that another
|
|
307
|
+
* process is demonstrably serving. Both literal addresses are therefore asked, cheapest
|
|
308
|
+
* question first: the extra one costs a refused connection, and skipping it costs a
|
|
309
|
+
* duplicate proxy.
|
|
310
|
+
*
|
|
311
|
+
* A non-loopback bind (a LAN address, a named host) gets exactly one candidate — the
|
|
312
|
+
* address it was configured with. Guessing another interface for it would answer a
|
|
313
|
+
* different question than the caller asked.
|
|
314
|
+
*/
|
|
315
|
+
export function loopbackProbeHosts(hostname: string | undefined): string[] {
|
|
316
|
+
const primary = probeHostname(hostname);
|
|
317
|
+
if (primary === "127.0.0.1" || /^localhost$/i.test(primary)) return ["127.0.0.1", "[::1]"];
|
|
318
|
+
if (primary === "[::1]") return ["[::1]", "127.0.0.1"];
|
|
319
|
+
return [primary];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Identity-checked answer to "who holds this exact port", independent of the pid file
|
|
324
|
+
* and the runtime-port record.
|
|
325
|
+
*
|
|
326
|
+
* `findLiveProxy` answers "is a proxy of this home alive", and it can only do that from
|
|
327
|
+
* recorded state plus the configured port. This answers the narrower question a start
|
|
328
|
+
* has to ask before it walks away from a busy port: an opencodex listening THERE, right
|
|
329
|
+
* now, whatever this home's records say about it. Returns null only after every
|
|
330
|
+
* candidate address has failed the identity check with the caller's full probe budget.
|
|
331
|
+
*/
|
|
332
|
+
export async function probePortOwner(
|
|
333
|
+
port: number,
|
|
334
|
+
opts: { hostname?: string } = {},
|
|
335
|
+
io: LivenessIo = {},
|
|
336
|
+
): Promise<{ pid: number | null; hostname: string; version?: string; role?: string } | null> {
|
|
337
|
+
for (const hostname of loopbackProbeHosts(opts.hostname)) {
|
|
338
|
+
const identity = await proxyIdentityAt(port, { hostname }, io);
|
|
339
|
+
if (identity) return { ...identity, hostname };
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
249
344
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
250
345
|
// Readiness (/readyz) strict probe.
|
|
251
346
|
//
|
package/src/server/readiness.ts
CHANGED
|
@@ -3,10 +3,30 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `GET /healthz` answers "is the process alive and serving HTTP?" the instant the
|
|
5
5
|
* listener binds. Readiness is stricter: the proxy is "ready" only after the
|
|
6
|
-
* post-startup Codex catalog/config sync (`syncModelsToCodex`) has
|
|
7
|
-
* `ok=true
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* post-startup Codex catalog/config sync (`syncModelsToCodex`) has SETTLED and
|
|
7
|
+
* reported `ok=true`. Until it settles the process is live (Codex can open a
|
|
8
|
+
* socket) but not ready — a request would race the sync — so clients back off.
|
|
9
|
+
*
|
|
10
|
+
* What readiness is NOT (#5181): it is not a verdict on the local Codex client's
|
|
11
|
+
* artifacts. A nonempty `warning` used to be terminal here, which made a
|
|
12
|
+
* degradation of files written into the local Codex home permanently un-ready a
|
|
13
|
+
* proxy that was serving every other provider correctly. In a single-replica
|
|
14
|
+
* Kubernetes deployment that removed the only Service endpoint.
|
|
15
|
+
*
|
|
16
|
+
* `ok` and `warning` are separate fields in the sync result because they answer
|
|
17
|
+
* separate questions, and the gate must not conflate them. `ok` is the sync's own
|
|
18
|
+
* verdict on whether the essential work — config injection, and the write
|
|
19
|
+
* admission that precedes it — succeeded. `warning` names a degradation the sync
|
|
20
|
+
* itself decided to continue past: no catalog source so Codex keeps its native
|
|
21
|
+
* catalog, combos omitted from the catalog, a conversation-history relabel left
|
|
22
|
+
* to Codex's own writer, or a caught catalog-refresh exception after which
|
|
23
|
+
* injection still runs and still reports its own `ok`. None of those stops this
|
|
24
|
+
* process from accepting HTTP or routing to a provider, so none of them may close
|
|
25
|
+
* the gate.
|
|
26
|
+
*
|
|
27
|
+
* That boundary is not new, only extended: the Claude Code roster reconciliation
|
|
28
|
+
* in `src/cli/claude-agent-startup-sync.ts` already delays the ready transition
|
|
29
|
+
* without being allowed to fail it, on the same reasoning.
|
|
10
30
|
*
|
|
11
31
|
* Design contract (per P1 review):
|
|
12
32
|
* - NO module-global mutable state. Each `startServer` invocation gets its own
|
|
@@ -65,8 +85,11 @@ export interface SyncOutcomeLike {
|
|
|
65
85
|
|
|
66
86
|
/**
|
|
67
87
|
* Drive the gate from the post-startup sync. Awaits `syncFn`; the gate goes to
|
|
68
|
-
* `ready`
|
|
69
|
-
*
|
|
88
|
+
* `ready` on `ok=true`. A throw, `null`, or `ok !== true` transitions to
|
|
89
|
+
* `failed`; a nonempty `warning` does not, because the sync that produced it
|
|
90
|
+
* still reported the essential work done (see the file header, #5181). A throw
|
|
91
|
+
* and `null` stay terminal because neither is a classified outcome: the startup
|
|
92
|
+
* path did not reach a verdict, so the gate cannot claim one. Used by `handleStart`
|
|
70
93
|
* so the startup transition is unit-testable without spawning the proxy. Returns
|
|
71
94
|
* the raw sync outcome so a caller that also needs the #1046 write flags (did the
|
|
72
95
|
* sync actually write the catalog/cache?) can keep them without a second call.
|
|
@@ -90,10 +113,6 @@ export async function runStartupReadinessSync(
|
|
|
90
113
|
gate.markFailed();
|
|
91
114
|
return result;
|
|
92
115
|
}
|
|
93
|
-
if (result.warning !== undefined && result.warning !== "") {
|
|
94
|
-
gate.markFailed();
|
|
95
|
-
return result;
|
|
96
|
-
}
|
|
97
116
|
gate.markReady();
|
|
98
117
|
return result;
|
|
99
118
|
}
|