pi-openai-toolkit 0.14.13 → 0.15.1

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.
@@ -0,0 +1,1347 @@
1
+ /**
2
+ * Version 2 global Toolkit configuration: validation, known-field merge and per-leaf
3
+ * provenance.
4
+ *
5
+ * This module is a pure decoder. It performs no file I/O, no path resolution, no auth or
6
+ * provider lookup and no tool mutation, and it deliberately depends on no JSON Schema
7
+ * library. The shipped `config.schema.json` mirrors the accepted document, and the runtime
8
+ * checks in this file stay the authority.
9
+ *
10
+ * Document shape (`schemaVersion` must be exactly 2):
11
+ *
12
+ * ```jsonc
13
+ * {
14
+ * "schemaVersion": 2,
15
+ * "$schema": "…metadata only…",
16
+ * "defaults": { "context": …, "webSearch": …, "imageGeneration": …, "autoMode": … },
17
+ * "models": { "provider/model-id": { "context": …, "webSearch": …, "autoMode": …, "compatibility": … } },
18
+ * "diagnostics": { … }
19
+ * }
20
+ * ```
21
+ *
22
+ * Rules:
23
+ * - Precedence is built-ins -> `defaults` -> the exact override of the resolved model.
24
+ * - Only known nested objects merge by field; arrays replace; `false` and `0` stay meaningful.
25
+ * - `null` is accepted only for the optional producer/reviewer/classifier model references and
26
+ * clears an inherited reference. `null` anywhere else is a path-specific error and never
27
+ * deletes an object.
28
+ * - Unknown properties are errors with masked names. Model-key diagnostic segments are bounded;
29
+ * raw values and unknown subtrees are never copied into diagnostics.
30
+ * - `defaults.compatibility` is not a recognized scope: the standard transport is internal and
31
+ * the Codex gateway transport stays an exact-model opt-in.
32
+ * - Image output settings (`defaults.imageGeneration`) and `diagnostics` are global only.
33
+ * - Exact model keys and model references use the legacy `provider/model-id` grammar: no
34
+ * whitespace, globs or bracket patterns. Keys are trimmed like the legacy decoder, and two
35
+ * raw keys that normalize to the same key are rejected instead of resolved last-wins.
36
+ *
37
+ * Fail-closed contract for consumers: `policy` always carries the best known value for every
38
+ * leaf, so an invalid present value keeps the inherited or built-in value instead of silently
39
+ * falling through to another route. The blocking signal is `invalidFeatures`, which is scoped to
40
+ * the resolved model: a leaf error in `defaults` marks the owning feature for every model, a
41
+ * leaf error in the resolved model's override marks that feature for that model only, an error
42
+ * in a different model is reported without invalidating the current selection, and a valid exact
43
+ * override shadows an invalid `defaults` leaf. Malformed document sections and unknown
44
+ * properties inside a feature scope are feature-level errors that no override can mask.
45
+ * `origins` explains the effective value of every leaf, so an ignored invalid value never claims
46
+ * provenance it does not own.
47
+ *
48
+ * Unversioned documents belong to the legacy adapter and must not be routed here: a missing or
49
+ * non-2 `schemaVersion`, a mixed legacy/v2 root and a non-object root are fatal document errors
50
+ * that resolve the shipped defaults and mark every feature invalid.
51
+ */
52
+
53
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
54
+ import { MAX_IMAGE_MODEL_ID_CHARS } from "../image-generation/types";
55
+ import {
56
+ BREAKER_LIMIT_MAX,
57
+ BREAKER_LIMIT_MIN,
58
+ BREAKER_WINDOW_MAX,
59
+ BREAKER_WINDOW_MIN,
60
+ CLASSIFIER_MAX_LAG_MAX,
61
+ CLASSIFIER_MAX_LAG_MIN,
62
+ EVIDENCE_ROUNDS_MAX,
63
+ EVIDENCE_ROUNDS_MIN,
64
+ RESPONSES_COMPACT_CAPABLE_APIS,
65
+ REVIEWER_TIMEOUT_MAX_MS,
66
+ REVIEWER_TIMEOUT_MIN_MS,
67
+ THINKING_LEVELS,
68
+ } from "../types";
69
+ import {
70
+ CONFIG_FEATURES,
71
+ createPolicyDefaults,
72
+ type ConfigFeature,
73
+ type ConfigIssue,
74
+ type ConfigOrigin,
75
+ type EffectiveToolkitPolicy,
76
+ type ResolvedToolkitPolicy,
77
+ type SearchRoute,
78
+ } from "./policy";
79
+
80
+ /**
81
+ * Stable, controlled diagnostic codes. Adding a code is a compatible change; consumers may
82
+ * match on these strings but must not build messages from raw configuration values.
83
+ */
84
+ export const V2_ISSUE = {
85
+ rootInvalid: "document.root.invalid",
86
+ schemaVersionMissing: "document.schemaVersion.missing",
87
+ schemaVersionUnsupported: "document.schemaVersion.unsupported",
88
+ formatMixed: "document.format.mixed",
89
+ schemaInvalid: "document.$schema.invalid",
90
+ defaultsInvalid: "document.defaults.invalid",
91
+ modelsInvalid: "document.models.invalid",
92
+ diagnosticsInvalid: "document.diagnostics.invalid",
93
+ scopeInvalid: "scope.invalid",
94
+ keyUnknown: "key.unknown",
95
+ keyInvalid: "models.key.invalid",
96
+ keyDuplicate: "models.key.duplicate",
97
+ expectedBoolean: "value.expected.boolean",
98
+ expectedString: "value.expected.string",
99
+ expectedList: "value.expected.list",
100
+ expectedInteger: "value.expected.integer",
101
+ expectedEnum: "value.expected.enum",
102
+ expectedModel: "value.expected.model",
103
+ nullUnsupported: "value.null.unsupported",
104
+ range: "value.range",
105
+ length: "value.length",
106
+ empty: "value.empty",
107
+ apiUnsupported: "value.api.unsupported",
108
+ imageDefaultNotAllowed: "imageGeneration.defaultModel.notAllowed",
109
+ } as const;
110
+
111
+ const CONTEXT_MODES = ["pi", "remote-compaction", "remote-windows"] as const;
112
+ const INPUT_SOURCES = ["legacy", "pi-context-hook"] as const;
113
+ const LEAVE_MANAGED_MODES = ["warn", "compact"] as const;
114
+ const SEARCH_ROUTES = ["unmanaged", "local", "hosted", "standalone-alpha"] as const;
115
+ const AUTO_GATES = ["side-effect", "all"] as const;
116
+ const TRANSPORTS = ["standard", "codex-gateway"] as const;
117
+ const DIAGNOSTIC_LEVELS = ["error", "warn", "info", "debug"] as const;
118
+
119
+ /** v1 root sections; their presence next to `schemaVersion: 2` is a mixed-format document. */
120
+ const LEGACY_ROOT_KEYS = ["compaction", "webSearch", "imageGeneration", "autoMode"] as const;
121
+
122
+ const ROOT_KEYS = new Set(["$schema", "schemaVersion", "defaults", "models", "diagnostics"]);
123
+ const DEFAULTS_KEYS = new Set(["context", "webSearch", "imageGeneration", "autoMode"]);
124
+ const MODEL_OVERRIDE_KEYS = new Set(["context", "webSearch", "autoMode", "compatibility"]);
125
+ const CONTEXT_KEYS = new Set(["mode", "remoteCompaction", "nativeFallback", "remoteWindows"]);
126
+ const REMOTE_COMPACTION_KEYS = new Set(["model", "inputSource", "allowContinuityBreak", "apis"]);
127
+ const NATIVE_FALLBACK_KEYS = new Set(["enabled", "model", "thinkingLevel"]);
128
+ const REMOTE_WINDOWS_KEYS = new Set(["leaveManagedMode", "reminderThresholdPercent"]);
129
+ const WEB_SEARCH_KEYS = new Set(["route"]);
130
+ const IMAGE_GENERATION_KEYS = new Set(["enabled", "defaultModel", "allowedModels"]);
131
+ const AUTO_MODE_KEYS = new Set([
132
+ "available",
133
+ "reviewerModel",
134
+ "gate",
135
+ "extraTools",
136
+ "timeoutMs",
137
+ "transcript",
138
+ "evidenceTools",
139
+ "maxEvidenceRounds",
140
+ "classifier",
141
+ "circuitBreaker",
142
+ ]);
143
+ const CLASSIFIER_KEYS = new Set(["enabled", "model", "timeoutMs", "maxLag"]);
144
+ const BREAKER_KEYS = new Set(["consecutiveDenials", "recentDenials", "windowSize"]);
145
+ const COMPATIBILITY_KEYS = new Set(["transport"]);
146
+ const DIAGNOSTICS_KEYS = new Set([
147
+ "level",
148
+ "notifyOnLoad",
149
+ "captureRequests",
150
+ "captureResponses",
151
+ "redactSensitiveData",
152
+ "artifactRoot",
153
+ ]);
154
+
155
+ /** Features a `models[exactKey]` entry is able to own. */
156
+ const MODEL_OVERRIDABLE_FEATURES: readonly ConfigFeature[] = [
157
+ "context",
158
+ "webSearch",
159
+ "autoMode",
160
+ "compatibility",
161
+ ];
162
+
163
+ /**
164
+ * Feature names usable as unknown-property attribution. `compaction` is the legacy name of the
165
+ * context section, so a v2 document that still uses it fails the owning feature closed instead
166
+ * of dropping the intent.
167
+ */
168
+ const FEATURE_NAMES: ReadonlyMap<string, ConfigFeature> = new Map<string, ConfigFeature>([
169
+ ["context", "context"],
170
+ ["compaction", "context"],
171
+ ["webSearch", "webSearch"],
172
+ ["imageGeneration", "imageGeneration"],
173
+ ["autoMode", "autoMode"],
174
+ ["compatibility", "compatibility"],
175
+ ["diagnostics", "diagnostics"],
176
+ ]);
177
+
178
+ /**
179
+ * Every effective leaf of `EffectiveToolkitPolicy`. Resolution fills `origins` for exactly these
180
+ * paths, so a read-only explanation can account for every value that reaches a consumer.
181
+ */
182
+ const POLICY_LEAVES: readonly { leaf: string; feature: ConfigFeature }[] = [
183
+ { leaf: "context.mode", feature: "context" },
184
+ { leaf: "context.remoteCompaction.model", feature: "context" },
185
+ { leaf: "context.remoteCompaction.inputSource", feature: "context" },
186
+ { leaf: "context.remoteCompaction.allowContinuityBreak", feature: "context" },
187
+ { leaf: "context.remoteCompaction.apis", feature: "context" },
188
+ { leaf: "context.nativeFallback.enabled", feature: "context" },
189
+ { leaf: "context.nativeFallback.model", feature: "context" },
190
+ { leaf: "context.nativeFallback.thinkingLevel", feature: "context" },
191
+ { leaf: "context.remoteWindows.leaveManagedMode", feature: "context" },
192
+ { leaf: "context.remoteWindows.reminderThresholdPercent", feature: "context" },
193
+ { leaf: "webSearch.route", feature: "webSearch" },
194
+ { leaf: "imageGeneration.enabled", feature: "imageGeneration" },
195
+ { leaf: "imageGeneration.defaultModel", feature: "imageGeneration" },
196
+ { leaf: "imageGeneration.allowedModels", feature: "imageGeneration" },
197
+ { leaf: "autoMode.available", feature: "autoMode" },
198
+ { leaf: "autoMode.reviewerModel", feature: "autoMode" },
199
+ { leaf: "autoMode.gate", feature: "autoMode" },
200
+ { leaf: "autoMode.extraTools", feature: "autoMode" },
201
+ { leaf: "autoMode.timeoutMs", feature: "autoMode" },
202
+ { leaf: "autoMode.transcript", feature: "autoMode" },
203
+ { leaf: "autoMode.evidenceTools", feature: "autoMode" },
204
+ { leaf: "autoMode.maxEvidenceRounds", feature: "autoMode" },
205
+ { leaf: "autoMode.classifier.enabled", feature: "autoMode" },
206
+ { leaf: "autoMode.classifier.model", feature: "autoMode" },
207
+ { leaf: "autoMode.classifier.timeoutMs", feature: "autoMode" },
208
+ { leaf: "autoMode.classifier.maxLag", feature: "autoMode" },
209
+ { leaf: "autoMode.circuitBreaker.consecutiveDenials", feature: "autoMode" },
210
+ { leaf: "autoMode.circuitBreaker.recentDenials", feature: "autoMode" },
211
+ { leaf: "autoMode.circuitBreaker.windowSize", feature: "autoMode" },
212
+ { leaf: "compatibility.transport", feature: "compatibility" },
213
+ { leaf: "diagnostics.level", feature: "diagnostics" },
214
+ { leaf: "diagnostics.notifyOnLoad", feature: "diagnostics" },
215
+ { leaf: "diagnostics.captureRequests", feature: "diagnostics" },
216
+ { leaf: "diagnostics.captureResponses", feature: "diagnostics" },
217
+ { leaf: "diagnostics.redactSensitiveData", feature: "diagnostics" },
218
+ { leaf: "diagnostics.artifactRoot", feature: "diagnostics" },
219
+ ];
220
+
221
+ /** Longest unrecognized key echoed into a diagnostic path. */
222
+ const MAX_DIAGNOSTIC_SEGMENT_CHARS = 64;
223
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/g;
224
+ /** Characters that make a model key a pattern rather than an exact key. */
225
+ const MODEL_KEY_PATTERN_CHARACTERS = /[\s*?[\]{}]/;
226
+
227
+ function isRecord(value: unknown): value is Record<string, unknown> {
228
+ return !!value && typeof value === "object" && !Array.isArray(value);
229
+ }
230
+
231
+ /**
232
+ * Exact `provider/model-id` grammar shared with the legacy decoder: one separator, non-empty
233
+ * halves, no whitespace and no glob/bracket characters.
234
+ */
235
+ function isExactModelKey(value: string): boolean {
236
+ const separator = value.indexOf("/");
237
+ if (separator <= 0 || separator >= value.length - 1) return false;
238
+ return !MODEL_KEY_PATTERN_CHARACTERS.test(value) && !/[\u0000-\u001f\u007f]/.test(value);
239
+ }
240
+
241
+ /** Bound an unrecognized key before it reaches a diagnostic path; the text is never a value. */
242
+ function boundedSegment(value: string): string {
243
+ const cleaned = value.replace(CONTROL_CHARACTERS, "");
244
+ return cleaned.length > MAX_DIAGNOSTIC_SEGMENT_CHARS
245
+ ? `${cleaned.slice(0, MAX_DIAGNOSTIC_SEGMENT_CHARS)}…`
246
+ : cleaned;
247
+ }
248
+
249
+ function joinPath(prefix: string, segment: string): string {
250
+ return prefix ? `${prefix}.${segment}` : segment;
251
+ }
252
+
253
+ /** Input JSON path of a model scope, e.g. `models["openai/gpt-5.2"]`. */
254
+ function modelScopePath(key: string): string {
255
+ return `models[${JSON.stringify(key)}]`;
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------------------------
259
+ // Document scopes
260
+ // ---------------------------------------------------------------------------------------------
261
+
262
+ type ScopeKind = "document" | "defaults" | "model";
263
+
264
+ type Scope = {
265
+ /** JSON path of the scope object inside the user document; `""` is the document root. */
266
+ input: string;
267
+ kind: ScopeKind;
268
+ /** Normalized exact key when `kind` is `"model"`. */
269
+ modelKey?: string;
270
+ };
271
+
272
+ const DOCUMENT_SCOPE: Scope = { input: "", kind: "document" };
273
+
274
+ function subScope(scope: Scope, key: string): Scope {
275
+ return { ...scope, input: joinPath(scope.input, key) };
276
+ }
277
+
278
+ type FieldDescriptor = {
279
+ /** Key inside the owning JSON object. */
280
+ key: string;
281
+ /** Effective policy leaf path, matching an entry in `POLICY_LEAVES`. */
282
+ leaf: string;
283
+ feature: ConfigFeature;
284
+ };
285
+
286
+ type LeafState = {
287
+ feature: ConfigFeature;
288
+ /** A present `defaults` value for this leaf failed validation. */
289
+ invalidAtDefaults: boolean;
290
+ /** A present override of the resolved model for this leaf failed validation. */
291
+ invalidAtModel: boolean;
292
+ /** A valid override of the resolved model supplied this leaf. */
293
+ modelApplied: boolean;
294
+ };
295
+
296
+ type ResolutionState = {
297
+ policy: EffectiveToolkitPolicy;
298
+ origins: Record<string, ConfigOrigin>;
299
+ issues: ConfigIssue[];
300
+ leaves: Map<string, LeafState>;
301
+ /** Feature-level errors a valid exact override cannot mask. */
302
+ hardInvalid: Set<ConfigFeature>;
303
+ /** Fatal document error: nothing is trusted and every feature is invalid. */
304
+ untrusted: boolean;
305
+ selectedKey: string | undefined;
306
+ gatewayCandidates: Set<string>;
307
+ };
308
+
309
+ function createState(modelKey: string | undefined): ResolutionState {
310
+ const origins: Record<string, ConfigOrigin> = {};
311
+ for (const { leaf } of POLICY_LEAVES) origins[leaf] = { kind: "builtin" };
312
+ const trimmed = typeof modelKey === "string" ? modelKey.trim() : "";
313
+ return {
314
+ policy: createPolicyDefaults(),
315
+ origins,
316
+ issues: [],
317
+ leaves: new Map(),
318
+ hardInvalid: new Set(),
319
+ gatewayCandidates: new Set(),
320
+ untrusted: false,
321
+ selectedKey: trimmed.length > 0 ? trimmed : undefined,
322
+ };
323
+ }
324
+
325
+ /** `defaults` and `diagnostics` always reach the resolved policy; a model scope only matches itself. */
326
+ function scopeApplies(state: ResolutionState, scope: Scope): boolean {
327
+ if (scope.kind === "model") return scope.modelKey === state.selectedKey;
328
+ return true;
329
+ }
330
+
331
+ function leafState(state: ResolutionState, leaf: string, feature: ConfigFeature): LeafState {
332
+ let entry = state.leaves.get(leaf);
333
+ if (!entry) {
334
+ entry = { feature, invalidAtDefaults: false, invalidAtModel: false, modelApplied: false };
335
+ state.leaves.set(leaf, entry);
336
+ }
337
+ return entry;
338
+ }
339
+
340
+ function withModel(scope: Scope, issue: ConfigIssue): ConfigIssue {
341
+ return scope.modelKey === undefined ? issue : { ...issue, modelKey: scope.modelKey };
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------------------------
345
+ // Diagnostics
346
+ // ---------------------------------------------------------------------------------------------
347
+
348
+ function fatalDocumentError(state: ResolutionState, code: string, path: string): void {
349
+ state.issues.push({ severity: "error", code, path, feature: "document" });
350
+ state.untrusted = true;
351
+ }
352
+
353
+ /** A value that is present but not usable for the leaf it was written to. */
354
+ function reportLeafError(
355
+ state: ResolutionState,
356
+ scope: Scope,
357
+ field: FieldDescriptor,
358
+ code: string,
359
+ inputPath: string,
360
+ ): void {
361
+ state.issues.push(
362
+ withModel(scope, { severity: "error", code, path: inputPath, feature: field.feature }),
363
+ );
364
+ if (!scopeApplies(state, scope)) return;
365
+ const leaf = leafState(state, field.leaf, field.feature);
366
+ if (scope.kind === "model") leaf.invalidAtModel = true;
367
+ else leaf.invalidAtDefaults = true;
368
+ }
369
+
370
+ /** A malformed object scope: the feature it owns cannot be trusted, override or not. */
371
+ function reportScopeError(
372
+ state: ResolutionState,
373
+ scope: Scope,
374
+ feature: ConfigFeature | "document",
375
+ code: string,
376
+ inputPath: string,
377
+ ): void {
378
+ state.issues.push(
379
+ withModel(scope, { severity: "error", code, path: inputPath, feature }),
380
+ );
381
+ if (scopeApplies(state, scope)) {
382
+ if (feature === "document") markModelOverrideInvalid(state, scope);
383
+ else state.hardInvalid.add(feature);
384
+ }
385
+ }
386
+
387
+ function markModelOverrideInvalid(state: ResolutionState, scope: Scope): void {
388
+ if (!scopeApplies(state, scope)) return;
389
+ for (const feature of MODEL_OVERRIDABLE_FEATURES) state.hardInvalid.add(feature);
390
+ }
391
+
392
+ /**
393
+ * Unknown properties are errors in their owning scope. A key named after a feature (or its
394
+ * legacy alias) fails that feature closed; otherwise the containing feature scope fails closed;
395
+ * an unmapped key blocks its containing model scope (or the document for a root/default key).
396
+ */
397
+ function reportUnknownKeys(
398
+ container: Record<string, unknown>,
399
+ known: ReadonlySet<string>,
400
+ scope: Scope,
401
+ state: ResolutionState,
402
+ owningFeature: ConfigFeature | undefined,
403
+ ): void {
404
+ for (const key of Object.keys(container)) {
405
+ if (known.has(key)) continue;
406
+ const feature = owningFeature ?? FEATURE_NAMES.get(key) ?? "document";
407
+ state.issues.push(
408
+ withModel(scope, {
409
+ severity: "error",
410
+ code: V2_ISSUE.keyUnknown,
411
+ path: joinPath(scope.input, "[unknown-field]"),
412
+ feature,
413
+ }),
414
+ );
415
+ if (scopeApplies(state, scope)) {
416
+ if (feature !== "document") state.hardInvalid.add(feature);
417
+ else if (scope.kind === "model") markModelOverrideInvalid(state, scope);
418
+ else state.untrusted = true;
419
+ }
420
+ }
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------------------------
424
+ // Value readers
425
+ // ---------------------------------------------------------------------------------------------
426
+
427
+ type ReadResult<T> = { ok: true; value: T } | { ok: false; code: string };
428
+
429
+ function typeCode(value: unknown, expected: string): string {
430
+ return value === null ? V2_ISSUE.nullUnsupported : expected;
431
+ }
432
+
433
+ function readBoolean(value: unknown): ReadResult<boolean> {
434
+ return typeof value === "boolean"
435
+ ? { ok: true, value }
436
+ : { ok: false, code: typeCode(value, V2_ISSUE.expectedBoolean) };
437
+ }
438
+
439
+ function readEnum<T extends string>(allowed: readonly T[], value: unknown): ReadResult<T> {
440
+ if (typeof value !== "string") {
441
+ return { ok: false, code: typeCode(value, V2_ISSUE.expectedString) };
442
+ }
443
+ const normalized = value.trim();
444
+ for (const entry of allowed) {
445
+ if (entry === normalized) return { ok: true, value: entry };
446
+ }
447
+ return { ok: false, code: V2_ISSUE.expectedEnum };
448
+ }
449
+
450
+ function readInteger(value: unknown, min: number, max: number): ReadResult<number> {
451
+ if (typeof value !== "number") {
452
+ return { ok: false, code: typeCode(value, V2_ISSUE.expectedInteger) };
453
+ }
454
+ if (!Number.isInteger(value) || value < min || value > max) {
455
+ return { ok: false, code: V2_ISSUE.range };
456
+ }
457
+ return { ok: true, value };
458
+ }
459
+
460
+ function readThinkingLevel(value: unknown): ReadResult<ThinkingLevel> {
461
+ return readEnum(THINKING_LEVELS, value);
462
+ }
463
+
464
+ /** A non-empty, trimmed string such as `artifactRoot`. */
465
+ function readNonEmptyString(value: unknown, maxChars?: number): ReadResult<string> {
466
+ if (typeof value !== "string") {
467
+ return { ok: false, code: typeCode(value, V2_ISSUE.expectedString) };
468
+ }
469
+ const normalized = value.trim();
470
+ if (normalized.length === 0) return { ok: false, code: V2_ISSUE.empty };
471
+ if (maxChars !== undefined && normalized.length > maxChars) {
472
+ return { ok: false, code: V2_ISSUE.length };
473
+ }
474
+ return { ok: true, value: normalized };
475
+ }
476
+
477
+ /** Exact `provider/model-id` reference; `null` clears an inherited reference. */
478
+ function readModelRef(value: unknown): ReadResult<string | null> {
479
+ if (value === null) return { ok: true, value: null };
480
+ if (typeof value !== "string") {
481
+ return { ok: false, code: V2_ISSUE.expectedModel };
482
+ }
483
+ const normalized = value.trim();
484
+ if (normalized.length === 0) return { ok: false, code: V2_ISSUE.empty };
485
+ if (!isExactModelKey(normalized)) return { ok: false, code: V2_ISSUE.expectedModel };
486
+ return { ok: true, value: normalized };
487
+ }
488
+
489
+ /** Order-preserving string list with the legacy trim/dedupe/drop-blank normalization. */
490
+ function readStringList(
491
+ value: unknown,
492
+ options?: { maxEntryChars?: number; requireNonEmpty?: boolean },
493
+ ): ReadResult<string[]> {
494
+ if (!Array.isArray(value)) {
495
+ return { ok: false, code: typeCode(value, V2_ISSUE.expectedList) };
496
+ }
497
+ const entries: string[] = [];
498
+ for (const item of value) {
499
+ if (typeof item !== "string") {
500
+ return { ok: false, code: V2_ISSUE.expectedString };
501
+ }
502
+ const normalized = item.trim();
503
+ if (normalized.length === 0) continue;
504
+ if (options?.maxEntryChars !== undefined && normalized.length > options.maxEntryChars) {
505
+ return { ok: false, code: V2_ISSUE.length };
506
+ }
507
+ if (!entries.includes(normalized)) entries.push(normalized);
508
+ }
509
+ if (options?.requireNonEmpty && entries.length === 0) {
510
+ return { ok: false, code: V2_ISSUE.empty };
511
+ }
512
+ return { ok: true, value: entries };
513
+ }
514
+
515
+ function readImageModelList(value: unknown): ReadResult<string[]> {
516
+ return readStringList(value, {
517
+ maxEntryChars: MAX_IMAGE_MODEL_ID_CHARS,
518
+ requireNonEmpty: true,
519
+ });
520
+ }
521
+
522
+ // ---------------------------------------------------------------------------------------------
523
+ // Field application
524
+ // ---------------------------------------------------------------------------------------------
525
+
526
+ /**
527
+ * Apply one known leaf when it is present: validate, and on success write the effective value,
528
+ * record its origin and mark the leaf supplied for the resolved model. An invalid present value
529
+ * is reported and leaves the previously resolved value and origin untouched.
530
+ */
531
+ function applyField<T>(
532
+ container: Record<string, unknown>,
533
+ field: FieldDescriptor,
534
+ scope: Scope,
535
+ state: ResolutionState,
536
+ read: (value: unknown) => ReadResult<T>,
537
+ assign: (value: T) => void,
538
+ ): void {
539
+ if (!Object.hasOwn(container, field.key)) return;
540
+ const inputPath = joinPath(scope.input, field.key);
541
+ const result = read(container[field.key]);
542
+ if (!result.ok) {
543
+ reportLeafError(state, scope, field, result.code, inputPath);
544
+ return;
545
+ }
546
+ if (!scopeApplies(state, scope)) return;
547
+ assign(result.value);
548
+ state.origins[field.leaf] = {
549
+ kind: scope.kind === "defaults" ? "defaults" : "model",
550
+ path: inputPath,
551
+ };
552
+ if (scope.kind === "model") leafState(state, field.leaf, field.feature).modelApplied = true;
553
+ }
554
+
555
+ /** Open a nested known object; a present non-object is a scope error for the owning feature. */
556
+ function openGroup(
557
+ container: Record<string, unknown>,
558
+ key: string,
559
+ scope: Scope,
560
+ state: ResolutionState,
561
+ feature: ConfigFeature,
562
+ ): { raw: Record<string, unknown>; scope: Scope } | undefined {
563
+ if (!Object.hasOwn(container, key)) return undefined;
564
+ const value = container[key];
565
+ if (!isRecord(value)) {
566
+ reportScopeError(state, scope, feature, V2_ISSUE.scopeInvalid, joinPath(scope.input, key));
567
+ return undefined;
568
+ }
569
+ return { raw: value, scope: subScope(scope, key) };
570
+ }
571
+
572
+ // ---------------------------------------------------------------------------------------------
573
+ // Feature scopes
574
+ // ---------------------------------------------------------------------------------------------
575
+
576
+ function applyContextSettings(
577
+ raw: Record<string, unknown>,
578
+ scope: Scope,
579
+ state: ResolutionState,
580
+ ): void {
581
+ reportUnknownKeys(raw, CONTEXT_KEYS, scope, state, "context");
582
+
583
+ applyField(
584
+ raw,
585
+ { key: "mode", leaf: "context.mode", feature: "context" },
586
+ scope,
587
+ state,
588
+ (value) => readEnum(CONTEXT_MODES, value),
589
+ (value) => {
590
+ state.policy.context.mode = value;
591
+ },
592
+ );
593
+
594
+ const remoteCompaction = openGroup(raw, "remoteCompaction", scope, state, "context");
595
+ if (remoteCompaction) applyRemoteCompactionSettings(remoteCompaction.raw, remoteCompaction.scope, state);
596
+
597
+ const nativeFallback = openGroup(raw, "nativeFallback", scope, state, "context");
598
+ if (nativeFallback) applyNativeFallbackSettings(nativeFallback.raw, nativeFallback.scope, state);
599
+
600
+ const remoteWindows = openGroup(raw, "remoteWindows", scope, state, "context");
601
+ if (remoteWindows) applyRemoteWindowsSettings(remoteWindows.raw, remoteWindows.scope, state);
602
+ }
603
+
604
+ function applyRemoteCompactionSettings(
605
+ raw: Record<string, unknown>,
606
+ scope: Scope,
607
+ state: ResolutionState,
608
+ ): void {
609
+ reportUnknownKeys(raw, REMOTE_COMPACTION_KEYS, scope, state, "context");
610
+
611
+ applyField(
612
+ raw,
613
+ { key: "model", leaf: "context.remoteCompaction.model", feature: "context" },
614
+ scope,
615
+ state,
616
+ readModelRef,
617
+ (value) => {
618
+ state.policy.context.remoteCompaction.model = value;
619
+ },
620
+ );
621
+ applyField(
622
+ raw,
623
+ { key: "inputSource", leaf: "context.remoteCompaction.inputSource", feature: "context" },
624
+ scope,
625
+ state,
626
+ (value) => readEnum(INPUT_SOURCES, value),
627
+ (value) => {
628
+ state.policy.context.remoteCompaction.inputSource = value;
629
+ },
630
+ );
631
+ applyField(
632
+ raw,
633
+ {
634
+ key: "allowContinuityBreak",
635
+ leaf: "context.remoteCompaction.allowContinuityBreak",
636
+ feature: "context",
637
+ },
638
+ scope,
639
+ state,
640
+ readBoolean,
641
+ (value) => {
642
+ state.policy.context.remoteCompaction.allowContinuityBreak = value;
643
+ },
644
+ );
645
+ applyApisField(raw, scope, state);
646
+ }
647
+
648
+ /**
649
+ * `apis` may only narrow the Responses APIs the toolkit can build a compact URL for, like the
650
+ * legacy decoder. Unsupported entries invalidate this leaf; a malformed selected policy
651
+ * cannot silently fall back to the inherited API list.
652
+ */
653
+ function applyApisField(raw: Record<string, unknown>, scope: Scope, state: ResolutionState): void {
654
+ if (!Object.hasOwn(raw, "apis")) return;
655
+ const field: FieldDescriptor = {
656
+ key: "apis",
657
+ leaf: "context.remoteCompaction.apis",
658
+ feature: "context",
659
+ };
660
+ const inputPath = joinPath(scope.input, "apis");
661
+ const value = raw.apis;
662
+ if (!Array.isArray(value)) {
663
+ reportLeafError(state, scope, field, typeCode(value, V2_ISSUE.expectedList), inputPath);
664
+ return;
665
+ }
666
+
667
+ const accepted: string[] = [];
668
+ for (let index = 0; index < value.length; index += 1) {
669
+ const entry = value[index];
670
+ if (typeof entry !== "string") {
671
+ reportLeafError(state, scope, field, V2_ISSUE.expectedString, inputPath);
672
+ return;
673
+ }
674
+ const normalized = entry.trim();
675
+ if (normalized.length === 0) continue;
676
+ const capable = RESPONSES_COMPACT_CAPABLE_APIS as readonly string[];
677
+ if (!capable.includes(normalized)) {
678
+ reportLeafError(state, scope, field, V2_ISSUE.apiUnsupported, `${inputPath}[${index}]`);
679
+ return;
680
+ }
681
+ if (!accepted.includes(normalized)) accepted.push(normalized);
682
+ }
683
+
684
+ if (!scopeApplies(state, scope)) return;
685
+ state.policy.context.remoteCompaction.apis = accepted;
686
+ state.origins[field.leaf] = {
687
+ kind: scope.kind === "defaults" ? "defaults" : "model",
688
+ path: inputPath,
689
+ };
690
+ if (scope.kind === "model") leafState(state, field.leaf, field.feature).modelApplied = true;
691
+ }
692
+
693
+ function applyNativeFallbackSettings(
694
+ raw: Record<string, unknown>,
695
+ scope: Scope,
696
+ state: ResolutionState,
697
+ ): void {
698
+ reportUnknownKeys(raw, NATIVE_FALLBACK_KEYS, scope, state, "context");
699
+
700
+ applyField(
701
+ raw,
702
+ { key: "enabled", leaf: "context.nativeFallback.enabled", feature: "context" },
703
+ scope,
704
+ state,
705
+ readBoolean,
706
+ (value) => {
707
+ state.policy.context.nativeFallback.enabled = value;
708
+ },
709
+ );
710
+ applyField(
711
+ raw,
712
+ { key: "model", leaf: "context.nativeFallback.model", feature: "context" },
713
+ scope,
714
+ state,
715
+ readModelRef,
716
+ (value) => {
717
+ state.policy.context.nativeFallback.model = value;
718
+ },
719
+ );
720
+ applyField(
721
+ raw,
722
+ { key: "thinkingLevel", leaf: "context.nativeFallback.thinkingLevel", feature: "context" },
723
+ scope,
724
+ state,
725
+ readThinkingLevel,
726
+ (value) => {
727
+ state.policy.context.nativeFallback.thinkingLevel = value;
728
+ },
729
+ );
730
+ }
731
+
732
+ function applyRemoteWindowsSettings(
733
+ raw: Record<string, unknown>,
734
+ scope: Scope,
735
+ state: ResolutionState,
736
+ ): void {
737
+ reportUnknownKeys(raw, REMOTE_WINDOWS_KEYS, scope, state, "context");
738
+
739
+ applyField(
740
+ raw,
741
+ { key: "leaveManagedMode", leaf: "context.remoteWindows.leaveManagedMode", feature: "context" },
742
+ scope,
743
+ state,
744
+ (value) => readEnum(LEAVE_MANAGED_MODES, value),
745
+ (value) => {
746
+ state.policy.context.remoteWindows.leaveManagedMode = value;
747
+ },
748
+ );
749
+ applyField(
750
+ raw,
751
+ {
752
+ key: "reminderThresholdPercent",
753
+ leaf: "context.remoteWindows.reminderThresholdPercent",
754
+ feature: "context",
755
+ },
756
+ scope,
757
+ state,
758
+ (value) => readInteger(value, 0, 100),
759
+ (value) => {
760
+ state.policy.context.remoteWindows.reminderThresholdPercent = value;
761
+ },
762
+ );
763
+ }
764
+
765
+ function applyWebSearchSettings(
766
+ raw: Record<string, unknown>,
767
+ scope: Scope,
768
+ state: ResolutionState,
769
+ ): void {
770
+ reportUnknownKeys(raw, WEB_SEARCH_KEYS, scope, state, "webSearch");
771
+
772
+ applyField(
773
+ raw,
774
+ { key: "route", leaf: "webSearch.route", feature: "webSearch" },
775
+ scope,
776
+ state,
777
+ (value) => readEnum(SEARCH_ROUTES, value),
778
+ (value) => {
779
+ state.policy.webSearch.route = value satisfies SearchRoute;
780
+ },
781
+ );
782
+ }
783
+
784
+ /**
785
+ * Image output settings are global only. The effective default must be a member of the effective
786
+ * non-empty allowed list; a mismatch or an empty list fails the paid feature closed instead of
787
+ * selecting a different model.
788
+ */
789
+ function applyImageGenerationSettings(
790
+ raw: Record<string, unknown>,
791
+ scope: Scope,
792
+ state: ResolutionState,
793
+ ): void {
794
+ reportUnknownKeys(raw, IMAGE_GENERATION_KEYS, scope, state, "imageGeneration");
795
+
796
+ applyField(
797
+ raw,
798
+ { key: "enabled", leaf: "imageGeneration.enabled", feature: "imageGeneration" },
799
+ scope,
800
+ state,
801
+ readBoolean,
802
+ (value) => {
803
+ state.policy.imageGeneration.enabled = value;
804
+ },
805
+ );
806
+ applyField(
807
+ raw,
808
+ { key: "defaultModel", leaf: "imageGeneration.defaultModel", feature: "imageGeneration" },
809
+ scope,
810
+ state,
811
+ (value) => readNonEmptyString(value, MAX_IMAGE_MODEL_ID_CHARS),
812
+ (value) => {
813
+ state.policy.imageGeneration.defaultModel = value;
814
+ },
815
+ );
816
+ applyField(
817
+ raw,
818
+ { key: "allowedModels", leaf: "imageGeneration.allowedModels", feature: "imageGeneration" },
819
+ scope,
820
+ state,
821
+ readImageModelList,
822
+ (value) => {
823
+ state.policy.imageGeneration.allowedModels = value;
824
+ },
825
+ );
826
+
827
+ const defaultLeaf = state.leaves.get("imageGeneration.defaultModel");
828
+ const allowedLeaf = state.leaves.get("imageGeneration.allowedModels");
829
+ const unreliable = (leaf: LeafState | undefined): boolean =>
830
+ !!leaf && (leaf.invalidAtDefaults || leaf.invalidAtModel);
831
+ if (unreliable(defaultLeaf) || unreliable(allowedLeaf)) return;
832
+
833
+ const image = state.policy.imageGeneration;
834
+ if (image.allowedModels.includes(image.defaultModel)) return;
835
+ reportScopeError(
836
+ state,
837
+ scope,
838
+ "imageGeneration",
839
+ V2_ISSUE.imageDefaultNotAllowed,
840
+ joinPath(scope.input, "defaultModel"),
841
+ );
842
+ }
843
+
844
+ function applyAutoModeSettings(
845
+ raw: Record<string, unknown>,
846
+ scope: Scope,
847
+ state: ResolutionState,
848
+ ): void {
849
+ reportUnknownKeys(raw, AUTO_MODE_KEYS, scope, state, "autoMode");
850
+
851
+ applyField(
852
+ raw,
853
+ { key: "available", leaf: "autoMode.available", feature: "autoMode" },
854
+ scope,
855
+ state,
856
+ readBoolean,
857
+ (value) => {
858
+ state.policy.autoMode.available = value;
859
+ },
860
+ );
861
+ applyField(
862
+ raw,
863
+ { key: "reviewerModel", leaf: "autoMode.reviewerModel", feature: "autoMode" },
864
+ scope,
865
+ state,
866
+ readModelRef,
867
+ (value) => {
868
+ state.policy.autoMode.reviewerModel = value;
869
+ },
870
+ );
871
+ applyField(
872
+ raw,
873
+ { key: "gate", leaf: "autoMode.gate", feature: "autoMode" },
874
+ scope,
875
+ state,
876
+ (value) => readEnum(AUTO_GATES, value),
877
+ (value) => {
878
+ state.policy.autoMode.gate = value;
879
+ },
880
+ );
881
+ applyField(
882
+ raw,
883
+ { key: "extraTools", leaf: "autoMode.extraTools", feature: "autoMode" },
884
+ scope,
885
+ state,
886
+ readStringList,
887
+ (value) => {
888
+ state.policy.autoMode.extraTools = value;
889
+ },
890
+ );
891
+ applyField(
892
+ raw,
893
+ { key: "timeoutMs", leaf: "autoMode.timeoutMs", feature: "autoMode" },
894
+ scope,
895
+ state,
896
+ (value) => readInteger(value, REVIEWER_TIMEOUT_MIN_MS, REVIEWER_TIMEOUT_MAX_MS),
897
+ (value) => {
898
+ state.policy.autoMode.timeoutMs = value;
899
+ },
900
+ );
901
+ applyField(
902
+ raw,
903
+ { key: "transcript", leaf: "autoMode.transcript", feature: "autoMode" },
904
+ scope,
905
+ state,
906
+ readBoolean,
907
+ (value) => {
908
+ state.policy.autoMode.transcript = value;
909
+ },
910
+ );
911
+ applyField(
912
+ raw,
913
+ { key: "evidenceTools", leaf: "autoMode.evidenceTools", feature: "autoMode" },
914
+ scope,
915
+ state,
916
+ readBoolean,
917
+ (value) => {
918
+ state.policy.autoMode.evidenceTools = value;
919
+ },
920
+ );
921
+ applyField(
922
+ raw,
923
+ { key: "maxEvidenceRounds", leaf: "autoMode.maxEvidenceRounds", feature: "autoMode" },
924
+ scope,
925
+ state,
926
+ (value) => readInteger(value, EVIDENCE_ROUNDS_MIN, EVIDENCE_ROUNDS_MAX),
927
+ (value) => {
928
+ state.policy.autoMode.maxEvidenceRounds = value;
929
+ },
930
+ );
931
+
932
+ const classifier = openGroup(raw, "classifier", scope, state, "autoMode");
933
+ if (classifier) applyClassifierSettings(classifier.raw, classifier.scope, state);
934
+
935
+ const circuitBreaker = openGroup(raw, "circuitBreaker", scope, state, "autoMode");
936
+ if (circuitBreaker) applyCircuitBreakerSettings(circuitBreaker.raw, circuitBreaker.scope, state);
937
+ }
938
+
939
+ function applyClassifierSettings(
940
+ raw: Record<string, unknown>,
941
+ scope: Scope,
942
+ state: ResolutionState,
943
+ ): void {
944
+ reportUnknownKeys(raw, CLASSIFIER_KEYS, scope, state, "autoMode");
945
+
946
+ applyField(
947
+ raw,
948
+ { key: "enabled", leaf: "autoMode.classifier.enabled", feature: "autoMode" },
949
+ scope,
950
+ state,
951
+ readBoolean,
952
+ (value) => {
953
+ state.policy.autoMode.classifier.enabled = value;
954
+ },
955
+ );
956
+ applyField(
957
+ raw,
958
+ { key: "model", leaf: "autoMode.classifier.model", feature: "autoMode" },
959
+ scope,
960
+ state,
961
+ readModelRef,
962
+ (value) => {
963
+ state.policy.autoMode.classifier.model = value;
964
+ },
965
+ );
966
+ applyField(
967
+ raw,
968
+ { key: "timeoutMs", leaf: "autoMode.classifier.timeoutMs", feature: "autoMode" },
969
+ scope,
970
+ state,
971
+ (value) => readInteger(value, REVIEWER_TIMEOUT_MIN_MS, REVIEWER_TIMEOUT_MAX_MS),
972
+ (value) => {
973
+ state.policy.autoMode.classifier.timeoutMs = value;
974
+ },
975
+ );
976
+ applyField(
977
+ raw,
978
+ { key: "maxLag", leaf: "autoMode.classifier.maxLag", feature: "autoMode" },
979
+ scope,
980
+ state,
981
+ (value) => readInteger(value, CLASSIFIER_MAX_LAG_MIN, CLASSIFIER_MAX_LAG_MAX),
982
+ (value) => {
983
+ state.policy.autoMode.classifier.maxLag = value;
984
+ },
985
+ );
986
+ }
987
+
988
+ function applyCircuitBreakerSettings(
989
+ raw: Record<string, unknown>,
990
+ scope: Scope,
991
+ state: ResolutionState,
992
+ ): void {
993
+ reportUnknownKeys(raw, BREAKER_KEYS, scope, state, "autoMode");
994
+
995
+ applyField(
996
+ raw,
997
+ {
998
+ key: "consecutiveDenials",
999
+ leaf: "autoMode.circuitBreaker.consecutiveDenials",
1000
+ feature: "autoMode",
1001
+ },
1002
+ scope,
1003
+ state,
1004
+ (value) => readInteger(value, BREAKER_LIMIT_MIN, BREAKER_LIMIT_MAX),
1005
+ (value) => {
1006
+ state.policy.autoMode.circuitBreaker.consecutiveDenials = value;
1007
+ },
1008
+ );
1009
+ applyField(
1010
+ raw,
1011
+ { key: "recentDenials", leaf: "autoMode.circuitBreaker.recentDenials", feature: "autoMode" },
1012
+ scope,
1013
+ state,
1014
+ (value) => readInteger(value, BREAKER_LIMIT_MIN, BREAKER_LIMIT_MAX),
1015
+ (value) => {
1016
+ state.policy.autoMode.circuitBreaker.recentDenials = value;
1017
+ },
1018
+ );
1019
+ applyField(
1020
+ raw,
1021
+ { key: "windowSize", leaf: "autoMode.circuitBreaker.windowSize", feature: "autoMode" },
1022
+ scope,
1023
+ state,
1024
+ (value) => readInteger(value, BREAKER_WINDOW_MIN, BREAKER_WINDOW_MAX),
1025
+ (value) => {
1026
+ state.policy.autoMode.circuitBreaker.windowSize = value;
1027
+ },
1028
+ );
1029
+ }
1030
+
1031
+ /** Protocol compatibility is an exact-model opt-in; there is no broad defaults wildcard. */
1032
+ function applyCompatibilitySettings(
1033
+ raw: Record<string, unknown>,
1034
+ scope: Scope,
1035
+ state: ResolutionState,
1036
+ ): void {
1037
+ reportUnknownKeys(raw, COMPATIBILITY_KEYS, scope, state, "compatibility");
1038
+ if (scope.modelKey && Object.hasOwn(raw, "transport") && typeof raw.transport === "string" && raw.transport.trim() === "codex-gateway") {
1039
+ state.gatewayCandidates.add(scope.modelKey);
1040
+ }
1041
+
1042
+ applyField(
1043
+ raw,
1044
+ { key: "transport", leaf: "compatibility.transport", feature: "compatibility" },
1045
+ scope,
1046
+ state,
1047
+ (value) => readEnum(TRANSPORTS, value),
1048
+ (value) => {
1049
+ state.policy.compatibility.transport = value;
1050
+ },
1051
+ );
1052
+ }
1053
+
1054
+ function applyDiagnosticsSettings(
1055
+ raw: Record<string, unknown>,
1056
+ scope: Scope,
1057
+ state: ResolutionState,
1058
+ ): void {
1059
+ reportUnknownKeys(raw, DIAGNOSTICS_KEYS, scope, state, "diagnostics");
1060
+
1061
+ applyField(
1062
+ raw,
1063
+ { key: "level", leaf: "diagnostics.level", feature: "diagnostics" },
1064
+ scope,
1065
+ state,
1066
+ (value) => readEnum(DIAGNOSTIC_LEVELS, value),
1067
+ (value) => {
1068
+ state.policy.diagnostics.level = value;
1069
+ },
1070
+ );
1071
+ applyField(
1072
+ raw,
1073
+ { key: "notifyOnLoad", leaf: "diagnostics.notifyOnLoad", feature: "diagnostics" },
1074
+ scope,
1075
+ state,
1076
+ readBoolean,
1077
+ (value) => {
1078
+ state.policy.diagnostics.notifyOnLoad = value;
1079
+ },
1080
+ );
1081
+ applyField(
1082
+ raw,
1083
+ { key: "captureRequests", leaf: "diagnostics.captureRequests", feature: "diagnostics" },
1084
+ scope,
1085
+ state,
1086
+ readBoolean,
1087
+ (value) => {
1088
+ state.policy.diagnostics.captureRequests = value;
1089
+ },
1090
+ );
1091
+ applyField(
1092
+ raw,
1093
+ { key: "captureResponses", leaf: "diagnostics.captureResponses", feature: "diagnostics" },
1094
+ scope,
1095
+ state,
1096
+ readBoolean,
1097
+ (value) => {
1098
+ state.policy.diagnostics.captureResponses = value;
1099
+ },
1100
+ );
1101
+ applyField(
1102
+ raw,
1103
+ {
1104
+ key: "redactSensitiveData",
1105
+ leaf: "diagnostics.redactSensitiveData",
1106
+ feature: "diagnostics",
1107
+ },
1108
+ scope,
1109
+ state,
1110
+ readBoolean,
1111
+ (value) => {
1112
+ state.policy.diagnostics.redactSensitiveData = value;
1113
+ },
1114
+ );
1115
+ applyField(
1116
+ raw,
1117
+ { key: "artifactRoot", leaf: "diagnostics.artifactRoot", feature: "diagnostics" },
1118
+ scope,
1119
+ state,
1120
+ readNonEmptyString,
1121
+ (value) => {
1122
+ // Path expansion stays in the loader; the resolver records the configured value.
1123
+ state.policy.diagnostics.artifactRoot = value;
1124
+ },
1125
+ );
1126
+ }
1127
+
1128
+ /** Reusable scope appliers so `defaults.<feature>` and `models[key].<feature>` share one contract. */
1129
+ function applyContextScope(container: Record<string, unknown>, scope: Scope, state: ResolutionState): void {
1130
+ const group = openGroup(container, "context", scope, state, "context");
1131
+ if (group) applyContextSettings(group.raw, group.scope, state);
1132
+ }
1133
+
1134
+ function applyWebSearchScope(
1135
+ container: Record<string, unknown>,
1136
+ scope: Scope,
1137
+ state: ResolutionState,
1138
+ ): void {
1139
+ const group = openGroup(container, "webSearch", scope, state, "webSearch");
1140
+ if (group) applyWebSearchSettings(group.raw, group.scope, state);
1141
+ }
1142
+
1143
+ function applyImageGenerationScope(
1144
+ container: Record<string, unknown>,
1145
+ scope: Scope,
1146
+ state: ResolutionState,
1147
+ ): void {
1148
+ const group = openGroup(container, "imageGeneration", scope, state, "imageGeneration");
1149
+ if (group) applyImageGenerationSettings(group.raw, group.scope, state);
1150
+ }
1151
+
1152
+ function applyAutoModeScope(
1153
+ container: Record<string, unknown>,
1154
+ scope: Scope,
1155
+ state: ResolutionState,
1156
+ ): void {
1157
+ const group = openGroup(container, "autoMode", scope, state, "autoMode");
1158
+ if (group) applyAutoModeSettings(group.raw, group.scope, state);
1159
+ }
1160
+
1161
+ function applyCompatibilityScope(
1162
+ container: Record<string, unknown>,
1163
+ scope: Scope,
1164
+ state: ResolutionState,
1165
+ ): void {
1166
+ const group = openGroup(container, "compatibility", scope, state, "compatibility");
1167
+ if (group) applyCompatibilitySettings(group.raw, group.scope, state);
1168
+ }
1169
+
1170
+ // ---------------------------------------------------------------------------------------------
1171
+ // Document sections
1172
+ // ---------------------------------------------------------------------------------------------
1173
+
1174
+ function applyDefaults(doc: Record<string, unknown>, state: ResolutionState): void {
1175
+ if (!Object.hasOwn(doc, "defaults")) return;
1176
+ const raw = doc.defaults;
1177
+ if (!isRecord(raw)) {
1178
+ state.issues.push({
1179
+ severity: "error",
1180
+ code: V2_ISSUE.defaultsInvalid,
1181
+ path: "defaults",
1182
+ feature: "document",
1183
+ });
1184
+ state.untrusted = true;
1185
+ return;
1186
+ }
1187
+
1188
+ const scope: Scope = { input: "defaults", kind: "defaults" };
1189
+ reportUnknownKeys(raw, DEFAULTS_KEYS, scope, state, undefined);
1190
+ applyContextScope(raw, scope, state);
1191
+ applyWebSearchScope(raw, scope, state);
1192
+ applyImageGenerationScope(raw, scope, state);
1193
+ applyAutoModeScope(raw, scope, state);
1194
+ }
1195
+
1196
+ function applyModels(doc: Record<string, unknown>, state: ResolutionState): void {
1197
+ if (!Object.hasOwn(doc, "models")) return;
1198
+ const raw = doc.models;
1199
+ if (!isRecord(raw)) {
1200
+ state.issues.push({
1201
+ severity: "error",
1202
+ code: V2_ISSUE.modelsInvalid,
1203
+ path: "models",
1204
+ feature: "document",
1205
+ });
1206
+ markModelOverrideInvalid(state, { input: "models", kind: "document" });
1207
+ return;
1208
+ }
1209
+
1210
+ // Trim-normalize keys and reject collisions instead of resolving them last-wins.
1211
+ // Keep the raw key for JSON paths: normalization selects the scope, but provenance and
1212
+ // diagnostics must still point at the property the caller actually supplied.
1213
+ const normalized = new Map<string, string[]>();
1214
+ for (const rawKey of Object.keys(raw)) {
1215
+ const key = rawKey.trim();
1216
+ const existing = normalized.get(key);
1217
+ if (existing) existing.push(rawKey);
1218
+ else normalized.set(key, [rawKey]);
1219
+ }
1220
+
1221
+ for (const [key, rawKeys] of normalized) {
1222
+ const firstRawKey = rawKeys[0] as string;
1223
+ const scope: Scope = { input: modelScopePath(firstRawKey), kind: "model", modelKey: key };
1224
+ if (!isExactModelKey(key)) {
1225
+ state.issues.push({
1226
+ severity: "error",
1227
+ code: V2_ISSUE.keyInvalid,
1228
+ path: modelScopePath(boundedSegment(firstRawKey)),
1229
+ feature: "document",
1230
+ });
1231
+ if (key === state.selectedKey) markModelOverrideInvalid(state, scope);
1232
+ continue;
1233
+ }
1234
+ if (rawKeys.length > 1) {
1235
+ state.issues.push(
1236
+ withModel(scope, {
1237
+ severity: "error",
1238
+ code: V2_ISSUE.keyDuplicate,
1239
+ path: modelScopePath(boundedSegment(firstRawKey)),
1240
+ feature: "document",
1241
+ }),
1242
+ );
1243
+ if (key === state.selectedKey) markModelOverrideInvalid(state, scope);
1244
+ continue;
1245
+ }
1246
+
1247
+ const entry = Object.hasOwn(raw, firstRawKey) ? raw[firstRawKey] : undefined;
1248
+ if (!isRecord(entry)) {
1249
+ reportScopeError(state, scope, "document", V2_ISSUE.scopeInvalid, modelScopePath(boundedSegment(firstRawKey)));
1250
+ markModelOverrideInvalid(state, scope);
1251
+ continue;
1252
+ }
1253
+
1254
+ reportUnknownKeys(entry, MODEL_OVERRIDE_KEYS, scope, state, undefined);
1255
+ applyContextScope(entry, scope, state);
1256
+ applyWebSearchScope(entry, scope, state);
1257
+ applyAutoModeScope(entry, scope, state);
1258
+ applyCompatibilityScope(entry, scope, state);
1259
+ }
1260
+ }
1261
+
1262
+ function applyDiagnostics(doc: Record<string, unknown>, state: ResolutionState): void {
1263
+ if (!Object.hasOwn(doc, "diagnostics")) return;
1264
+ const raw = doc.diagnostics;
1265
+ if (!isRecord(raw)) {
1266
+ reportScopeError(
1267
+ state,
1268
+ { input: "diagnostics", kind: "document" },
1269
+ "diagnostics",
1270
+ V2_ISSUE.diagnosticsInvalid,
1271
+ "diagnostics",
1272
+ );
1273
+ return;
1274
+ }
1275
+ // Diagnostics are global, so they resolve into the inherited policy like `defaults`.
1276
+ applyDiagnosticsSettings(raw, { input: "diagnostics", kind: "defaults" }, state);
1277
+ }
1278
+
1279
+ type V2Resolution = ResolvedToolkitPolicy & { gatewayModelKeys: string[] };
1280
+
1281
+ function finish(state: ResolutionState): V2Resolution {
1282
+ const invalidFeatures = CONFIG_FEATURES.filter((feature) => {
1283
+ if (state.untrusted || state.hardInvalid.has(feature)) return true;
1284
+ for (const leaf of state.leaves.values()) {
1285
+ if (leaf.feature !== feature) continue;
1286
+ if (leaf.invalidAtModel) return true;
1287
+ if (leaf.invalidAtDefaults && !leaf.modelApplied) return true;
1288
+ }
1289
+ return false;
1290
+ });
1291
+ // All model scopes were validated in the same pass. An unrelated context/search error
1292
+ // does not change a valid transport; document/compatibility errors do.
1293
+ const blockedGateways = new Set<string>();
1294
+ let blockAllGateways = state.untrusted;
1295
+ for (const issue of state.issues) {
1296
+ if (issue.severity !== "error" || (issue.feature !== "document" && issue.feature !== "compatibility")) continue;
1297
+ if (issue.modelKey !== undefined) blockedGateways.add(issue.modelKey);
1298
+ else if (issue.code !== V2_ISSUE.keyInvalid) blockAllGateways = true;
1299
+ }
1300
+ return {
1301
+ policy: state.policy,
1302
+ origins: state.origins,
1303
+ issues: state.issues,
1304
+ invalidFeatures: [...invalidFeatures],
1305
+ gatewayModelKeys: blockAllGateways ? [] : [...state.gatewayCandidates].filter((key) => !blockedGateways.has(key)),
1306
+ };
1307
+ }
1308
+
1309
+ /**
1310
+ * Validate and resolve a version 2 document for `modelKey`.
1311
+ *
1312
+ * Pure: no file I/O, path expansion, auth lookup, provider call or tool mutation. Callers must
1313
+ * route unversioned documents through the legacy adapter instead. Consumers must treat any
1314
+ * feature listed in `invalidFeatures` as fail-closed and must never reconstruct the merge
1315
+ * themselves.
1316
+ */
1317
+ export function resolveV2Config(raw: unknown, modelKey: string | undefined): V2Resolution {
1318
+ const state = createState(modelKey);
1319
+
1320
+ if (!isRecord(raw)) {
1321
+ fatalDocumentError(state, V2_ISSUE.rootInvalid, "document");
1322
+ return finish(state);
1323
+ }
1324
+ if (!Object.hasOwn(raw, "schemaVersion")) {
1325
+ fatalDocumentError(state, V2_ISSUE.schemaVersionMissing, "schemaVersion");
1326
+ return finish(state);
1327
+ }
1328
+ if (raw.schemaVersion !== 2) {
1329
+ fatalDocumentError(state, V2_ISSUE.schemaVersionUnsupported, "schemaVersion");
1330
+ return finish(state);
1331
+ }
1332
+ if (LEGACY_ROOT_KEYS.some((key) => Object.hasOwn(raw, key))) {
1333
+ fatalDocumentError(state, V2_ISSUE.formatMixed, "document");
1334
+ return finish(state);
1335
+ }
1336
+
1337
+ if (Object.hasOwn(raw, "$schema") && typeof raw.$schema !== "string") {
1338
+ fatalDocumentError(state, V2_ISSUE.schemaInvalid, "$schema");
1339
+ }
1340
+ reportUnknownKeys(raw, ROOT_KEYS, DOCUMENT_SCOPE, state, undefined);
1341
+
1342
+ applyDefaults(raw, state);
1343
+ applyModels(raw, state);
1344
+ applyDiagnostics(raw, state);
1345
+
1346
+ return finish(state);
1347
+ }