@bitkyc08/opencodex 2.55.0-preview.20260914 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{index-DH2PUHqr.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +611 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +554 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +422 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +95 -28
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +335 -0
- package/src/server/index.ts +40 -2547
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isValidProviderName } from "../provider-name";
|
|
4
|
+
import {
|
|
5
|
+
modelPinnedEffortsConfigError,
|
|
6
|
+
pinnedReasoningEffortConfigError,
|
|
7
|
+
modelDisplayNamesConfigError,
|
|
8
|
+
autoReviewModelOverridesConfigError,
|
|
9
|
+
autoReviewModelTargetConfigError,
|
|
10
|
+
normalizeNonBlankStringArray,
|
|
11
|
+
normalizeAutoReviewModelOverrides,
|
|
12
|
+
modelCapabilitiesConfigError,
|
|
13
|
+
mergeModelCapabilities,
|
|
14
|
+
} from "../provider-validation";
|
|
15
|
+
import { isValidCodexAccountNamespaceTarget } from "../../codex/account-namespace-match";
|
|
16
|
+
import { isCodexAccountPriorityKey } from "../../codex/account-priority";
|
|
17
|
+
import { parseAccountPriority } from "../../codex/pool-rotation";
|
|
18
|
+
import { credentialGroupIssues } from "../../routing/identity-domains";
|
|
19
|
+
import { providerDestinationConfigError } from "../../lib/destination-policy";
|
|
20
|
+
import { redactSecretString } from "../../lib/redact";
|
|
21
|
+
import {
|
|
22
|
+
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
23
|
+
pinnedWireAdapter,
|
|
24
|
+
PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS,
|
|
25
|
+
UPSTREAM_HTTP_VERSION_VALUES,
|
|
26
|
+
type OcxProviderConfig,
|
|
27
|
+
type FastWire,
|
|
28
|
+
type ProviderCostOverlay,
|
|
29
|
+
} from "../../types";
|
|
30
|
+
import { fastWireDeclarationError } from "../../providers/fastwire";
|
|
31
|
+
import { getProviderRegistryEntry, providerMatchesRegistryTransport, providerModelWireDefault } from "../../providers/registry";
|
|
32
|
+
import { resolveOpenAiVirtualModel } from "../../providers/openai-virtual-models";
|
|
33
|
+
import { COST4_RATE_KEYS, isValidCost4Rate } from "../../usage/user-cost-overlays";
|
|
34
|
+
import { MAX_COST4_RATE } from "../../usage/expected-prices";
|
|
35
|
+
import { isHostedToolUnsupportedForModel } from "../../responses/hosted-tool-policy";
|
|
36
|
+
import { getConfigDir } from "../paths";
|
|
37
|
+
|
|
38
|
+
/** One definition of "usable secret", shared by the schema and the warnings. */
|
|
39
|
+
export function isUsableApiKeySecret(value: unknown): value is string {
|
|
40
|
+
return typeof value === "string" && value.length > 0 && value === value.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Bounds for the opt-in same-target 429 wait-and-retry policy. Single source of truth
|
|
45
|
+
* shared by the config schema, the load-time sanitizer, and the management write
|
|
46
|
+
* boundary. Strict, so an unknown key is rejected at every validation boundary instead
|
|
47
|
+
* of being silently ignored (the load-time sanitizer still degrades unknown keys with a
|
|
48
|
+
* warning before schema validation, so hand-edited configs keep loading).
|
|
49
|
+
*/
|
|
50
|
+
export const retryOn429PolicySchema = z.object({
|
|
51
|
+
enabled: z.boolean().optional(),
|
|
52
|
+
attempts: z.number().int().min(1).max(20).optional(),
|
|
53
|
+
intervalMs: z.number().int().min(100).max(600_000).optional(),
|
|
54
|
+
// The effective cap for a single wait is MAX_COOLDOWN_MS (10 min) in key-failover.ts;
|
|
55
|
+
// larger configured values would be dead config.
|
|
56
|
+
maxIntervalMs: z.number().int().min(100).max(600_000).optional(),
|
|
57
|
+
respectRetryAfter: z.boolean().optional(),
|
|
58
|
+
}).strict();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `transientRetryOn5xx` accepts only these keys. `attempts` is a TOTAL send budget shared by
|
|
62
|
+
* both retry layers, so the ceiling is deliberately lower than `retryOn429`'s: 10 total sends
|
|
63
|
+
* against an already-failing provider is already generous.
|
|
64
|
+
*/
|
|
65
|
+
const transientRetryOn5xxPolicySchema = z.object({
|
|
66
|
+
enabled: z.boolean().optional(),
|
|
67
|
+
attempts: z.number().int().min(1).max(10).optional(),
|
|
68
|
+
}).strict();
|
|
69
|
+
|
|
70
|
+
const requestPacingRuleSchema = z.object({
|
|
71
|
+
// Keep the RPM-derived timer within the same one-hour bound as minIntervalMs.
|
|
72
|
+
requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
|
|
73
|
+
minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
|
|
74
|
+
}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, {
|
|
75
|
+
message: "request pacing rules need requestsPerMinute or minIntervalMs",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const requestPacingSchema = z.object({
|
|
79
|
+
enabled: z.boolean(),
|
|
80
|
+
requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
|
|
81
|
+
minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
|
|
82
|
+
models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(),
|
|
83
|
+
}).strict().refine(value => value.enabled === false
|
|
84
|
+
|| value.requestsPerMinute !== undefined
|
|
85
|
+
|| value.minIntervalMs !== undefined
|
|
86
|
+
|| (value.models !== undefined && Object.keys(value.models).length > 0), {
|
|
87
|
+
message: "enabled request pacing needs a provider rule or model override",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export function requestPacingConfigError(value: unknown): string | null {
|
|
91
|
+
if (value === undefined) return null;
|
|
92
|
+
const parsed = requestPacingSchema.safeParse(value);
|
|
93
|
+
if (parsed.success) return null;
|
|
94
|
+
return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Bounds for the opt-in passthrough web-search bridge (`providers.<name>.webSearchBridge`,
|
|
99
|
+
* #3761). Strict for the same reason `retryOn429` is: a misspelled key here would silently
|
|
100
|
+
* leave the bridge disarmed while the operator believes they enabled it.
|
|
101
|
+
*
|
|
102
|
+
* `endpoint` names the destination that receives this provider's API key, so it gets the same
|
|
103
|
+
* literal destination assessment `baseUrl` gets (#4519) — see `providerWebSearchBridgeConfigError`
|
|
104
|
+
* below. This schema itself still only shape-checks: it is `.catch(undefined)` at the provider
|
|
105
|
+
* row, and a hand-edited config file never reaches the error function at all. The authorization
|
|
106
|
+
* boundary is therefore `resolveOllamaWebSearchEndpoint`, which runs the same assessment and is
|
|
107
|
+
* the only reader of this field in the tree; config validation is where an operator is told why,
|
|
108
|
+
* not what makes the value safe.
|
|
109
|
+
*/
|
|
110
|
+
const providerWebSearchBridgeSchema = z.object({
|
|
111
|
+
enabled: z.boolean().optional(),
|
|
112
|
+
backend: z.enum(PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS).optional(),
|
|
113
|
+
maxSearches: z.number().int().min(1).max(10).optional(),
|
|
114
|
+
timeoutMs: z.number().int().min(1_000).max(600_000).optional(),
|
|
115
|
+
endpoint: z.string().min(1).optional(),
|
|
116
|
+
}).strict();
|
|
117
|
+
|
|
118
|
+
export function providerWebSearchBridgeConfigError(
|
|
119
|
+
value: unknown,
|
|
120
|
+
providerName: string,
|
|
121
|
+
provider: Pick<OcxProviderConfig, "allowPrivateNetwork">,
|
|
122
|
+
): string | null {
|
|
123
|
+
if (value === undefined) return null;
|
|
124
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
125
|
+
return "webSearchBridge must be a plain object";
|
|
126
|
+
}
|
|
127
|
+
const parsed = providerWebSearchBridgeSchema.safeParse(value);
|
|
128
|
+
if (!parsed.success) {
|
|
129
|
+
return "webSearchBridge accepts only enabled (boolean), backend "
|
|
130
|
+
+ `(${PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS.join("|")}), maxSearches (1..10), `
|
|
131
|
+
+ "timeoutMs (1000..600000), and endpoint (absolute http(s) URL)";
|
|
132
|
+
}
|
|
133
|
+
const endpoint = parsed.data.endpoint;
|
|
134
|
+
if (endpoint !== undefined) {
|
|
135
|
+
let url: URL;
|
|
136
|
+
try {
|
|
137
|
+
url = new URL(endpoint);
|
|
138
|
+
} catch {
|
|
139
|
+
return "webSearchBridge.endpoint must be an absolute http(s) URL";
|
|
140
|
+
}
|
|
141
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
142
|
+
return "webSearchBridge.endpoint must be an absolute http(s) URL";
|
|
143
|
+
}
|
|
144
|
+
// Same classifier baseUrl uses, so a metadata address is refused outright and loopback or
|
|
145
|
+
// private space needs the provider's allowPrivateNetwork opt-in (or a registry entry that is
|
|
146
|
+
// local by definition, which is what keeps a self-hosted Ollama working). Literal-only and
|
|
147
|
+
// synchronous, exactly as at the baseUrl boundary: no DNS is resolved here.
|
|
148
|
+
const destinationError = providerDestinationConfigError(providerName, {
|
|
149
|
+
baseUrl: endpoint,
|
|
150
|
+
allowPrivateNetwork: provider.allowPrivateNetwork,
|
|
151
|
+
});
|
|
152
|
+
if (destinationError) {
|
|
153
|
+
return destinationError.replace(/^baseUrl/, "webSearchBridge.endpoint");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const fastWireSchema = z.object({
|
|
160
|
+
kind: z.string(),
|
|
161
|
+
canonicalToWire: z.record(z.string().trim(), z.string().trim()),
|
|
162
|
+
foreignCallerTiers: z.string(),
|
|
163
|
+
betas: z.array(z.string().trim()).optional(),
|
|
164
|
+
}).strict().superRefine((fastWire, ctx) => {
|
|
165
|
+
const error = fastWireDeclarationError({ fastWire });
|
|
166
|
+
if (error) ctx.addIssue({ code: "custom", message: error });
|
|
167
|
+
}).transform(fastWire => fastWire as FastWire);
|
|
168
|
+
|
|
169
|
+
const modelDisplayNamesSchema = z.unknown().superRefine((value, ctx) => {
|
|
170
|
+
const error = modelDisplayNamesConfigError(value);
|
|
171
|
+
if (error) ctx.addIssue({ code: "custom", message: error });
|
|
172
|
+
}).transform(value => {
|
|
173
|
+
const labels = Object.create(null) as Record<string, string>;
|
|
174
|
+
for (const [modelId, displayName] of Object.entries(value as Record<string, string>)) {
|
|
175
|
+
labels[modelId] = displayName;
|
|
176
|
+
}
|
|
177
|
+
return labels;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const pinnedReasoningEffortSchema = z.unknown().superRefine((value, ctx) => {
|
|
181
|
+
const error = pinnedReasoningEffortConfigError(value);
|
|
182
|
+
if (error) ctx.addIssue({ code: "custom", message: error });
|
|
183
|
+
}).transform(value => value as string);
|
|
184
|
+
|
|
185
|
+
export const modelPinnedEffortsSchema = z.unknown().superRefine((value, ctx) => {
|
|
186
|
+
const error = modelPinnedEffortsConfigError(value);
|
|
187
|
+
if (error) ctx.addIssue({ code: "custom", message: error });
|
|
188
|
+
}).transform(value => Object.fromEntries(
|
|
189
|
+
Object.entries(value as Record<string, string>).map(([key, effort]) => [key.trim(), effort]),
|
|
190
|
+
));
|
|
191
|
+
|
|
192
|
+
const autoReviewModelSchema = z.unknown().superRefine((value, ctx) => {
|
|
193
|
+
const error = autoReviewModelTargetConfigError(value, "autoReviewModel", true);
|
|
194
|
+
if (error) ctx.addIssue({ code: "custom", message: error });
|
|
195
|
+
}).transform(value => {
|
|
196
|
+
if (typeof value !== "string") return undefined;
|
|
197
|
+
const trimmed = value.trim();
|
|
198
|
+
return trimmed ? trimmed : undefined;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const autoReviewModelOverridesSchema = z.unknown().superRefine((value, ctx) => {
|
|
202
|
+
const error = autoReviewModelOverridesConfigError(value, "autoReviewModelOverrides", true);
|
|
203
|
+
if (error) ctx.addIssue({ code: "custom", message: error });
|
|
204
|
+
}).transform(value => normalizeAutoReviewModelOverrides(value));
|
|
205
|
+
|
|
206
|
+
const modelCapabilitiesSchema = z.unknown().superRefine((value, ctx) => {
|
|
207
|
+
const error = modelCapabilitiesConfigError(value);
|
|
208
|
+
if (error) ctx.addIssue({ code: "custom", message: error });
|
|
209
|
+
}).transform(value => mergeModelCapabilities(undefined, value));
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Zod schema for one provider entry: known fields are validated strictly while unknown
|
|
213
|
+
* fields pass through (preserved for runtime extensions).
|
|
214
|
+
*/
|
|
215
|
+
export const providerConfigSchema = z.object({
|
|
216
|
+
modelCapabilities: modelCapabilitiesSchema.optional(),
|
|
217
|
+
pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(),
|
|
218
|
+
modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(),
|
|
219
|
+
// Validated rather than left to passthrough: an unrecognized strategy would otherwise
|
|
220
|
+
// load silently and then be ignored at selection time, which reads as a broken feature
|
|
221
|
+
// rather than a rejected setting.
|
|
222
|
+
apiKeyPoolStrategy: z.enum(["round-robin", "fill-first", "quota"]).optional(),
|
|
223
|
+
autoReviewModel: autoReviewModelSchema.optional(),
|
|
224
|
+
autoReviewModelOverrides: autoReviewModelOverridesSchema.optional(),
|
|
225
|
+
adapter: z.string().min(1),
|
|
226
|
+
baseUrl: z.string().min(1),
|
|
227
|
+
alias: z.string().optional(),
|
|
228
|
+
modelAliases: z.record(z.string(), z.string()).optional(),
|
|
229
|
+
modelDisplayNames: modelDisplayNamesSchema.optional(),
|
|
230
|
+
defaultAliases: z.boolean().optional(),
|
|
231
|
+
initialModelSelection: z.object({
|
|
232
|
+
version: z.literal(1),
|
|
233
|
+
registrationId: z.uuid(),
|
|
234
|
+
status: z.enum(["pending", "ready", "all-off"]),
|
|
235
|
+
modelCount: z.number().int().nonnegative().optional(),
|
|
236
|
+
}).optional().catch(undefined),
|
|
237
|
+
requestPacing: requestPacingSchema.optional().catch(undefined),
|
|
238
|
+
mcpMaxTools: z.number().int().positive().optional(),
|
|
239
|
+
mcpMaxSchemaBytes: z.number().int().positive().optional(),
|
|
240
|
+
mcpMaxResultBytes: z.number().int().positive().optional(),
|
|
241
|
+
apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
|
|
242
|
+
responsesPath: z.string().min(1).optional(),
|
|
243
|
+
chatCompletionsPath: z.string().min(1).optional(),
|
|
244
|
+
statelessResponses: z.boolean().optional(),
|
|
245
|
+
requiresAdjacentResponsesToolResults: z.boolean().optional(),
|
|
246
|
+
annotateEmptyToolOutputs: z.boolean().optional(),
|
|
247
|
+
fastWire: fastWireSchema.nullable().optional(),
|
|
248
|
+
supportsServiceTier: z.boolean().optional(),
|
|
249
|
+
modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
|
|
250
|
+
preserveResponsesReasoningContent: z.boolean().optional(),
|
|
251
|
+
decodesNativeCompactionBlobs: z.boolean().optional(),
|
|
252
|
+
allowEncryptedV2AgentTasks: z.boolean().optional(),
|
|
253
|
+
allowPrivateNetwork: z.boolean().optional(),
|
|
254
|
+
// The management API accepts `null` as "clear this", so a config written before the POST
|
|
255
|
+
// canonicalization below can hold one on disk. Rejecting it here would send the operator
|
|
256
|
+
// through invalid-config recovery for a value the API told them was fine.
|
|
257
|
+
upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES)
|
|
258
|
+
.nullish()
|
|
259
|
+
.transform(value => value ?? undefined),
|
|
260
|
+
// Opt-in upstream Responses WebSocket for OpenAI-compatible providers (e.g.
|
|
261
|
+
// aggregators whose WebSocket ingress is measurably faster than SSE). The
|
|
262
|
+
// canonical ChatGPT backend WS selection is independent of this flag.
|
|
263
|
+
upstreamWebsocket: z.boolean().optional(),
|
|
264
|
+
directGeminiWireRenames: z.boolean().optional(),
|
|
265
|
+
noStructuredOutputModels: z.array(z.string().min(1))
|
|
266
|
+
.transform(normalizeNonBlankStringArray)
|
|
267
|
+
.optional(),
|
|
268
|
+
noJsonSchemaModels: z.array(z.string().min(1))
|
|
269
|
+
.transform(normalizeNonBlankStringArray)
|
|
270
|
+
.optional(),
|
|
271
|
+
retainModels: z.array(z.string().min(1))
|
|
272
|
+
.transform(normalizeNonBlankStringArray)
|
|
273
|
+
.optional(),
|
|
274
|
+
omitReasoningEffortWithToolsModels: z.array(z.string().min(1))
|
|
275
|
+
.transform(normalizeNonBlankStringArray)
|
|
276
|
+
.optional(),
|
|
277
|
+
retryOn429: retryOn429PolicySchema.optional(),
|
|
278
|
+
transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(),
|
|
279
|
+
codexAccountMode: z.enum(["pool", "direct"]).optional(),
|
|
280
|
+
// Validated rather than passed through: this schema ends in `.passthrough()`, so an
|
|
281
|
+
// undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be
|
|
282
|
+
// accepted, persisted, and then silently resolved to the `code_mode_only` default — the
|
|
283
|
+
// operator asked for shell mode, got code mode, and was told nothing (#2106).
|
|
284
|
+
codexToolMode: z.enum(["code_mode_only", "shell"]).optional(),
|
|
285
|
+
responsesItemIdRepair: z.object({
|
|
286
|
+
message: z.array(z.string().min(1)).optional(),
|
|
287
|
+
reasoning: z.array(z.string().min(1)).optional(),
|
|
288
|
+
repairMissingTerminalIds: z.boolean().optional(),
|
|
289
|
+
repairInvalidIds: z.boolean().optional(),
|
|
290
|
+
}).strict().optional(),
|
|
291
|
+
responsesSnapshotRepair: z.boolean().optional(),
|
|
292
|
+
// Invalid blocks degrade to "absent" rather than failing the whole config load: an unusable
|
|
293
|
+
// bridge block must never send an operator through invalid-config recovery for an opt-in
|
|
294
|
+
// feature that is off by default. The management write boundary still rejects it loudly.
|
|
295
|
+
webSearchBridge: providerWebSearchBridgeSchema.optional().catch(undefined),
|
|
296
|
+
xaiResponsesXSearch: z.boolean().optional(),
|
|
297
|
+
xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
|
|
298
|
+
zaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
|
|
299
|
+
}).passthrough();
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Shared shape check for the two relative send-path overrides. `field` names the
|
|
304
|
+
* offending key so the message stays specific to what the user actually wrote.
|
|
305
|
+
*/
|
|
306
|
+
export function providerRelativeSendPathConfigError(field: string, value: string | undefined): string | null {
|
|
307
|
+
if (value === undefined) return null;
|
|
308
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || value.includes("://")) {
|
|
309
|
+
return `${field} must be a relative path without a URL scheme`;
|
|
310
|
+
}
|
|
311
|
+
if (!value.startsWith("/")) return `${field} must start with /`;
|
|
312
|
+
if (value.includes("?") || value.includes("#")) {
|
|
313
|
+
return `${field} must not include query strings or fragments`;
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Validate `providers.<name>.modelCosts`: a plain object keyed by exact model
|
|
320
|
+
* id, each value a 4-tuple of non-negative finite USD-per-1M-token rates.
|
|
321
|
+
* Returns null when valid/absent, else a human-readable error.
|
|
322
|
+
*/
|
|
323
|
+
export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null {
|
|
324
|
+
if (value === undefined) return null;
|
|
325
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
326
|
+
return `${field} must be a plain object keyed by model id`;
|
|
327
|
+
}
|
|
328
|
+
for (const [modelId, entry] of Object.entries(value)) {
|
|
329
|
+
if (!modelId.trim()) return `${field} keys must be nonblank model ids`;
|
|
330
|
+
// Redact secret-shaped model ids and JSON-escape control characters so a
|
|
331
|
+
// malformed write cannot echo a pasted key/secret back through the
|
|
332
|
+
// management API response.
|
|
333
|
+
const safeModelId = JSON.stringify(redactSecretString(modelId));
|
|
334
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
335
|
+
return `${field}.${safeModelId} must be an object with input, output, cacheRead, and cacheWrite (USD per 1M tokens)`;
|
|
336
|
+
}
|
|
337
|
+
const rates = entry as Record<string, unknown>;
|
|
338
|
+
for (const key of COST4_RATE_KEYS) {
|
|
339
|
+
const rate = rates[key];
|
|
340
|
+
if (!isValidCost4Rate(rate)) {
|
|
341
|
+
return `${field}.${safeModelId}.${key} must be a non-negative finite number at most ${MAX_COST4_RATE} (USD per 1M tokens)`;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// Reject unknown fields: a misplaced apiKey/apiKeyPool under a cost row
|
|
345
|
+
// would otherwise be persisted and echoed verbatim by display paths that
|
|
346
|
+
// mask only top-level provider secrets.
|
|
347
|
+
const extraKeys = Object.keys(rates)
|
|
348
|
+
.filter((key) => !(COST4_RATE_KEYS as readonly string[]).includes(key));
|
|
349
|
+
if (extraKeys.length > 0) {
|
|
350
|
+
return `${field}.${safeModelId} has unexpected fields ${JSON.stringify(extraKeys.map(redactSecretString).join(", "))} — only input, output, cacheRead, and cacheWrite are allowed (USD per 1M tokens)`;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Serialize `providers.<name>.modelCosts` for display: copy ONLY the four
|
|
358
|
+
* numeric rate fields per model and DROP secret-shaped model ids, so a pasted
|
|
359
|
+
* API key in a key position cannot be echoed back by CLI/DTO display paths.
|
|
360
|
+
* The result uses a null prototype so "__proto__" remains an own row.
|
|
361
|
+
*/
|
|
362
|
+
export function sanitizeModelCostsForDisplay(costs: unknown): Record<string, ProviderCostOverlay> | undefined {
|
|
363
|
+
if (!costs || typeof costs !== "object" || Array.isArray(costs)) return undefined;
|
|
364
|
+
const out = Object.create(null) as Record<string, ProviderCostOverlay>;
|
|
365
|
+
for (const [modelId, entry] of Object.entries(costs)) {
|
|
366
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
367
|
+
const rates = entry as Record<string, unknown>;
|
|
368
|
+
const input = rates.input;
|
|
369
|
+
const output = rates.output;
|
|
370
|
+
const cacheRead = rates.cacheRead;
|
|
371
|
+
const cacheWrite = rates.cacheWrite;
|
|
372
|
+
if (
|
|
373
|
+
isValidCost4Rate(input)
|
|
374
|
+
&& isValidCost4Rate(output)
|
|
375
|
+
&& isValidCost4Rate(cacheRead)
|
|
376
|
+
&& isValidCost4Rate(cacheWrite)
|
|
377
|
+
) {
|
|
378
|
+
// Secret-shaped ids are DROPPED rather than mapped to "[REDACTED]" so
|
|
379
|
+
// distinct rows cannot collapse into one placeholder key.
|
|
380
|
+
if (redactSecretString(modelId) !== modelId) continue;
|
|
381
|
+
out[modelId] = { input, output, cacheRead, cacheWrite };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]);
|
|
388
|
+
|
|
389
|
+
export function modelPreferHostedToolsConfigError(
|
|
390
|
+
value: unknown,
|
|
391
|
+
field: string,
|
|
392
|
+
providerName: string,
|
|
393
|
+
provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown; baseUrl?: unknown },
|
|
394
|
+
): string | null {
|
|
395
|
+
if (value === undefined) return null;
|
|
396
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
397
|
+
const prototype = Object.getPrototypeOf(value);
|
|
398
|
+
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
399
|
+
const entries = Object.entries(value);
|
|
400
|
+
const registry = getProviderRegistryEntry(providerName);
|
|
401
|
+
// Effective transport: a `preserveCustomDestination` registry row reused under a
|
|
402
|
+
// different endpoint keeps its own adapter AND its own auth at runtime, because
|
|
403
|
+
// `routedProviderConfig()` honors `providerMatchesRegistryTransport()`. Both the
|
|
404
|
+
// wire check below and the forward-auth check here have to start from the same
|
|
405
|
+
// decision, or validation accepts a preference the adapter never applies —
|
|
406
|
+
// `preferConfiguredHostedTools()` runs only on the non-forward branch.
|
|
407
|
+
const registryTransportMatches = typeof provider.baseUrl === "string"
|
|
408
|
+
&& providerMatchesRegistryTransport(providerName, {
|
|
409
|
+
baseUrl: provider.baseUrl,
|
|
410
|
+
adapter: provider.adapter as OcxProviderConfig["adapter"],
|
|
411
|
+
...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
|
|
412
|
+
});
|
|
413
|
+
const effectiveForwardAuth = registryTransportMatches
|
|
414
|
+
? registry?.authKind === "forward"
|
|
415
|
+
: provider.authMode === "forward";
|
|
416
|
+
if (entries.length > 0 && effectiveForwardAuth) {
|
|
417
|
+
return `${field} is not supported on forward-auth Responses providers`;
|
|
418
|
+
}
|
|
419
|
+
const requestedWireFor = (modelId: string): unknown => provider.modelAdapters
|
|
420
|
+
&& typeof provider.modelAdapters === "object"
|
|
421
|
+
&& !Array.isArray(provider.modelAdapters)
|
|
422
|
+
? (provider.modelAdapters as Record<string, unknown>)[modelId]
|
|
423
|
+
: undefined;
|
|
424
|
+
const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => {
|
|
425
|
+
const pinned = pinnedWireAdapter(providerName, modelId);
|
|
426
|
+
if (pinned) return pinned;
|
|
427
|
+
const requestedWire = requestedWireFor(modelId);
|
|
428
|
+
if (typeof requestedWire === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requestedWire)) {
|
|
429
|
+
return requestedWire;
|
|
430
|
+
}
|
|
431
|
+
// No explicit override: fall back to the registry's per-model wire default before
|
|
432
|
+
// the provider-wide adapter, because that is the order `resolveModelAdapter()`
|
|
433
|
+
// uses at request time (src/server/adapter-resolve.ts:38-48). Skipping it rejected
|
|
434
|
+
// preferences the runtime would have honored — DeepSeek routes `deepseek-v4-flash`
|
|
435
|
+
// over native Responses for a Responses inbound while the provider-wide wire stays
|
|
436
|
+
// openai-chat. Hosted-tool preferences only apply to Responses traffic, so the
|
|
437
|
+
// inbound to ask about is "responses".
|
|
438
|
+
const registryDefault = typeof currentWire === "string" && typeof provider.baseUrl === "string"
|
|
439
|
+
? providerModelWireDefault(
|
|
440
|
+
providerName,
|
|
441
|
+
{
|
|
442
|
+
baseUrl: provider.baseUrl,
|
|
443
|
+
adapter: currentWire,
|
|
444
|
+
...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
|
|
445
|
+
},
|
|
446
|
+
modelId,
|
|
447
|
+
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
448
|
+
"responses",
|
|
449
|
+
)
|
|
450
|
+
: undefined;
|
|
451
|
+
return registryDefault ?? currentWire;
|
|
452
|
+
};
|
|
453
|
+
for (const [key, entry] of entries) {
|
|
454
|
+
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
455
|
+
if (!Array.isArray(entry)) return `${field}.${key} must be an array`;
|
|
456
|
+
if (entry.length === 0) return `${field}.${key} must include image_generation`;
|
|
457
|
+
for (const tool of entry) {
|
|
458
|
+
if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) {
|
|
459
|
+
return `${field}.${key} supports only image_generation`;
|
|
460
|
+
}
|
|
461
|
+
if (isHostedToolUnsupportedForModel(key, tool)) {
|
|
462
|
+
return `${field}.${key} cannot prefer ${tool}: the model does not support it`;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Same `registryTransportMatches` decision the forward-auth check above uses:
|
|
466
|
+
// start from the registry adapter only when this config still points at the
|
|
467
|
+
// registry's documented transport.
|
|
468
|
+
const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter;
|
|
469
|
+
let effectiveWire = resolveEffectiveWire(key, baseWire);
|
|
470
|
+
const virtualWireModel = resolveOpenAiVirtualModel(providerName, key)?.wireModelId;
|
|
471
|
+
if (virtualWireModel && virtualWireModel !== key) {
|
|
472
|
+
effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire);
|
|
473
|
+
}
|
|
474
|
+
if (effectiveWire !== "openai-responses") {
|
|
475
|
+
return `${field}.${key} requires the openai-responses wire`;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR =
|
|
482
|
+
"codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids";
|
|
483
|
+
const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR =
|
|
484
|
+
"account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys";
|
|
485
|
+
const CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR =
|
|
486
|
+
"account selector targets must be @main or valid Codex pool-account ids";
|
|
487
|
+
export const CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR =
|
|
488
|
+
"account selectors must not collide with configured Codex pool-account ids or account selector targets";
|
|
489
|
+
|
|
490
|
+
export function configuredCodexPoolAccountIds(value: unknown): Set<string> {
|
|
491
|
+
const accountIds = new Set<string>();
|
|
492
|
+
if (!Array.isArray(value)) return accountIds;
|
|
493
|
+
for (const account of value) {
|
|
494
|
+
if (!account || typeof account !== "object" || Array.isArray(account)) continue;
|
|
495
|
+
const { id, isMain } = account as { id?: unknown; isMain?: unknown };
|
|
496
|
+
if (typeof id === "string" && isMain !== true) accountIds.add(id);
|
|
497
|
+
}
|
|
498
|
+
return accountIds;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
|
|
502
|
+
(value): value is Record<string, unknown> => !!value
|
|
503
|
+
&& typeof value === "object"
|
|
504
|
+
&& !Array.isArray(value)
|
|
505
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
|
|
506
|
+
{ error: CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR },
|
|
507
|
+
).superRefine((accountNamespaces, ctx) => {
|
|
508
|
+
// Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
|
|
509
|
+
for (const [namespace, accountId] of Object.entries(accountNamespaces)) {
|
|
510
|
+
if (!isValidProviderName(namespace)) {
|
|
511
|
+
ctx.addIssue({
|
|
512
|
+
code: "custom",
|
|
513
|
+
path: [namespace],
|
|
514
|
+
message: CODEX_ACCOUNT_NAMESPACE_KEY_ERROR,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
if (!isValidCodexAccountNamespaceTarget(accountId)) {
|
|
518
|
+
ctx.addIssue({
|
|
519
|
+
code: "custom",
|
|
520
|
+
path: [namespace],
|
|
521
|
+
message: CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}).pipe(z.record(z.string(), z.string()));
|
|
526
|
+
|
|
527
|
+
const CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR =
|
|
528
|
+
"codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers";
|
|
529
|
+
const CODEX_ACCOUNT_PRIORITY_KEY_ERROR =
|
|
530
|
+
"selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys";
|
|
531
|
+
const CODEX_ACCOUNT_PRIORITY_VALUE_ERROR =
|
|
532
|
+
"selection order must be an integer between -100 and 100";
|
|
533
|
+
|
|
534
|
+
export const CODEX_ACCOUNT_PIN_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
535
|
+
|
|
536
|
+
export const codexAccountPrioritiesSchema = z.custom<Record<string, unknown>>(
|
|
537
|
+
(value): value is Record<string, unknown> => !!value
|
|
538
|
+
&& typeof value === "object"
|
|
539
|
+
&& !Array.isArray(value)
|
|
540
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
|
|
541
|
+
{ error: CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR },
|
|
542
|
+
).superRefine((priorities, ctx) => {
|
|
543
|
+
// Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
|
|
544
|
+
for (const [accountId, priority] of Object.entries(priorities)) {
|
|
545
|
+
if (!isCodexAccountPriorityKey(accountId)) {
|
|
546
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_KEY_ERROR });
|
|
547
|
+
}
|
|
548
|
+
if (parseAccountPriority(priority) === null) {
|
|
549
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_VALUE_ERROR });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}).pipe(z.record(z.string(), z.number().int()));
|
|
553
|
+
|
|
554
|
+
const codexQuotaAutoRefreshEntrySchema = z.object({
|
|
555
|
+
fiveHour: z.boolean().optional(),
|
|
556
|
+
weekly: z.boolean().optional(),
|
|
557
|
+
lastFiveHourResetAt: z.number().finite().nonnegative().optional(),
|
|
558
|
+
lastWeeklyResetAt: z.number().finite().nonnegative().optional(),
|
|
559
|
+
nextFiveHourResetAt: z.number().finite().nonnegative().optional(),
|
|
560
|
+
nextWeeklyResetAt: z.number().finite().nonnegative().optional(),
|
|
561
|
+
}).strict();
|
|
562
|
+
const CODEX_QUOTA_AUTO_REFRESH_KEY_ERROR =
|
|
563
|
+
"quota auto-refresh keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys";
|
|
564
|
+
|
|
565
|
+
export const codexQuotaAutoRefreshSchema = z.custom<Record<string, unknown>>(
|
|
566
|
+
(value): value is Record<string, unknown> => !!value
|
|
567
|
+
&& typeof value === "object"
|
|
568
|
+
&& !Array.isArray(value)
|
|
569
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
|
|
570
|
+
{ error: "codexQuotaAutoRefresh must be a plain object" },
|
|
571
|
+
).superRefine((settings, ctx) => {
|
|
572
|
+
// Inspect own entries before z.record parses them; Zod omits __proto__ record keys.
|
|
573
|
+
for (const [accountId, setting] of Object.entries(settings)) {
|
|
574
|
+
if (!isCodexAccountPriorityKey(accountId)) {
|
|
575
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_QUOTA_AUTO_REFRESH_KEY_ERROR });
|
|
576
|
+
}
|
|
577
|
+
const parsed = codexQuotaAutoRefreshEntrySchema.safeParse(setting);
|
|
578
|
+
if (!parsed.success) {
|
|
579
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: "invalid quota auto-refresh setting" });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}).pipe(z.record(z.string(), codexQuotaAutoRefreshEntrySchema));
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Deliberately permissive. A user's config is not ours to invalidate: a strict
|
|
586
|
+
* entry fails the whole parse, and loadConfig's fallback then backs the file up
|
|
587
|
+
* and returns defaults — losing providers and pool accounts because one key name
|
|
588
|
+
* was too long. Length and charset rules live at the POST/PATCH boundary, where
|
|
589
|
+
* rejecting produces a 400 instead. `.passthrough()` keeps unknown per-key
|
|
590
|
+
* properties across a load -> mutate -> save round trip.
|
|
591
|
+
*
|
|
592
|
+
* Only `key` is load-bearing: admission compares that string and nothing else
|
|
593
|
+
* (src/server/auth-cors.ts isDataPlaneAdmissionSecret). So the secret is the one
|
|
594
|
+
* field that must be a usable string, and every piece of metadata around it
|
|
595
|
+
* degrades instead of taking the credential down with it. Dropping a working key
|
|
596
|
+
* because its `name` was hand-edited to a number would be a silent revocation —
|
|
597
|
+
* and on a remote bind, potentially a server that refuses to start.
|
|
598
|
+
*
|
|
599
|
+
* "Usable" matches admission exactly. The presented token is trimmed before the
|
|
600
|
+
* comparison but the stored value is not, so a key with surrounding whitespace
|
|
601
|
+
* can never match either form of itself. Keeping one would be worse than dropping
|
|
602
|
+
* it: `system-env.ts` and `cli/claude.ts` hand `apiKeys[0].key` to launched
|
|
603
|
+
* clients, so a junk first entry would mask a valid later one.
|
|
604
|
+
*/
|
|
605
|
+
const pendingApiKeyRotationSchema = z.object({
|
|
606
|
+
id: z.string().trim().min(1).max(256),
|
|
607
|
+
key: z.string().refine(isUsableApiKeySecret),
|
|
608
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
609
|
+
expiresAt: z.string().datetime({ offset: true }),
|
|
610
|
+
}).strict();
|
|
611
|
+
|
|
612
|
+
export const apiKeyEntrySchema = z.object({
|
|
613
|
+
key: z.string().refine(isUsableApiKeySecret),
|
|
614
|
+
// Degrades to "" here; every schema consumer then runs `normalizeApiKeyIds`,
|
|
615
|
+
// which fills it deterministically so the id is stable across loads.
|
|
616
|
+
id: z.string().catch(""),
|
|
617
|
+
name: z.string().catch(""),
|
|
618
|
+
createdAt: z.string().catch(""),
|
|
619
|
+
// A damaged overlap record must never discard the still-authoritative key.
|
|
620
|
+
pendingRotation: pendingApiKeyRotationSchema.optional().catch(undefined),
|
|
621
|
+
}).passthrough();
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Durable per-client intent.
|
|
625
|
+
*
|
|
626
|
+
* `.passthrough()` is load-bearing: a binary that only knows `codex` must not
|
|
627
|
+
* erase a key a later version wrote during a field-scoped mutation. And each key
|
|
628
|
+
* degrades on its own — a hand edit of `{"codex": "false", "future": false}`
|
|
629
|
+
* drops `codex` to absent (which reads as ON) and keeps `future`, rather than
|
|
630
|
+
* invalidating the object or, worse, the whole config.
|
|
631
|
+
*/
|
|
632
|
+
export const clientIntegrationsSchema = z.object({
|
|
633
|
+
codex: z.boolean().optional().catch(undefined),
|
|
634
|
+
grok: z.boolean().optional().catch(undefined),
|
|
635
|
+
"claude-desktop": z.boolean().optional().catch(undefined),
|
|
636
|
+
}).passthrough();
|
|
637
|
+
|
|
638
|
+
export const asideProfileSyncSchema = z.object({
|
|
639
|
+
allProfiles: z.boolean().optional(),
|
|
640
|
+
profiles: z.record(
|
|
641
|
+
z.string().regex(/^(0|[1-9][0-9]*)$/).refine(value => Number.isSafeInteger(Number(value))),
|
|
642
|
+
z.boolean(),
|
|
643
|
+
).optional(),
|
|
644
|
+
legacyProfileId: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable().optional(),
|
|
645
|
+
}).passthrough();
|
|
646
|
+
|
|
647
|
+
export const agentTaskRecoverySchema = z.object({
|
|
648
|
+
enabled: z.boolean().optional(),
|
|
649
|
+
model: z.string().trim().min(1).optional(),
|
|
650
|
+
timeoutMs: z.number().int().min(1_000).max(120_000).optional(),
|
|
651
|
+
cacheEntries: z.number().int().min(1).max(512).optional(),
|
|
652
|
+
}).strict();
|
|
653
|
+
|
|
654
|
+
export const runtimeRoleSchema = z.enum(["standalone", "hub", "client"]);
|
|
655
|
+
|
|
656
|
+
function canonicalHttpOrigin(value: string): string | null {
|
|
657
|
+
try {
|
|
658
|
+
const parsed = new URL(value);
|
|
659
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
660
|
+
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
|
|
661
|
+
return parsed.origin;
|
|
662
|
+
} catch {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export const managementIngressSchema = z.union([
|
|
668
|
+
z.object({ enabled: z.literal(false) }).strict(),
|
|
669
|
+
z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }).strict(),
|
|
670
|
+
]);
|
|
671
|
+
|
|
672
|
+
export const hubConfigSchema = z.object({
|
|
673
|
+
managementPublicOrigin: z.string().transform((value, ctx) => {
|
|
674
|
+
const origin = canonicalHttpOrigin(value);
|
|
675
|
+
if (!origin) {
|
|
676
|
+
ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
|
|
677
|
+
return z.NEVER;
|
|
678
|
+
}
|
|
679
|
+
return origin;
|
|
680
|
+
}).optional(),
|
|
681
|
+
// Same canonical-origin rule as managementPublicOrigin, and deliberately NOT `.catch`ed:
|
|
682
|
+
// a mistyped data origin must be rejected at write time, because silently dropping it
|
|
683
|
+
// makes `ocx hub invite` print the `http://<hostname>:<port>` fallback that the operator
|
|
684
|
+
// set this field precisely to replace.
|
|
685
|
+
dataPublicOrigin: z.string().transform((value, ctx) => {
|
|
686
|
+
const origin = canonicalHttpOrigin(value);
|
|
687
|
+
if (!origin) {
|
|
688
|
+
ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
|
|
689
|
+
return z.NEVER;
|
|
690
|
+
}
|
|
691
|
+
return origin;
|
|
692
|
+
}).optional(),
|
|
693
|
+
// A malformed hand edit disables only the optional ingress. Live writes are rejected by
|
|
694
|
+
// managementIngressConfigError before this load-time degradation can hide the mistake.
|
|
695
|
+
managementIngress: managementIngressSchema.optional().catch(undefined),
|
|
696
|
+
}).strict();
|
|
697
|
+
|
|
698
|
+
const tailscaleUserSchema = z.string().trim().min(1).superRefine((value, ctx) => {
|
|
699
|
+
if (new TextEncoder().encode(value).byteLength > 320) {
|
|
700
|
+
ctx.addIssue({ code: "custom", message: "must be at most 320 UTF-8 bytes" });
|
|
701
|
+
}
|
|
702
|
+
if (/[\x00-\x1f\x7f]/.test(value)) {
|
|
703
|
+
ctx.addIssue({ code: "custom", message: "must not contain ASCII control characters" });
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
export const remoteGuiConfigSchema = z.object({
|
|
708
|
+
allowedTailscaleUsers: z.array(tailscaleUserSchema).max(64).superRefine((users, ctx) => {
|
|
709
|
+
const seen = new Set<string>();
|
|
710
|
+
for (let index = 0; index < users.length; index++) {
|
|
711
|
+
const user = users[index]!;
|
|
712
|
+
if (seen.has(user)) {
|
|
713
|
+
ctx.addIssue({ code: "custom", path: [index], message: "must contain unique users after trimming" });
|
|
714
|
+
}
|
|
715
|
+
seen.add(user);
|
|
716
|
+
}
|
|
717
|
+
}).optional(),
|
|
718
|
+
// Retired (see OcxRemoteGuiConfig): accepted so an existing file still loads, ignored by
|
|
719
|
+
// the pairing path. Removing it from a strict schema would reject the whole config.
|
|
720
|
+
allowInsecureHttp: z.boolean().optional(),
|
|
721
|
+
}).strict();
|
|
722
|
+
|
|
723
|
+
const connectedClientIdSchema = z.enum(["codex", "claude"]);
|
|
724
|
+
const clientTimestampSchema = z.string().datetime({ offset: true });
|
|
725
|
+
const clientOriginSchema = z.string().transform((value, ctx) => {
|
|
726
|
+
const origin = canonicalHttpOrigin(value);
|
|
727
|
+
if (!origin) {
|
|
728
|
+
ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
|
|
729
|
+
return z.NEVER;
|
|
730
|
+
}
|
|
731
|
+
return origin;
|
|
732
|
+
});
|
|
733
|
+
export const clientConnectionSchema = z.object({
|
|
734
|
+
serverUrl: clientOriginSchema,
|
|
735
|
+
managementUrl: clientOriginSchema,
|
|
736
|
+
managementTransport: z.enum(["direct", "relay"]),
|
|
737
|
+
selectedClients: z.array(connectedClientIdSchema).min(1).max(2).superRefine((clients, ctx) => {
|
|
738
|
+
if (new Set(clients).size !== clients.length) {
|
|
739
|
+
ctx.addIssue({ code: "custom", message: "must contain unique client ids" });
|
|
740
|
+
}
|
|
741
|
+
}),
|
|
742
|
+
tokenEnv: z.literal("OPENCODEX_API_AUTH_TOKEN"),
|
|
743
|
+
apiKeyId: z.string().trim().min(1).max(256),
|
|
744
|
+
tokenFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
|
745
|
+
protocolVersion: z.literal(1),
|
|
746
|
+
connectedAt: clientTimestampSchema,
|
|
747
|
+
catalogFingerprint: z.string().min(1).max(512).optional(),
|
|
748
|
+
// base64 of the pre-connect catalog, or "" for "there was none". Bounded above the
|
|
749
|
+
// catalog size cap so a legitimate snapshot round-trips.
|
|
750
|
+
priorCatalog: z.string().max(64 * 1024 * 1024).optional(),
|
|
751
|
+
catalogSyncedAt: clientTimestampSchema.optional(),
|
|
752
|
+
pendingOperation: z.object({
|
|
753
|
+
kind: z.literal("rotate"),
|
|
754
|
+
rotationId: z.string().trim().min(1).max(256),
|
|
755
|
+
newKeyIssuedAt: clientTimestampSchema,
|
|
756
|
+
oldKeyBackupPath: z.string().min(1),
|
|
757
|
+
}).strict().superRefine((operation, ctx) => {
|
|
758
|
+
const expected = join(getConfigDir(), "service-api-token.prev");
|
|
759
|
+
if (operation.oldKeyBackupPath !== expected) {
|
|
760
|
+
ctx.addIssue({ code: "custom", path: ["oldKeyBackupPath"], message: `must equal ${expected}` });
|
|
761
|
+
}
|
|
762
|
+
}).optional(),
|
|
763
|
+
}).strict();
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Codex pool selection policy section.
|
|
767
|
+
*
|
|
768
|
+
* `.strict()` like its neighbour: a typo in an optional feature section should surface as a
|
|
769
|
+
* rejected write rather than a silently ignored key that leaves the operator believing they
|
|
770
|
+
* excluded something.
|
|
771
|
+
*/
|
|
772
|
+
export const codexPoolSchema = z.object({
|
|
773
|
+
excludedPlans: z.array(z.string().trim().min(1)).optional(),
|
|
774
|
+
}).strict();
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Shape guard for the cross-element checks below. Zod runs an array-level check even
|
|
778
|
+
* when an element failed its own validation, and a failed element is not the shape the
|
|
779
|
+
* checker expects — reading `credentials.length` off it would throw out of `safeParse`
|
|
780
|
+
* and take the whole config load with it. Those elements already carry their own issues.
|
|
781
|
+
*/
|
|
782
|
+
export function isCredentialGroupShape(value: unknown): value is { id: string; credentials: string[] } {
|
|
783
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
784
|
+
const group = value as { id?: unknown; credentials?: unknown };
|
|
785
|
+
return typeof group.id === "string"
|
|
786
|
+
&& Array.isArray(group.credentials)
|
|
787
|
+
&& group.credentials.every(member => typeof member === "string");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Operator-declared quota domains (`pool.credentialGroups`).
|
|
792
|
+
*
|
|
793
|
+
* Loose enough to hand-write, strict enough that it cannot mean two things: unique group
|
|
794
|
+
* ids, a non-empty member list, provider-qualified members, and each credential in at
|
|
795
|
+
* most one group. Those are not tidiness rules. `classifyCredential` keys a declared
|
|
796
|
+
* domain by group id, so a duplicate id or a credential listed twice merges two quota
|
|
797
|
+
* domains the operator never said were one -- after which the pool counts real capacity
|
|
798
|
+
* once and declines to rotate into it. A bare credential id is ambiguous for the same
|
|
799
|
+
* reason ids are provider-scoped in the auth store, so members carry their provider.
|
|
800
|
+
* {@link credentialGroupIssues} is the single definition, shared with the classifier.
|
|
801
|
+
*/
|
|
802
|
+
export const credentialGroupsSchema = z.array(z.object({
|
|
803
|
+
id: z.string().trim().min(1),
|
|
804
|
+
credentials: z.array(z.string().trim().min(1)).min(1),
|
|
805
|
+
note: z.string().optional(),
|
|
806
|
+
})).superRefine((groups, ctx) => {
|
|
807
|
+
if (!Array.isArray(groups) || !groups.every(isCredentialGroupShape)) return;
|
|
808
|
+
for (const message of credentialGroupIssues(groups)) {
|
|
809
|
+
ctx.addIssue({ code: "custom", message });
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Quota-reset notification section.
|
|
815
|
+
*
|
|
816
|
+
* `.strict()` like its neighbour: a typo in an optional feature section should surface as a
|
|
817
|
+
* rejected write rather than a silently ignored key that leaves the operator believing they
|
|
818
|
+
* enabled something.
|
|
819
|
+
*
|
|
820
|
+
* `pollSeconds` admits 0 (passive-only, no timer) and the resolver clamps anything between 1
|
|
821
|
+
* and the 60-second floor. Bounds live in the resolver rather than here so a hand-edited value
|
|
822
|
+
* degrades to a sane one instead of discarding the whole section.
|
|
823
|
+
*/
|
|
824
|
+
export const quotaResetNotifySchema = z.object({
|
|
825
|
+
enabled: z.boolean().optional(),
|
|
826
|
+
kinds: z.array(z.enum(["scheduled", "surprise"])).optional(),
|
|
827
|
+
pollSeconds: z.number().int().min(0).optional(),
|
|
828
|
+
// `z.string().url()` accepts any scheme. The payload carries account identity and the hook
|
|
829
|
+
// URL is frequently a bearer-equivalent secret, so an http: sink puts both in cleartext.
|
|
830
|
+
webhookUrl: z.string().url().refine(
|
|
831
|
+
value => { try { return new URL(value).protocol === "https:"; } catch { return false; } },
|
|
832
|
+
{ message: "webhookUrl must use https" },
|
|
833
|
+
).optional(),
|
|
834
|
+
allowPrivateNetwork: z.boolean().optional(),
|
|
835
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
836
|
+
command: z.array(z.string()).optional(),
|
|
837
|
+
}).strict();
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Catalog auto-refresh section (issue #3630).
|
|
841
|
+
*
|
|
842
|
+
* `.strict()` like its neighbour: a typo in an optional feature section should surface as a
|
|
843
|
+
* rejected write rather than a silently ignored key that leaves the operator believing they
|
|
844
|
+
* enabled something.
|
|
845
|
+
*
|
|
846
|
+
* `intervalMinutes` admits 0 (configured but dormant, no timer) and the resolver clamps
|
|
847
|
+
* anything between 1 and the 15-minute floor. Bounds live in the resolver rather than here
|
|
848
|
+
* so a hand-edited value degrades to a sane one instead of discarding the whole section.
|
|
849
|
+
* The 1440 ceiling keeps a hand edit from scheduling the refresh further out than a day,
|
|
850
|
+
* which is operator error far more often than intent.
|
|
851
|
+
*/
|
|
852
|
+
export const catalogAutoRefreshSchema = z.object({
|
|
853
|
+
enabled: z.boolean().optional(),
|
|
854
|
+
intervalMinutes: z.number().int().min(0).max(1440).optional(),
|
|
855
|
+
}).strict();
|