@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,880 @@
|
|
|
1
|
+
import { chmodSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
modelPinnedEffortsConfigError,
|
|
5
|
+
pinnedReasoningEffortConfigError,
|
|
6
|
+
autoReviewModelOverridesConfigError,
|
|
7
|
+
autoReviewModelTargetConfigError,
|
|
8
|
+
modelCapabilitiesConfigError,
|
|
9
|
+
sanitizeModelCapabilitiesForLoad,
|
|
10
|
+
modelDisplayNamesConfigError,
|
|
11
|
+
} from "./provider-validation";
|
|
12
|
+
import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "../codex/upstream-host-health";
|
|
13
|
+
import { hardenSecretPath } from "../lib/windows-secret-acl";
|
|
14
|
+
import { redactSecretString } from "../lib/redact";
|
|
15
|
+
import { isValidProviderName } from "./provider-name";
|
|
16
|
+
import { MODEL_ALIAS_PATTERN } from "../providers/default-aliases";
|
|
17
|
+
import { MODEL_DISCOVERY_MAX_MODELS } from "../providers/model-discovery-limits";
|
|
18
|
+
import { getProviderRegistryEntry, providerMatchesRegistryTransport, registryModelServiceTierCapabilityApplies } from "../providers/registry";
|
|
19
|
+
import { isCodexReasoningEffort } from "../reasoning-effort";
|
|
20
|
+
import { refreshUserCostOverlays } from "../usage/user-cost-overlays";
|
|
21
|
+
import { type OcxClaudeCodeConfig, type OcxConfig } from "../types";
|
|
22
|
+
import {
|
|
23
|
+
agentTaskRecoverySchema,
|
|
24
|
+
catalogAutoRefreshSchema,
|
|
25
|
+
clientConnectionSchema,
|
|
26
|
+
isUsableApiKeySecret,
|
|
27
|
+
managementIngressSchema,
|
|
28
|
+
codexPoolSchema,
|
|
29
|
+
providerModelCostsConfigError,
|
|
30
|
+
credentialGroupsSchema,
|
|
31
|
+
hubConfigSchema,
|
|
32
|
+
quotaResetNotifySchema,
|
|
33
|
+
remoteGuiConfigSchema,
|
|
34
|
+
retryOn429PolicySchema,
|
|
35
|
+
runtimeRoleSchema,
|
|
36
|
+
} from "./schema/leaf-validators";
|
|
37
|
+
import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict } from "./warn-memo";
|
|
38
|
+
|
|
39
|
+
export function hardenExistingSecret(path: string): void {
|
|
40
|
+
if (existsSync(path)) {
|
|
41
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
42
|
+
if (process.platform === "win32") {
|
|
43
|
+
hardenSecretPath(path, { required: false });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Load only: discard invalid optional pins without rewriting the file or losing providers. */
|
|
48
|
+
export function sanitizeReasoningPinsForLoad(parsed: unknown): void {
|
|
49
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
50
|
+
const root = parsed as Record<string, unknown>;
|
|
51
|
+
let degraded = false;
|
|
52
|
+
const sanitizeMap = (owner: Record<string, unknown>, field: string) => {
|
|
53
|
+
const value = owner[field];
|
|
54
|
+
if (value === undefined) return;
|
|
55
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
56
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
|
|
57
|
+
delete owner[field];
|
|
58
|
+
degraded = true;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const counts = new Map<string, number>();
|
|
62
|
+
for (const key of Object.keys(value)) counts.set(key.trim(), (counts.get(key.trim()) ?? 0) + 1);
|
|
63
|
+
const valid: Record<string, string> = Object.create(null);
|
|
64
|
+
for (const [key, effort] of Object.entries(value)) {
|
|
65
|
+
if (counts.get(key.trim()) !== 1 || modelPinnedEffortsConfigError({ [key]: effort }) !== null) {
|
|
66
|
+
degraded = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
valid[key.trim()] = effort as string;
|
|
70
|
+
}
|
|
71
|
+
if (Object.keys(valid).length) owner[field] = valid;
|
|
72
|
+
else delete owner[field];
|
|
73
|
+
};
|
|
74
|
+
sanitizeMap(root, "modelPinnedEfforts");
|
|
75
|
+
if (root.providers && typeof root.providers === "object" && !Array.isArray(root.providers)) {
|
|
76
|
+
for (const value of Object.values(root.providers)) {
|
|
77
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
78
|
+
const provider = value as Record<string, unknown>;
|
|
79
|
+
if (pinnedReasoningEffortConfigError(provider.pinnedReasoningEffort)) {
|
|
80
|
+
delete provider.pinnedReasoningEffort;
|
|
81
|
+
degraded = true;
|
|
82
|
+
}
|
|
83
|
+
sanitizeMap(provider, "modelPinnedReasoningEfforts");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Never include a provider/model name or value: malformed pins can contain secrets.
|
|
87
|
+
if (degraded) console.warn("config.json contains invalid optional reasoning pins — ignoring invalid fields or entries");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The schema's `.catch(undefined)` silently degrades an invalid persisted
|
|
92
|
+
* `streamMode` to "auto"; surface that once so a hand-edited typo (e.g.
|
|
93
|
+
* "legacy_tee") is discoverable instead of silently changing stream shape.
|
|
94
|
+
*/
|
|
95
|
+
export function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void {
|
|
96
|
+
if (!rawParsed || typeof rawParsed !== "object") return;
|
|
97
|
+
const raw = (rawParsed as Record<string, unknown>).streamMode;
|
|
98
|
+
if (raw !== undefined && validated.streamMode === undefined) {
|
|
99
|
+
console.warn(`⚠️ config.json streamMode ${JSON.stringify(raw)} is invalid (expected "auto", "legacy-tee", or "eager-relay") — falling back to "auto"`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Load-time degradation for `retryOn429` (loadConfig only): one hand-edited invalid optional
|
|
105
|
+
* field (e.g. `attempts: 0` or a string) must not trip the whole provider schema and hide every
|
|
106
|
+
* provider/key behind a default config. Invalid fields are dropped with a warning; the management
|
|
107
|
+
* write boundary still rejects invalid policies explicitly.
|
|
108
|
+
*/
|
|
109
|
+
export function sanitizeRetryOn429ForLoad(parsed: unknown): void {
|
|
110
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
111
|
+
const root = parsed as Record<string, unknown>;
|
|
112
|
+
const providers = root.providers;
|
|
113
|
+
if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
|
|
114
|
+
for (const [name, provider] of Object.entries(providers as Record<string, unknown>)) {
|
|
115
|
+
// This sanitizer runs BEFORE schema validation, so the provider name is untrusted: redact
|
|
116
|
+
// secret-shaped names and JSON-escape control characters before it reaches any warning.
|
|
117
|
+
const safeProviderName = JSON.stringify(redactSecretString(name));
|
|
118
|
+
if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue;
|
|
119
|
+
const p = provider as Record<string, unknown>;
|
|
120
|
+
const policy = p.retryOn429;
|
|
121
|
+
if (policy === undefined) continue;
|
|
122
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
123
|
+
delete p.retryOn429;
|
|
124
|
+
// Never serialize the value: an accidental `retryOn429: "sk-..."` would leak the secret.
|
|
125
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 (${typeof policy}) is invalid — ignoring the policy`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const policyRecord = policy as Record<string, unknown>;
|
|
129
|
+
// An explicitly present but invalid master switch must not silently default to ENABLED:
|
|
130
|
+
// drop the whole policy so a hand-edit that tried to disable retries stays disabled.
|
|
131
|
+
if ("enabled" in policyRecord && typeof policyRecord.enabled !== "boolean") {
|
|
132
|
+
delete p.retryOn429;
|
|
133
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.enabled (${typeof policyRecord.enabled}) is invalid — ignoring the whole policy`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
// Field checks derive from the shared policy schema so the bounds cannot drift
|
|
137
|
+
// between the load-time sanitizer, the config schema, and the write boundary.
|
|
138
|
+
const policyShape = retryOn429PolicySchema.shape;
|
|
139
|
+
const hadPolicyEntries = Object.keys(policyRecord).length > 0;
|
|
140
|
+
const cleaned: Record<string, unknown> = {};
|
|
141
|
+
for (const [key, fieldSchema] of Object.entries(policyShape)) {
|
|
142
|
+
const value = policyRecord[key];
|
|
143
|
+
if (value === undefined) continue;
|
|
144
|
+
if (fieldSchema.safeParse(value).success) cleaned[key] = value;
|
|
145
|
+
// Log only the received type, never the value (provider config can hold secrets).
|
|
146
|
+
else console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${key} (${typeof value}) is invalid — ignoring the field`);
|
|
147
|
+
}
|
|
148
|
+
const knownKeys = new Set(Object.keys(policyShape));
|
|
149
|
+
for (const key of Object.keys(policyRecord)) {
|
|
150
|
+
if (!knownKeys.has(key)) {
|
|
151
|
+
// Redact the field NAME before logging: a malformed hand-edit can place a secret in a
|
|
152
|
+
// property name (`retryOn429: { "sk-...": true }`). Ordinary typos (e.g. `attempt`)
|
|
153
|
+
// stay readable, secret-shaped names become [REDACTED]. JSON-escape afterwards so a
|
|
154
|
+
// control-character property name (newline/ANSI) can never forge a log line.
|
|
155
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${JSON.stringify(redactSecretString(key))} is not a recognized field — ignoring it`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (hadPolicyEntries && Object.keys(cleaned).length === 0) {
|
|
159
|
+
// Every supplied field was invalid: drop the whole policy. Persisting `{}` here would
|
|
160
|
+
// opt IN to retries with defaults, which is the opposite of what a malformed
|
|
161
|
+
// disable-oriented edit (`retryOn429: { enabled: "false" }`, `attempts: 0`) asked for.
|
|
162
|
+
delete p.retryOn429;
|
|
163
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 has no valid fields left — removing the policy (an empty policy would enable retries with defaults)`);
|
|
164
|
+
} else {
|
|
165
|
+
// Preserve an intentionally empty `retryOn429: {}` (presence = opt-in with defaults).
|
|
166
|
+
p.retryOn429 = cleaned;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Management write-boundary validation for `retryOn429` (fail closed). Unlike the
|
|
173
|
+
* lenient load-time sanitizer, invalid values and unknown keys are rejected outright so
|
|
174
|
+
* a POST/PATCH cannot persist a policy the proxy would then silently degrade. Reuses the
|
|
175
|
+
* shared policy schema. Never echoes values, and secret-shaped unknown field names are
|
|
176
|
+
* redacted (a malformed write can place a secret in a property name).
|
|
177
|
+
*/
|
|
178
|
+
export function retryOn429PolicyConfigError(policy: unknown): string | null {
|
|
179
|
+
if (policy === undefined) return null;
|
|
180
|
+
const result = retryOn429PolicySchema.safeParse(policy);
|
|
181
|
+
if (result.success) return null;
|
|
182
|
+
const first = result.error.issues[0];
|
|
183
|
+
if (!first) return "retryOn429 is invalid";
|
|
184
|
+
if (first.code === "unrecognized_keys") {
|
|
185
|
+
const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", ");
|
|
186
|
+
return `retryOn429 has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`;
|
|
187
|
+
}
|
|
188
|
+
if (first.path.length === 0) return `retryOn429 is invalid (${first.message})`;
|
|
189
|
+
const field = String(first.path[first.path.length - 1]);
|
|
190
|
+
return `retryOn429.${field} is invalid (${first.message})`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function sanitizeCapabilityDeclarationsForLoad(parsed: unknown): void {
|
|
194
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
195
|
+
const providers = (parsed as Record<string, unknown>).providers;
|
|
196
|
+
if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
|
|
197
|
+
for (const [name, value] of Object.entries(providers)) {
|
|
198
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
199
|
+
const provider = value as Record<string, unknown>;
|
|
200
|
+
if (provider.modelCapabilities === undefined) continue;
|
|
201
|
+
if (modelCapabilitiesConfigError(provider.modelCapabilities) !== null) {
|
|
202
|
+
console.warn(`config.json provider ${JSON.stringify(redactSecretString(name))} has malformed modelCapabilities; retaining valid axes and restricting malformed input modalities to text`);
|
|
203
|
+
const repaired = sanitizeModelCapabilitiesForLoad(provider.modelCapabilities);
|
|
204
|
+
if (repaired) provider.modelCapabilities = repaired;
|
|
205
|
+
else delete provider.modelCapabilities;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Load-time degradation for `providers.<name>.modelCosts`, mirroring
|
|
212
|
+
* {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row
|
|
213
|
+
* must not fail the whole config parse — that would back up config.json and
|
|
214
|
+
* fall back to defaults, dropping otherwise valid providers and the default
|
|
215
|
+
* route for a typo in a non-runtime display field. Invalid rows are dropped
|
|
216
|
+
* with a warning; strict rejection stays at the management/write boundary
|
|
217
|
+
* (providerManagementConfigError).
|
|
218
|
+
*/
|
|
219
|
+
export function sanitizeModelCostsForLoad(parsed: unknown): void {
|
|
220
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
221
|
+
const root = parsed as Record<string, unknown>;
|
|
222
|
+
const providers = root.providers;
|
|
223
|
+
if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
|
|
224
|
+
for (const [name, provider] of Object.entries(providers as Record<string, unknown>)) {
|
|
225
|
+
// Runs before schema validation, so the provider name is untrusted: redact
|
|
226
|
+
// secret-shaped names and JSON-escape control characters for the warning.
|
|
227
|
+
const safeProviderName = JSON.stringify(redactSecretString(name));
|
|
228
|
+
if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue;
|
|
229
|
+
const p = provider as Record<string, unknown>;
|
|
230
|
+
const costs = p.modelCosts;
|
|
231
|
+
if (costs === undefined) continue;
|
|
232
|
+
if (!costs || typeof costs !== "object" || Array.isArray(costs)) {
|
|
233
|
+
delete p.modelCosts;
|
|
234
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts (${typeof costs}) is invalid — ignoring the overlay`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const costsRecord = costs as Record<string, unknown>;
|
|
238
|
+
const hadEntries = Object.keys(costsRecord).length > 0;
|
|
239
|
+
let kept = 0;
|
|
240
|
+
for (const [modelId, entry] of Object.entries(costsRecord)) {
|
|
241
|
+
// Reuse the shared per-row shape contract so the load-time sanitizer
|
|
242
|
+
// cannot drift from the schema and the write boundary.
|
|
243
|
+
if (providerModelCostsConfigError({ [modelId]: entry }) === null) {
|
|
244
|
+
kept++;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
delete costsRecord[modelId];
|
|
248
|
+
// Redact the model id: a hand-edit can place a secret in a key name.
|
|
249
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts.${JSON.stringify(redactSecretString(modelId))} is invalid — ignoring the row`);
|
|
250
|
+
}
|
|
251
|
+
if (hadEntries && kept === 0) {
|
|
252
|
+
delete p.modelCosts;
|
|
253
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts has no valid rows left — removing the overlay`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Load-time degradation for provider-scoped auto-review selectors. A malformed
|
|
260
|
+
* hand edit must not fail the whole config parse; the management boundary stays
|
|
261
|
+
* strict and rejects the same shapes before they can be written.
|
|
262
|
+
*/
|
|
263
|
+
export function sanitizeAutoReviewForLoad(parsed: unknown): void {
|
|
264
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
265
|
+
const root = parsed as Record<string, unknown>;
|
|
266
|
+
const providers = root.providers;
|
|
267
|
+
if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
|
|
268
|
+
for (const [name, providerValue] of Object.entries(providers as Record<string, unknown>)) {
|
|
269
|
+
if (!providerValue || typeof providerValue !== "object" || Array.isArray(providerValue)) continue;
|
|
270
|
+
const provider = providerValue as Record<string, unknown>;
|
|
271
|
+
const safeProviderName = JSON.stringify(redactSecretString(name));
|
|
272
|
+
if (name === "openai") {
|
|
273
|
+
delete provider.autoReviewModel;
|
|
274
|
+
delete provider.autoReviewModelOverrides;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (provider.autoReviewModel !== undefined
|
|
278
|
+
&& autoReviewModelTargetConfigError(provider.autoReviewModel, "autoReviewModel", true) !== null) {
|
|
279
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.autoReviewModel is invalid — ignoring the selector`);
|
|
280
|
+
delete provider.autoReviewModel;
|
|
281
|
+
}
|
|
282
|
+
if (provider.autoReviewModelOverrides !== undefined) {
|
|
283
|
+
const overridesError = autoReviewModelOverridesConfigError(
|
|
284
|
+
provider.autoReviewModelOverrides,
|
|
285
|
+
"autoReviewModelOverrides",
|
|
286
|
+
true,
|
|
287
|
+
);
|
|
288
|
+
if (overridesError) {
|
|
289
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.autoReviewModelOverrides is invalid — ignoring the map`);
|
|
290
|
+
delete provider.autoReviewModelOverrides;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind
|
|
298
|
+
* falls back to loopback, which is the safe direction but not what the file asked for —
|
|
299
|
+
* say so once instead of silently ignoring the field.
|
|
300
|
+
*/
|
|
301
|
+
export function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void {
|
|
302
|
+
if (!rawParsed || typeof rawParsed !== "object") return;
|
|
303
|
+
const raw = (rawParsed as Record<string, unknown>).hostname;
|
|
304
|
+
if (raw !== undefined && validated.hostname === undefined) {
|
|
305
|
+
console.warn(`⚠️ config.json hostname ${JSON.stringify(raw)} is not a usable bind address — falling back to 127.0.0.1`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function degradedListenerWarnings(rawParsed: unknown, validated: OcxConfig): string[] {
|
|
310
|
+
const raw = rawConfigRecord(rawParsed);
|
|
311
|
+
if (!raw) return [];
|
|
312
|
+
const warnings: string[] = [];
|
|
313
|
+
if (raw.unauthenticatedLoopbackListener !== undefined && validated.unauthenticatedLoopbackListener === undefined) {
|
|
314
|
+
warnings.push("unauthenticatedLoopbackListener ignored: invalid listener configuration; repair config.json before enabling the listener");
|
|
315
|
+
}
|
|
316
|
+
const hub = rawConfigRecord(raw.hub);
|
|
317
|
+
if (hub?.managementIngress !== undefined && !managementIngressSchema.safeParse(hub.managementIngress).success) {
|
|
318
|
+
warnings.push("hub.managementIngress ignored: invalid management listener configuration; repair config.json before enabling the listener");
|
|
319
|
+
}
|
|
320
|
+
return warnings;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function warnDegradedListeners(rawParsed: unknown, validated: OcxConfig): void {
|
|
324
|
+
for (const warning of degradedListenerWarnings(rawParsed, validated)) {
|
|
325
|
+
console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Companion to {@link warnDegradedStreamMode} for a malformed selection-order map.
|
|
331
|
+
* Priority is a preference, so the schema drops the whole map rather than failing
|
|
332
|
+
* the parse — say so once, otherwise the pool silently reverts to flat ordering.
|
|
333
|
+
*/
|
|
334
|
+
export function degradedCodexAccountPriorityWarnings(rawParsed: unknown, validated: OcxConfig): string[] {
|
|
335
|
+
const record = rawConfigRecord(rawParsed);
|
|
336
|
+
const warnings: string[] = [];
|
|
337
|
+
// The pin degrades silently otherwise, which reads as the manual selection simply
|
|
338
|
+
// not having survived the restart.
|
|
339
|
+
if (record?.activeCodexAccountPinned !== undefined && validated.activeCodexAccountPinned === undefined) {
|
|
340
|
+
warnings.push("activeCodexAccountPinned is not a valid account id — the manually selected account is no longer pinned");
|
|
341
|
+
}
|
|
342
|
+
const raw = record?.codexAccountPriorities;
|
|
343
|
+
if (raw !== undefined && validated.codexAccountPriorities === undefined) {
|
|
344
|
+
warnings.push("codexAccountPriorities is invalid (expected account ids mapped to integers between -100 and 100) — account selection order is disabled");
|
|
345
|
+
}
|
|
346
|
+
return warnings;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function warnDegradedCodexAccountPriorities(rawParsed: unknown, validated: OcxConfig): void {
|
|
350
|
+
for (const warning of degradedCodexAccountPriorityWarnings(rawParsed, validated)) {
|
|
351
|
+
console.warn(`⚠️ config.json ${warning}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function degradedCodexQuotaAutoRefreshWarning(rawParsed: unknown, validated: OcxConfig): string | null {
|
|
356
|
+
const raw = rawConfigRecord(rawParsed)?.codexQuotaAutoRefresh;
|
|
357
|
+
if (raw === undefined || validated.codexQuotaAutoRefresh !== undefined) return null;
|
|
358
|
+
return "codexQuotaAutoRefresh is invalid — automatic quota-window activation is disabled";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function warnDegradedCodexQuotaAutoRefresh(rawParsed: unknown, validated: OcxConfig): void {
|
|
362
|
+
const warning = degradedCodexQuotaAutoRefreshWarning(rawParsed, validated);
|
|
363
|
+
if (warning) console.warn(`⚠️ config.json ${warning}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Companion to the degrade warnings above, for a malformed or ambiguous declared
|
|
368
|
+
* grouping. The list now degrades on its own so the rest of `pool` survives, which is
|
|
369
|
+
* also why it needs a voice: nothing else about the config looks different afterwards,
|
|
370
|
+
* and silently ungrouped credentials read as capacity the pool does not have.
|
|
371
|
+
*/
|
|
372
|
+
export function degradedCredentialGroupsWarning(rawParsed: unknown): string | null {
|
|
373
|
+
const pool = rawConfigRecord(rawConfigRecord(rawParsed)?.pool);
|
|
374
|
+
if (!pool || pool.credentialGroups === undefined) return null;
|
|
375
|
+
const parsed = credentialGroupsSchema.safeParse(pool.credentialGroups);
|
|
376
|
+
if (parsed.success) return null;
|
|
377
|
+
// Every issue message is redacted before it is joined. The custom messages embed the
|
|
378
|
+
// offending member through `JSON.stringify`, so a malformed credential string that
|
|
379
|
+
// happens to carry secret material would otherwise be printed verbatim at config load
|
|
380
|
+
// — a config file is exactly where a pasted token ends up in the wrong field.
|
|
381
|
+
const details = parsed.error.issues.map(issue => redactSecretString(issue.message)).join("; ");
|
|
382
|
+
return `pool.credentialGroups is invalid (${details}) — declared quota grouping is disabled; other pool settings were preserved`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function warnDegradedCredentialGroups(rawParsed: unknown): void {
|
|
386
|
+
const warning = degradedCredentialGroupsWarning(rawParsed);
|
|
387
|
+
if (warning) console.warn(`⚠️ config.json ${warning}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The apiKeys schema salvages entry by entry rather than failing the parse, so a
|
|
392
|
+
* dropped key is otherwise invisible — and it will not be re-saved by the next
|
|
393
|
+
* mutation. Say so out loud. Compares the raw array against the validated one,
|
|
394
|
+
* the same shape as the degrade warnings above.
|
|
395
|
+
*/
|
|
396
|
+
/**
|
|
397
|
+
* Give every salvaged key a stable, targetable id.
|
|
398
|
+
*
|
|
399
|
+
* Pure and deterministic on purpose. Two earlier spellings were wrong: minting a
|
|
400
|
+
* UUID inside the schema transform handed out a different id on every parse, and
|
|
401
|
+
* repairing-then-writing during `loadConfig` put a file write on the read path,
|
|
402
|
+
* where it could clobber a concurrent legitimate save with a stale snapshot.
|
|
403
|
+
*
|
|
404
|
+
* So the replacement id is derived from the entry's position, which is already
|
|
405
|
+
* how the file orders these rows: same file in, same ids out, no I/O and no
|
|
406
|
+
* randomness. It is not derived from the secret — a public identifier should
|
|
407
|
+
* never be a function of key material.
|
|
408
|
+
*/
|
|
409
|
+
export function normalizeApiKeyIds(config: OcxConfig): OcxConfig {
|
|
410
|
+
const keys = config.apiKeys;
|
|
411
|
+
if (!keys?.length) return config;
|
|
412
|
+
// Reserve every explicit id BEFORE synthesizing any, or a synthetic
|
|
413
|
+
// `salvaged-1` assigned to row 1 would push a row that legitimately owns that
|
|
414
|
+
// id onto `salvaged-2`. An id the user already has is the one thing this
|
|
415
|
+
// repair must never take away.
|
|
416
|
+
const reserved = new Set<string>();
|
|
417
|
+
for (const entry of keys) {
|
|
418
|
+
if (entry.id) reserved.add(entry.id);
|
|
419
|
+
}
|
|
420
|
+
const taken = new Set<string>(reserved);
|
|
421
|
+
const kept = new Set<string>();
|
|
422
|
+
keys.forEach((entry, index) => {
|
|
423
|
+
// The first row holding an explicit id keeps it; later collisions are the
|
|
424
|
+
// ones that move.
|
|
425
|
+
if (entry.id && !kept.has(entry.id)) {
|
|
426
|
+
kept.add(entry.id);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
let candidate = `salvaged-${index + 1}`;
|
|
430
|
+
let suffix = 1;
|
|
431
|
+
while (taken.has(candidate)) candidate = `salvaged-${index + 1}-${++suffix}`;
|
|
432
|
+
entry.id = candidate;
|
|
433
|
+
taken.add(candidate);
|
|
434
|
+
kept.add(candidate);
|
|
435
|
+
});
|
|
436
|
+
return config;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function warnDegradedApiKeys(rawParsed: unknown, validated: OcxConfig): void {
|
|
440
|
+
if (!rawParsed || typeof rawParsed !== "object") return;
|
|
441
|
+
const raw = (rawParsed as Record<string, unknown>).apiKeys;
|
|
442
|
+
if (raw === undefined) return;
|
|
443
|
+
if (!Array.isArray(raw)) {
|
|
444
|
+
console.warn(`⚠️ config.json apiKeys is not an array — ignoring it; generate a new key from the API tab`);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const dropped = raw.length - (validated.apiKeys?.length ?? 0);
|
|
448
|
+
if (dropped > 0) {
|
|
449
|
+
console.warn(`⚠️ config.json apiKeys: skipped ${dropped} malformed entr${dropped === 1 ? "y" : "ies"} — the remaining keys still work`);
|
|
450
|
+
}
|
|
451
|
+
// Same-length repairs are invisible to the count above, and they are the ones
|
|
452
|
+
// that show up as a blank name or an unknown date in the dashboard. Say so.
|
|
453
|
+
const repaired = raw.filter(row => {
|
|
454
|
+
if (!row || typeof row !== "object") return false;
|
|
455
|
+
const entry = row as Record<string, unknown>;
|
|
456
|
+
// Must match the schema exactly: a row whose key is unusable was DROPPED, and
|
|
457
|
+
// saying "the key still works" about it would be a lie.
|
|
458
|
+
if (!isUsableApiKeySecret(entry.key)) return false;
|
|
459
|
+
return typeof entry.id !== "string" || !entry.id
|
|
460
|
+
|| typeof entry.name !== "string"
|
|
461
|
+
|| typeof entry.createdAt !== "string";
|
|
462
|
+
}).length;
|
|
463
|
+
if (repaired > 0) {
|
|
464
|
+
console.warn(`⚠️ config.json apiKeys: repaired metadata on ${repaired} entr${repaired === 1 ? "y" : "ies"} — the key still works, but its name or date may read as unknown`);
|
|
465
|
+
}
|
|
466
|
+
// A duplicate id is repaired too, and it is not visible in either count above.
|
|
467
|
+
const ids = raw.filter(row => row && typeof row === "object" && isUsableApiKeySecret((row as Record<string, unknown>).key))
|
|
468
|
+
.map(row => (row as Record<string, unknown>).id)
|
|
469
|
+
.filter((id): id is string => typeof id === "string" && !!id);
|
|
470
|
+
const duplicates = ids.length - new Set(ids).size;
|
|
471
|
+
if (duplicates > 0) {
|
|
472
|
+
console.warn(`⚠️ config.json apiKeys: ${duplicates} entr${duplicates === 1 ? "y" : "ies"} shared an id — reassigned so each key can be renamed and revoked on its own`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export const CLAUDE_SUBAGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
477
|
+
|
|
478
|
+
export function isClaudeSubagentEffort(value: unknown): value is NonNullable<OcxClaudeCodeConfig["subagentEffort"]> {
|
|
479
|
+
return typeof value === "string" && CLAUDE_SUBAGENT_EFFORTS.includes(value as typeof CLAUDE_SUBAGENT_EFFORTS[number]);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function rawClaudeSubagentEffort(rawParsed: unknown): unknown {
|
|
483
|
+
const raw = rawConfigRecord(rawParsed);
|
|
484
|
+
const claudeCode = raw?.claudeCode;
|
|
485
|
+
if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) return undefined;
|
|
486
|
+
return (claudeCode as Record<string, unknown>).subagentEffort;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCode"] {
|
|
490
|
+
if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) {
|
|
491
|
+
return claudeCode as OcxConfig["claudeCode"];
|
|
492
|
+
}
|
|
493
|
+
const normalized = { ...claudeCode } as Record<string, unknown>;
|
|
494
|
+
if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) {
|
|
495
|
+
delete normalized.subagentEffort;
|
|
496
|
+
}
|
|
497
|
+
// A hand-authored config never passes through the management validator, so coerce here too.
|
|
498
|
+
// A malformed classifierFallbacks (a bare string, or an array with non-string entries) would
|
|
499
|
+
// otherwise reach the resolver unchecked.
|
|
500
|
+
if (Object.hasOwn(normalized, "classifierModel")) {
|
|
501
|
+
const value = typeof normalized.classifierModel === "string" ? normalized.classifierModel.trim() : "";
|
|
502
|
+
if (value.length > 0) normalized.classifierModel = value;
|
|
503
|
+
else delete normalized.classifierModel;
|
|
504
|
+
}
|
|
505
|
+
if (Object.hasOwn(normalized, "classifierFallbacks")) {
|
|
506
|
+
const raw = normalized.classifierFallbacks;
|
|
507
|
+
const kept = Array.isArray(raw)
|
|
508
|
+
? raw.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map(entry => entry.trim())
|
|
509
|
+
: [];
|
|
510
|
+
if (kept.length > 0) normalized.classifierFallbacks = kept;
|
|
511
|
+
else delete normalized.classifierFallbacks;
|
|
512
|
+
}
|
|
513
|
+
const desktopProfile = normalized.desktopProfile;
|
|
514
|
+
if (desktopProfile && typeof desktopProfile === "object" && !Array.isArray(desktopProfile)) {
|
|
515
|
+
const profile = { ...desktopProfile } as Record<string, unknown>;
|
|
516
|
+
if (typeof profile.appliedFingerprint !== "string") delete profile.appliedFingerprint;
|
|
517
|
+
if (typeof profile.appliedAt !== "string") delete profile.appliedAt;
|
|
518
|
+
normalized.desktopProfile = profile;
|
|
519
|
+
}
|
|
520
|
+
return normalized as OcxConfig["claudeCode"];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function normalizeClaudeSubagentEffort(config: OcxConfig, _rawParsed: unknown): OcxConfig {
|
|
524
|
+
// Unconditional. This used to short-circuit when `subagentEffort` was absent or already valid,
|
|
525
|
+
// which meant a config whose ONLY defect was elsewhere in `claudeCode` was never normalized.
|
|
526
|
+
// The specialized subagentEffort WARNING is a separate concern and stays exactly as it is.
|
|
527
|
+
if (!config.claudeCode) return config;
|
|
528
|
+
return { ...config, claudeCode: normalizePersistedClaudeCode(config.claudeCode) };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
|
|
532
|
+
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
533
|
+
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
534
|
+
console.warn(`⚠️ config.json claudeCode.subagentEffort is invalid (expected ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}) — ignoring it. Other settings were preserved.`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function malformedUpstreamHostCircuitThresholdWarning(rawParsed: unknown): string | null {
|
|
539
|
+
const raw = rawConfigRecord(rawParsed);
|
|
540
|
+
if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
|
|
541
|
+
const threshold = raw.upstreamHostCircuitThreshold;
|
|
542
|
+
if (threshold === undefined) return null;
|
|
543
|
+
if (typeof threshold === "number"
|
|
544
|
+
&& Number.isInteger(threshold)
|
|
545
|
+
&& threshold >= 0
|
|
546
|
+
&& threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
|
|
547
|
+
return `upstreamHostCircuitThreshold ignored: expected an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function warnDegradedUpstreamHostCircuitThreshold(rawParsed: unknown): void {
|
|
551
|
+
const warning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
|
|
552
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function malformedPlaintextV2AgentMessagesWarning(value: unknown): string | null {
|
|
556
|
+
const raw = rawConfigRecord(value);
|
|
557
|
+
if (!raw || raw.plaintextV2AgentMessages === undefined || typeof raw.plaintextV2AgentMessages === "boolean") return null;
|
|
558
|
+
return "plaintextV2AgentMessages ignored: expected a boolean";
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function warnDegradedPlaintextV2AgentMessages(value: unknown): void {
|
|
562
|
+
const warning = malformedPlaintextV2AgentMessagesWarning(value);
|
|
563
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function malformedAgentTaskRecoveryWarning(rawParsed: unknown): string | null {
|
|
567
|
+
const raw = rawConfigRecord(rawParsed);
|
|
568
|
+
if (!raw || !Object.hasOwn(raw, "agentTaskRecovery")) return null;
|
|
569
|
+
const result = agentTaskRecoverySchema.safeParse(raw.agentTaskRecovery);
|
|
570
|
+
if (result.success) return null;
|
|
571
|
+
const field = result.error.issues[0]?.path.join(".");
|
|
572
|
+
return `agentTaskRecovery${field ? `.${field}` : ""} ignored: invalid experimental recovery configuration`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function warnDegradedAgentTaskRecovery(rawParsed: unknown): void {
|
|
576
|
+
const warning = malformedAgentTaskRecoveryWarning(rawParsed);
|
|
577
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function malformedRuntimeRoleWarning(rawParsed: unknown): string | null {
|
|
581
|
+
const raw = rawConfigRecord(rawParsed);
|
|
582
|
+
if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null;
|
|
583
|
+
if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null;
|
|
584
|
+
return 'runtimeRole ignored: expected "standalone", "hub", or "client"; falling back to "standalone"';
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function warnDegradedRuntimeRole(rawParsed: unknown): void {
|
|
588
|
+
const warning = malformedRuntimeRoleWarning(rawParsed);
|
|
589
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function malformedOptionalRemoteBlockWarning(
|
|
593
|
+
rawParsed: unknown,
|
|
594
|
+
key: "hub" | "remoteGui",
|
|
595
|
+
): string | null {
|
|
596
|
+
const raw = rawConfigRecord(rawParsed);
|
|
597
|
+
if (!raw || !Object.hasOwn(raw, key) || raw[key] === undefined) return null;
|
|
598
|
+
const schema = key === "hub" ? hubConfigSchema : remoteGuiConfigSchema;
|
|
599
|
+
const result = schema.safeParse(raw[key]);
|
|
600
|
+
if (result.success) return null;
|
|
601
|
+
const field = result.error.issues[0]?.path.join(".");
|
|
602
|
+
return `${key}${field ? `.${field}` : ""} ignored: invalid remote GUI configuration`;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export function malformedClientConnectionWarning(rawParsed: unknown): string | null {
|
|
606
|
+
const raw = rawConfigRecord(rawParsed);
|
|
607
|
+
if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null;
|
|
608
|
+
const result = clientConnectionSchema.safeParse(raw.client);
|
|
609
|
+
if (result.success) return null;
|
|
610
|
+
const field = result.error.issues[0]?.path.join(".");
|
|
611
|
+
return `client${field ? `.${field}` : ""} invalid: remote client mode is disabled until config.json is repaired`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function warnDegradedOptionalRemoteBlocks(rawParsed: unknown): void {
|
|
615
|
+
for (const key of ["hub", "remoteGui"] as const) {
|
|
616
|
+
const warning = malformedOptionalRemoteBlockWarning(rawParsed, key);
|
|
617
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function malformedQuotaResetNotifyWarning(rawParsed: unknown): string | null {
|
|
622
|
+
const raw = rawConfigRecord(rawParsed);
|
|
623
|
+
if (!raw || !Object.hasOwn(raw, "quotaResetNotify")) return null;
|
|
624
|
+
const result = quotaResetNotifySchema.safeParse(raw.quotaResetNotify);
|
|
625
|
+
if (result.success) return null;
|
|
626
|
+
const field = result.error.issues[0]?.path.join(".");
|
|
627
|
+
return `quotaResetNotify${field ? `.${field}` : ""} ignored: invalid quota-reset notification configuration`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export function malformedCatalogAutoRefreshWarning(rawParsed: unknown): string | null {
|
|
631
|
+
const raw = rawConfigRecord(rawParsed);
|
|
632
|
+
if (!raw || !Object.hasOwn(raw, "catalogAutoRefresh")) return null;
|
|
633
|
+
const result = catalogAutoRefreshSchema.safeParse(raw.catalogAutoRefresh);
|
|
634
|
+
if (result.success) return null;
|
|
635
|
+
const field = result.error.issues[0]?.path.join(".");
|
|
636
|
+
return `catalogAutoRefresh${field ? `.${field}` : ""} ignored: invalid catalog auto-refresh configuration`;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Same silent-in-the-wrong-direction failure as the notification block: a dropped pool policy means
|
|
641
|
+
* the accounts the operator meant to exclude keep taking traffic, and the only visible symptom is
|
|
642
|
+
* traffic going somewhere it was supposed to stop going.
|
|
643
|
+
*/
|
|
644
|
+
export function malformedCodexPoolWarning(rawParsed: unknown): string | null {
|
|
645
|
+
const raw = rawConfigRecord(rawParsed);
|
|
646
|
+
if (!raw || !Object.hasOwn(raw, "codexPool")) return null;
|
|
647
|
+
const result = codexPoolSchema.safeParse(raw.codexPool);
|
|
648
|
+
if (result.success) return null;
|
|
649
|
+
const field = result.error.issues[0]?.path.join(".");
|
|
650
|
+
return `codexPool${field ? `.${field}` : ""} ignored: invalid Codex pool selection policy`;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Warn once per load that the section was dropped.
|
|
655
|
+
*
|
|
656
|
+
* This matters more than a usual degradation notice: the failure is SILENT in the direction
|
|
657
|
+
* that hurts. A dropped section means notifications are off, so the operator sees nothing —
|
|
658
|
+
* which is exactly what they would see if the feature were working and no reset had happened.
|
|
659
|
+
*/
|
|
660
|
+
export function warnDegradedQuotaResetNotify(rawParsed: unknown): void {
|
|
661
|
+
const warning = malformedQuotaResetNotifyWarning(rawParsed);
|
|
662
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Warn once per load that the section was dropped.
|
|
667
|
+
*
|
|
668
|
+
* Same silent-in-the-wrong-direction failure as the notification block: a dropped section
|
|
669
|
+
* means the scheduler never starts, so the operator sees a stale catalog — which is exactly
|
|
670
|
+
* what they would see if the feature were working and no new models had shipped.
|
|
671
|
+
*/
|
|
672
|
+
export function warnDegradedCatalogAutoRefresh(rawParsed: unknown): void {
|
|
673
|
+
const warning = malformedCatalogAutoRefreshWarning(rawParsed);
|
|
674
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Warn once per load that the pool policy was dropped.
|
|
679
|
+
*
|
|
680
|
+
* `.catch(undefined)` turns a malformed policy into a SUCCESSFUL parse, so without this the proxy
|
|
681
|
+
* starts, rotates onto the accounts the operator meant to exclude, and prints nothing. The visible
|
|
682
|
+
* symptom would be traffic going exactly where it was told not to go.
|
|
683
|
+
*/
|
|
684
|
+
export function warnDegradedCodexPool(rawParsed: unknown): void {
|
|
685
|
+
const warning = malformedCodexPoolWarning(rawParsed);
|
|
686
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";
|
|
690
|
+
|
|
691
|
+
export function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
|
|
692
|
+
return rawParsed !== null && typeof rawParsed === "object" && !Array.isArray(rawParsed)
|
|
693
|
+
? rawParsed as Record<string, unknown>
|
|
694
|
+
: null;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export function malformedNativeSubagentFields(rawParsed: unknown): NativeSubagentPersistedField[] {
|
|
698
|
+
const raw = rawConfigRecord(rawParsed);
|
|
699
|
+
if (!raw) return [];
|
|
700
|
+
const malformed: NativeSubagentPersistedField[] = [];
|
|
701
|
+
if (Object.hasOwn(raw, "injectionModel") && typeof raw.injectionModel !== "string") {
|
|
702
|
+
malformed.push("injectionModel");
|
|
703
|
+
}
|
|
704
|
+
if (Object.hasOwn(raw, "injectionEffort") && typeof raw.injectionEffort !== "string") {
|
|
705
|
+
malformed.push("injectionEffort");
|
|
706
|
+
}
|
|
707
|
+
if (Object.hasOwn(raw, "syncCodexSubagentDefaults") && typeof raw.syncCodexSubagentDefaults !== "boolean") {
|
|
708
|
+
malformed.push("syncCodexSubagentDefaults");
|
|
709
|
+
}
|
|
710
|
+
return malformed;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField): string {
|
|
714
|
+
const expected = field === "syncCodexSubagentDefaults" ? "a boolean" : "a string";
|
|
715
|
+
return `${field} ignored: expected ${expected}`;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function malformedCodexAccountPickerWarning(rawParsed: unknown): string | null {
|
|
719
|
+
const raw = rawConfigRecord(rawParsed);
|
|
720
|
+
if (!raw || !Object.hasOwn(raw, "codexAccountPickerEnabled")) return null;
|
|
721
|
+
if (typeof raw.codexAccountPickerEnabled === "boolean") return null;
|
|
722
|
+
return "codexAccountPickerEnabled ignored: expected a boolean";
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export function warnDegradedCodexAccountPicker(rawParsed: unknown): void {
|
|
726
|
+
const warning = malformedCodexAccountPickerWarning(rawParsed);
|
|
727
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null {
|
|
731
|
+
if (config.syncCodexSubagentDefaults !== true) return null;
|
|
732
|
+
const malformed = malformedNativeSubagentFields(rawParsed);
|
|
733
|
+
if (malformed.includes("injectionModel")) return "injectionModel must be a string";
|
|
734
|
+
if (!config.injectionModel?.trim()) return "a nonblank injectionModel is required";
|
|
735
|
+
if (malformed.includes("injectionEffort")) return "injectionEffort must be a string or omitted";
|
|
736
|
+
if (config.injectionEffort !== undefined && !isCodexReasoningEffort(config.injectionEffort)) {
|
|
737
|
+
return "injectionEffort must be a supported Codex reasoning effort";
|
|
738
|
+
}
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export function normalizeNativeSubagentSync(config: OcxConfig, rawParsed?: unknown): OcxConfig {
|
|
743
|
+
if (!nativeSubagentSyncDisabledReason(config, rawParsed)) return config;
|
|
744
|
+
const normalized = { ...config };
|
|
745
|
+
delete normalized.syncCodexSubagentDefaults;
|
|
746
|
+
return normalized;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig): void {
|
|
750
|
+
for (const field of malformedNativeSubagentFields(rawParsed)) {
|
|
751
|
+
console.warn(`⚠️ config.json ${malformedNativeSubagentFieldWarning(field)}. Other settings were preserved.`);
|
|
752
|
+
}
|
|
753
|
+
const reason = nativeSubagentSyncDisabledReason(config, rawParsed);
|
|
754
|
+
if (reason) {
|
|
755
|
+
console.warn(`⚠️ config.json syncCodexSubagentDefaults was disabled: ${reason}. Other settings were preserved.`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Registry metadata can gain service-tier capability after a config was written. An explicit
|
|
761
|
+
* `fastWire: null` remains authoritative on load and on whole-document writes; rejecting either
|
|
762
|
+
* would discard or lock access to unrelated providers and API keys. Direct contradictions within
|
|
763
|
+
* one provider row remain schema errors through the outer config refinement, where the dynamic
|
|
764
|
+
* provider name can be redacted before it reaches diagnostics.
|
|
765
|
+
*/
|
|
766
|
+
export function inheritedFastWireConflictProviderNames(
|
|
767
|
+
config: Pick<OcxConfig, "providers">,
|
|
768
|
+
): string[] {
|
|
769
|
+
const conflicts: string[] = [];
|
|
770
|
+
for (const [name, provider] of Object.entries(config.providers)) {
|
|
771
|
+
if (provider.fastWire !== null || provider.supportsServiceTier === false) continue;
|
|
772
|
+
const registry = providerMatchesRegistryTransport(name, provider)
|
|
773
|
+
? getProviderRegistryEntry(name)
|
|
774
|
+
: undefined;
|
|
775
|
+
if (!registry) continue;
|
|
776
|
+
const effectiveProviderCapability = provider.supportsServiceTier ?? registry.supportsServiceTier;
|
|
777
|
+
const effectiveModelCapabilities = {
|
|
778
|
+
...(registryModelServiceTierCapabilityApplies(registry, provider)
|
|
779
|
+
? registry.modelSupportsServiceTier ?? {}
|
|
780
|
+
: {}),
|
|
781
|
+
...(provider.modelSupportsServiceTier ?? {}),
|
|
782
|
+
};
|
|
783
|
+
if (
|
|
784
|
+
effectiveProviderCapability === true
|
|
785
|
+
|| Object.values(effectiveModelCapabilities).some(value => value === true)
|
|
786
|
+
) {
|
|
787
|
+
conflicts.push(name);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return conflicts;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export function inheritedFastWireConflictWarning(name: string): string {
|
|
794
|
+
return `providers.${redactSecretString(name)}.fastWire=null overrides service-tier capability inherited from the matching registry entry`;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export function warnInheritedFastWireConflicts(configPath: string, config: OcxConfig): void {
|
|
798
|
+
const names = inheritedFastWireConflictProviderNames(config);
|
|
799
|
+
if (names.length === 0 || hasWarnedInheritedFastWireConflict(configPath)) return;
|
|
800
|
+
markWarnedInheritedFastWireConflict(configPath);
|
|
801
|
+
console.warn(
|
|
802
|
+
`⚠️ config.json ${names.map(inheritedFastWireConflictWarning).join("; ")}. `
|
|
803
|
+
+ "The persisted providers and API keys were preserved.",
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Hand-edited alias mistakes disable only the bad alias; providers and routing survive. */
|
|
808
|
+
export function sanitizeAliasesForLoad(raw: unknown): void {
|
|
809
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
810
|
+
const root = raw as Record<string, unknown>;
|
|
811
|
+
if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return;
|
|
812
|
+
const providers = root.providers as Record<string, Record<string, unknown>>;
|
|
813
|
+
const providerNames = new Set(Object.keys(providers).map(name => name.toLowerCase()));
|
|
814
|
+
const claimedProviders = new Set<string>();
|
|
815
|
+
const comboAliases = new Set(Object.values((root.combos as Record<string, { alias?: unknown }> | undefined) ?? {})
|
|
816
|
+
.map(combo => typeof combo?.alias === "string" ? combo.alias.toLowerCase() : "").filter(Boolean));
|
|
817
|
+
const accountNamespaces = new Set(Object.keys((root.codexAccountNamespaces as Record<string, unknown> | undefined) ?? {}).map(name => name.toLowerCase()));
|
|
818
|
+
for (const provider of Object.values(providers)) {
|
|
819
|
+
const alias = provider.alias;
|
|
820
|
+
if (typeof alias !== "string" || !isValidProviderName(alias)
|
|
821
|
+
|| providerNames.has(alias.toLowerCase()) || claimedProviders.has(alias.toLowerCase())
|
|
822
|
+
|| comboAliases.has(alias.toLowerCase()) || accountNamespaces.has(alias.toLowerCase())) {
|
|
823
|
+
if (alias !== undefined) console.warn("Ignoring invalid or colliding provider alias in config.json");
|
|
824
|
+
delete provider.alias;
|
|
825
|
+
} else claimedProviders.add(alias.toLowerCase());
|
|
826
|
+
if (!provider.modelAliases || typeof provider.modelAliases !== "object" || Array.isArray(provider.modelAliases)) {
|
|
827
|
+
if (provider.modelAliases !== undefined) delete provider.modelAliases;
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
const aliases = provider.modelAliases as Record<string, unknown>;
|
|
831
|
+
const nativeIds = new Set((Array.isArray(provider.models) ? provider.models : []).filter((id): id is string => typeof id === "string").map(id => id.toLowerCase()));
|
|
832
|
+
const claimed = new Set<string>();
|
|
833
|
+
for (const [id, value] of Object.entries(aliases)) {
|
|
834
|
+
const lower = typeof value === "string" ? value.toLowerCase() : "";
|
|
835
|
+
if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value) || claimed.has(lower)
|
|
836
|
+
|| nativeIds.has(lower) || comboAliases.has(lower) || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) {
|
|
837
|
+
console.warn(`Ignoring invalid or colliding model alias for ${id} in config.json`);
|
|
838
|
+
delete aliases[id];
|
|
839
|
+
} else claimed.add(lower);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Hand-edited display-name mistakes disable only the bad label. */
|
|
845
|
+
export function sanitizeModelDisplayNamesForLoad(raw: unknown): void {
|
|
846
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
847
|
+
const root = raw as Record<string, unknown>;
|
|
848
|
+
if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return;
|
|
849
|
+
for (const [providerName, providerValue] of Object.entries(root.providers as Record<string, unknown>)) {
|
|
850
|
+
if (!providerValue || typeof providerValue !== "object" || Array.isArray(providerValue)) continue;
|
|
851
|
+
const provider = providerValue as Record<string, unknown>;
|
|
852
|
+
const value = provider.modelDisplayNames;
|
|
853
|
+
if (value === undefined) continue;
|
|
854
|
+
const providerLabel = JSON.stringify(redactSecretString(providerName));
|
|
855
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
856
|
+
|| Object.entries(value).length > MODEL_DISCOVERY_MAX_MODELS) {
|
|
857
|
+
console.warn(`Ignoring invalid modelDisplayNames map for provider ${providerLabel} in config.json`);
|
|
858
|
+
delete provider.modelDisplayNames;
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
const labels = value as Record<string, unknown>;
|
|
862
|
+
for (const [modelId, rawDisplayName] of Object.entries(labels)) {
|
|
863
|
+
const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : rawDisplayName;
|
|
864
|
+
if (modelDisplayNamesConfigError({ [modelId]: displayName })) {
|
|
865
|
+
const safeModelId = JSON.stringify(redactSecretString(modelId));
|
|
866
|
+
console.warn(`Ignoring invalid modelDisplayNames entry ${safeModelId} for provider ${providerLabel} in config.json`);
|
|
867
|
+
delete labels[modelId];
|
|
868
|
+
} else {
|
|
869
|
+
labels[modelId] = displayName;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (Object.keys(labels).length === 0) delete provider.modelDisplayNames;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** Refresh the user cost-overlay registry from `config` and return it unchanged. */
|
|
877
|
+
export function withRefreshedCostOverlays(config: OcxConfig): OcxConfig {
|
|
878
|
+
refreshUserCostOverlays(config);
|
|
879
|
+
return config;
|
|
880
|
+
}
|