pi-openai-toolkit 0.14.12 → 0.15.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/src/config.ts CHANGED
@@ -1,720 +1,318 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
4
+ import { getExactModelKey, type ExactModelScopeModel } from "./model-scope";
5
+ import { resolveWebSearchRoute, type WebSearchModel } from "./web-search/types";
6
+ import { CONFIG_PATH, normalizeLegacyConfig } from "./config/legacy";
7
+ import { resolveV2Config } from "./config/v2";
5
8
  import {
6
- DEFAULT_COMPACTION_CONFIG,
7
- DEFAULT_TOOLKIT_CONFIG,
8
- DEFAULT_AUTO_MODE_CONFIG,
9
- DEFAULT_IMAGE_GENERATION_CONFIG,
10
- DEFAULT_IMAGE_GENERATION_MODEL,
11
- DEFAULT_NATIVE_FALLBACK_CONFIG,
12
- DEFAULT_WEB_SEARCH_CONFIG,
13
- RESPONSES_COMPACT_CAPABLE_APIS,
14
- BREAKER_LIMIT_MAX,
15
- BREAKER_LIMIT_MIN,
16
- BREAKER_WINDOW_MAX,
17
- BREAKER_WINDOW_MIN,
18
- CLASSIFIER_MAX_LAG_MAX,
19
- CLASSIFIER_MAX_LAG_MIN,
20
- DEFAULT_CLASSIFIER_TIMEOUT_MS,
21
- EVIDENCE_ROUNDS_MAX,
22
- EVIDENCE_ROUNDS_MIN,
23
- REVIEWER_TIMEOUT_MAX_MS,
24
- REVIEWER_TIMEOUT_MIN_MS,
25
- THINKING_LEVELS,
26
- TOOLKIT_ID,
27
- type AutoModeCircuitBreakerConfig,
28
- type AutoModeClassifierConfig,
29
- type AutoModeConfig,
30
- type AutoModeGate,
31
- type CompactionConfig,
32
- type ContextManagementMode,
33
- type ImageGenerationConfig,
34
- type RemoteV2ContextSource,
35
- type LoadedToolkitConfig,
36
- type NativeFallbackConfig,
37
- type ToolkitConfig,
38
- type WebSearchConfig,
39
- type WebSearchRoute,
40
- } from "./types";
41
- import { MAX_IMAGE_MODEL_ID_CHARS } from "./image-generation/types";
42
-
43
- export const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", TOOLKIT_ID);
44
- export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
45
-
46
- const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch", "imageGeneration", "autoMode"]);
47
- const COMPACTION_FIELDS = new Set([
48
- "enabled",
49
- "contextManagement",
50
- "allowCompactionContinuityBreak",
51
- "remoteCompactModel",
52
- "remoteV2ContextSource",
53
- "nativeFallback",
54
- "responsesApis",
55
- "gatewayContextModels",
56
- "contextReminderThresholdPercent",
57
- "notifyOnLoad",
58
- "debug",
59
- "logProviderPayloads",
60
- "logCompactResponses",
61
- "redactSensitiveData",
62
- "artifactRoot",
63
- ]);
64
- const NATIVE_FALLBACK_FIELDS = new Set(["enabled", "model", "thinkingLevel"]);
65
- const WEB_SEARCH_FIELDS = new Set(["enabled", "models", "defaultRoute", "routes"]);
66
- const IMAGE_GENERATION_FIELDS = new Set(["enabled", "models"]);
67
- const AUTO_MODE_FIELDS = new Set([
68
- "enabled",
69
- "models",
70
- "reviewerModel",
71
- "gate",
72
- "extraTools",
73
- "timeoutMs",
74
- "transcript",
75
- "evidenceTools",
76
- "maxEvidenceRounds",
77
- "classifier",
78
- "circuitBreaker",
79
- ]);
80
- const AUTO_MODE_CLASSIFIER_FIELDS = new Set(["enabled", "model", "timeoutMs", "maxLag"]);
81
- const AUTO_MODE_BREAKER_FIELDS = new Set(["consecutiveDenials", "recentDenials", "windowSize"]);
9
+ CONFIG_FEATURES,
10
+ createPolicyDefaults,
11
+ type ConfigDocumentSnapshot,
12
+ type ConfigFeature,
13
+ type ConfigIssue,
14
+ type ConfigOrigin,
15
+ type EffectiveToolkitPolicy,
16
+ type ResolvedToolkitPolicy,
17
+ } from "./config/policy";
18
+ import type { LoadedToolkitConfig, ToolkitConfig } from "./types";
19
+
20
+ export { CONFIG_DIR, CONFIG_PATH } from "./config/legacy";
21
+ export { DEFAULT_TOOLKIT_CONFIG } from "./types";
22
+ export type { ConfigFeature, ConfigIssue, ConfigOrigin, EffectiveToolkitPolicy } from "./config/policy";
23
+
24
+ export type ResolvedToolkitConfig = ResolvedToolkitPolicy & {
25
+ /** Narrow adapters for existing feature engines, never raw document sections. */
26
+ config: ToolkitConfig;
27
+ format: ConfigDocumentSnapshot["format"];
28
+ source?: string;
29
+ modelKey?: string;
30
+ /** Cross-feature protocol policy, independent of context enablement. */
31
+ gatewayModelKeys: string[];
32
+ /** Same decoded document for resolving another identity within this operation. */
33
+ snapshot: LoadedToolkitConfig;
34
+ };
82
35
 
83
36
  function isRecord(value: unknown): value is Record<string, unknown> {
84
37
  return !!value && typeof value === "object" && !Array.isArray(value);
85
38
  }
86
-
87
- function isFile(filePath: string): boolean {
88
- try {
89
- return fs.statSync(filePath).isFile();
90
- } catch {
91
- return false;
92
- }
39
+ function has(value: Record<string, unknown>, key: string): boolean {
40
+ return Object.prototype.hasOwnProperty.call(value, key);
93
41
  }
94
-
95
- function readJsonObject(filePath: string, warnings: string[]): Record<string, unknown> | undefined {
96
- if (!isFile(filePath)) return undefined;
97
-
98
- try {
99
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
100
- if (isRecord(parsed)) return parsed;
101
- warnings.push(`Ignoring ${filePath}: expected a JSON object at the top level.`);
102
- return undefined;
103
- } catch (error) {
104
- const message = error instanceof Error ? error.message : String(error);
105
- warnings.push(`Ignoring ${filePath}: ${message}`);
106
- return undefined;
42
+ function freeze<T>(value: T): T {
43
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
44
+ Object.freeze(value);
45
+ for (const item of Object.values(value)) freeze(item);
107
46
  }
47
+ return value;
108
48
  }
109
-
110
- function resolveConfiguredPath(rawPath: string, baseDir: string): string {
111
- if (rawPath.startsWith("~/")) {
112
- return path.join(os.homedir(), rawPath.slice(2));
113
- }
114
- if (path.isAbsolute(rawPath)) {
115
- return path.resolve(rawPath);
116
- }
117
- return path.resolve(baseDir, rawPath);
49
+ function documentError(code: string): ConfigIssue {
50
+ return { severity: "error", code, path: "$", feature: "document" };
118
51
  }
119
52
 
120
- function warnUnknownFields(
121
- value: Record<string, unknown>,
122
- knownFields: ReadonlySet<string>,
123
- fieldPath: string,
124
- warnings: string[],
125
- ): void {
126
- for (const key of Object.keys(value)) {
127
- if (!knownFields.has(key)) {
128
- warnings.push(`Ignoring ${fieldPath ? `${fieldPath}.` : ""}${key}: unknown field.`);
53
+ /** Read exactly one global file. No project discovery, cache, writes or auth/network work. */
54
+ export function loadToolkitConfig(configPath: string = CONFIG_PATH): LoadedToolkitConfig {
55
+ let raw: unknown;
56
+ let format: ConfigDocumentSnapshot["format"] = "missing";
57
+ const issues: ConfigIssue[] = [];
58
+ const warnings: string[] = [];
59
+ try {
60
+ const text = fs.readFileSync(configPath, "utf8");
61
+ try {
62
+ raw = JSON.parse(text);
63
+ if (!isRecord(raw)) {
64
+ format = "invalid";
65
+ issues.push(documentError("expected-object"));
66
+ } else if (has(raw, "schemaVersion")) {
67
+ format = raw.schemaVersion === 2 ? "v2" : "invalid";
68
+ if (format === "invalid") issues.push(documentError("unsupported-schema-version"));
69
+ } else if (has(raw, "defaults") || has(raw, "models") || has(raw, "diagnostics")) {
70
+ format = "invalid";
71
+ issues.push(documentError("missing-schema-version"));
72
+ } else {
73
+ format = "legacy";
74
+ }
75
+ } catch {
76
+ format = "invalid";
77
+ issues.push(documentError("invalid-json"));
78
+ }
79
+ } catch (error) {
80
+ if (!isRecord(error) || error.code !== "ENOENT") {
81
+ format = "invalid";
82
+ issues.push(documentError("unreadable-config"));
129
83
  }
130
84
  }
85
+ for (const issue of issues) warnings.push(`Ignoring ${configPath}: ${issue.code}.`);
86
+ const legacy = normalizeLegacyConfig(format === "legacy" && isRecord(raw) ? raw : undefined, configPath, warnings);
87
+ return {
88
+ ...legacy,
89
+ ...(format === "v2" ? { source: configPath } : {}),
90
+ document: freeze({ format, configPath, raw, issues }),
91
+ };
131
92
  }
132
93
 
133
- function toBoolean(value: unknown, fieldPath: string, warnings: string[]): boolean | undefined {
134
- if (value === undefined) return undefined;
135
- if (typeof value === "boolean") return value;
136
- warnings.push(`Ignoring ${fieldPath}: expected a boolean.`);
137
- return undefined;
138
- }
139
-
140
- function toContextManagementMode(
141
- value: unknown,
142
- fieldPath: string,
143
- warnings: string[],
144
- ): ContextManagementMode | undefined {
145
- if (value === undefined) return undefined;
146
- if (typeof value === "string") {
147
- const normalized = value.trim();
148
- if (normalized === "off" || normalized === "remote") return normalized;
149
- }
150
- warnings.push(`Ignoring ${fieldPath}: expected one of off, remote.`);
151
- return undefined;
152
- }
153
-
154
- function toRemoteV2ContextSource(
155
- value: unknown,
156
- fieldPath: string,
157
- warnings: string[],
158
- ): RemoteV2ContextSource | undefined {
159
- if (value === undefined) return undefined;
160
- if (typeof value === "string") {
161
- const normalized = value.trim();
162
- if (normalized === "pi-context-hook" || normalized === "legacy") return normalized;
163
- }
164
- warnings.push(`Ignoring ${fieldPath}: expected one of pi-context-hook, legacy.`);
165
- return undefined;
94
+ function configuredPath(value: string, configPath: string): string {
95
+ if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
96
+ return path.resolve(path.dirname(configPath), value);
166
97
  }
167
98
 
168
- function toModelSpec(value: unknown, fieldPath: string, warnings: string[]): string | null | undefined {
169
- if (value === undefined) return undefined;
170
- if (value === null) return null;
171
- if (typeof value === "string" && value.trim().length > 0) {
172
- return value.trim();
173
- }
174
- warnings.push(`Ignoring ${fieldPath}: expected "provider/model-id" or null.`);
175
- return undefined;
99
+ function leafPaths(value: unknown, prefix = ""): string[] {
100
+ if (!isRecord(value)) return [prefix];
101
+ return Object.entries(value).flatMap(([key, child]) => leafPaths(child, prefix ? `${prefix}.${key}` : key));
176
102
  }
177
103
 
178
- function toThinkingLevel(value: unknown, fieldPath: string, warnings: string[]): ThinkingLevel | undefined {
179
- if (value === undefined) return undefined;
180
- if (typeof value === "string" && (THINKING_LEVELS as readonly string[]).includes(value)) {
181
- return value as ThinkingLevel;
182
- }
183
- warnings.push(`Ignoring ${fieldPath}: expected one of ${THINKING_LEVELS.join(", ")}.`);
184
- return undefined;
104
+ const LEGACY_PATHS: Record<string, string> = {
105
+ "context.mode": "compaction.contextManagement",
106
+ "context.remoteCompaction.model": "compaction.remoteCompactModel",
107
+ "context.remoteCompaction.inputSource": "compaction.remoteV2ContextSource",
108
+ "context.remoteCompaction.allowContinuityBreak": "compaction.allowCompactionContinuityBreak",
109
+ "context.remoteCompaction.apis": "compaction.responsesApis",
110
+ "context.nativeFallback.enabled": "compaction.nativeFallback.enabled",
111
+ "context.nativeFallback.model": "compaction.nativeFallback.model",
112
+ "context.nativeFallback.thinkingLevel": "compaction.nativeFallback.thinkingLevel",
113
+ "context.remoteWindows.leaveManagedMode": "compaction.leaveManagedMode",
114
+ "context.remoteWindows.reminderThresholdPercent": "compaction.contextReminderThresholdPercent",
115
+ "webSearch.route": "webSearch.models",
116
+ "imageGeneration.enabled": "imageGeneration.enabled",
117
+ "imageGeneration.defaultModel": "imageGeneration.models",
118
+ "imageGeneration.allowedModels": "imageGeneration.models",
119
+ "autoMode.available": "autoMode.models",
120
+ "compatibility.transport": "compaction.gatewayContextModels",
121
+ "diagnostics.level": "compaction.debug",
122
+ "diagnostics.notifyOnLoad": "compaction.notifyOnLoad",
123
+ "diagnostics.captureRequests": "compaction.logProviderPayloads",
124
+ "diagnostics.captureResponses": "compaction.logCompactResponses",
125
+ "diagnostics.redactSensitiveData": "compaction.redactSensitiveData",
126
+ "diagnostics.artifactRoot": "compaction.artifactRoot",
127
+ };
128
+ function containsPath(value: unknown, fieldPath: string): boolean {
129
+ for (const key of fieldPath.split(".")) {
130
+ if (!isRecord(value) || !has(value, key)) return false;
131
+ value = value[key];
132
+ }
133
+ return true;
185
134
  }
186
135
 
187
- function toBoundedInteger(
188
- value: unknown,
189
- fieldPath: string,
190
- warnings: string[],
191
- min: number,
192
- max: number,
193
- ): number | undefined {
194
- if (value === undefined) return undefined;
195
- if (typeof value === "number" && Number.isInteger(value) && Number.isFinite(value) && value >= min && value <= max) {
196
- return value;
197
- }
198
- warnings.push(`Ignoring ${fieldPath}: expected an integer between ${min} and ${max}.`);
199
- return undefined;
136
+ function legacyRouteKey(raw: unknown, modelKey: string | undefined): string | undefined {
137
+ if (!modelKey || !isRecord(raw) || !isRecord(raw.webSearch) || !isRecord(raw.webSearch.routes)) return undefined;
138
+ return Object.keys(raw.webSearch.routes).find((key) => key.trim() === modelKey);
200
139
  }
201
140
 
202
- function toSupportedApis(
203
- value: unknown,
204
- fieldPath: string,
205
- capableApis: readonly string[],
206
- warnings: string[],
207
- ): string[] | undefined {
208
- if (value === undefined) return undefined;
209
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
210
- warnings.push(`Ignoring ${fieldPath}: expected a string array.`);
211
- return undefined;
212
- }
213
-
214
- const capable = new Set(capableApis);
215
- const accepted: string[] = [];
216
- for (const item of new Set(value.map((entry) => entry.trim()).filter(Boolean))) {
217
- if (capable.has(item)) {
218
- accepted.push(item);
219
- } else {
220
- warnings.push(
221
- `Ignoring ${fieldPath} entry "${item}": only ${capableApis.join(", ")} are supported.`,
222
- );
141
+ function legacyIssues(loaded: LoadedToolkitConfig, modelKey: string | undefined): ConfigIssue[] {
142
+ const raw = loaded.document?.raw;
143
+ const rawWeb = isRecord(raw) && isRecord(raw.webSearch) ? raw.webSearch : undefined;
144
+ // Route keys may contain dots or colons. Never recover identity by splitting a warning.
145
+ const routeIssues: ConfigIssue[] = [];
146
+ if (isRecord(rawWeb?.routes)) {
147
+ for (const [key, value] of Object.entries(rawWeb.routes)) {
148
+ const normalized = key.trim();
149
+ const separator = normalized.indexOf("/");
150
+ const exact = separator > 0 && separator < normalized.length - 1 && !/[\s*?\[\]{}]/.test(normalized);
151
+ if (exact && typeof value === "string" && ["local", "hosted", "standalone-alpha"].includes(value)) continue;
152
+ routeIssues.push({ severity: "error", code: "invalid-legacy-value", feature: "webSearch",
153
+ path: exact ? `webSearch.routes[${JSON.stringify(normalized).slice(0, 180)}]` : "webSearch.routes.[invalid-model-key]",
154
+ modelKey: normalized });
223
155
  }
224
156
  }
225
-
226
- return accepted;
227
- }
228
-
229
- function toStringList(value: unknown, fieldPath: string, warnings: string[]): string[] | undefined {
230
- if (value === undefined) return undefined;
231
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
232
- warnings.push(`Ignoring ${fieldPath}: expected a string array.`);
233
- return undefined;
234
- }
235
-
236
- return [...new Set(value.map((entry) => entry.trim()).filter(Boolean))];
237
- }
238
-
239
- function toImageGenerationModels(value: unknown, fieldPath: string, warnings: string[]): string[] | undefined {
240
- const models = toStringList(value, fieldPath, warnings);
241
- if (models === undefined) return undefined;
242
- if (models.some((model) => model.length > MAX_IMAGE_MODEL_ID_CHARS)) {
243
- warnings.push(
244
- `Ignoring ${fieldPath}: each model id must be at most ${MAX_IMAGE_MODEL_ID_CHARS} characters.`,
245
- );
246
- return undefined;
247
- }
248
- return models;
157
+ const issues = loaded.warnings.filter((warning) => !warning.startsWith("Ignoring webSearch.routes.")).map((warning): ConfigIssue => {
158
+ const match = /^Ignoring ([^:]+):/.exec(warning);
159
+ const field = warning.startsWith("Ignoring compaction.responsesApis entry ") ? "compaction.responsesApis" : match?.[1] ?? "$";
160
+ const root = field.split(".")[0];
161
+ const diagnostic = Object.entries(LEGACY_PATHS).some(([leaf, input]) => leaf.startsWith("diagnostics.") && input === field);
162
+ const feature: ConfigFeature | "document" = diagnostic ? "diagnostics"
163
+ : field === "compaction.gatewayContextModels" ? "compatibility" : root === "compaction" ? "context"
164
+ : root === "webSearch" || root === "imageGeneration" || root === "autoMode" ? root : "document";
165
+ const supersededDefault = field === "webSearch.defaultRoute" && legacyRouteKey(raw, modelKey) !== undefined;
166
+ const warningOnly = warning.endsWith("unknown field.") || !match
167
+ || warning.includes("expected at least one model id") || supersededDefault;
168
+ return {
169
+ severity: warningOnly ? "warning" : "error",
170
+ code: warning.endsWith("unknown field.") ? "unknown-legacy-field" : warningOnly ? "legacy-normalization" : "invalid-legacy-value",
171
+ path: warning.endsWith("unknown field.") ? `${feature}.[unknown-field]` : field.slice(0, 220),
172
+ feature,
173
+ };
174
+ });
175
+ return [...issues, ...routeIssues];
249
176
  }
250
177
 
251
- function toWebSearchRoute(
252
- value: unknown,
253
- fieldPath: string,
254
- warnings: string[],
255
- ): WebSearchRoute | undefined {
256
- if (value === undefined) return undefined;
257
- if (value === "local" || value === "hosted" || value === "standalone-alpha") {
258
- return value;
178
+ function selectedInvalidFeatures(issues: ConfigIssue[], modelKey?: string): ConfigFeature[] {
179
+ const invalid = new Set<ConfigFeature>();
180
+ for (const issue of issues) {
181
+ if (issue.severity !== "error" || (issue.modelKey !== undefined && issue.modelKey !== modelKey)) continue;
182
+ if (issue.feature === "document") for (const feature of CONFIG_FEATURES) invalid.add(feature);
183
+ else invalid.add(issue.feature);
259
184
  }
260
- warnings.push(`Ignoring ${fieldPath}: expected one of local, hosted, standalone-alpha.`);
261
- return undefined;
185
+ return [...invalid];
262
186
  }
263
187
 
264
- function isExactWebSearchModelKey(value: string): boolean {
265
- const trimmed = value.trim();
266
- const separatorIndex = trimmed.indexOf("/");
267
- if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) return false;
268
- if (/[\s*?\[\]{}]/.test(trimmed)) return false;
269
- const provider = trimmed.slice(0, separatorIndex);
270
- const modelId = trimmed.slice(separatorIndex + 1);
271
- return provider.length > 0 && modelId.length > 0;
272
- }
273
-
274
- function toWebSearchRoutes(
275
- value: unknown,
276
- fieldPath: string,
277
- warnings: string[],
278
- ): Record<string, WebSearchRoute> | undefined {
279
- if (value === undefined) return undefined;
280
- if (!isRecord(value)) {
281
- warnings.push(`Ignoring ${fieldPath}: expected a JSON object.`);
282
- return undefined;
283
- }
284
-
285
- const routes: Record<string, WebSearchRoute> = {};
286
- for (const [rawKey, rawRoute] of Object.entries(value)) {
287
- const key = rawKey.trim();
288
- if (!isExactWebSearchModelKey(key)) {
289
- warnings.push(`Ignoring ${fieldPath}.${rawKey}: expected an exact "provider/model-id" key.`);
290
- continue;
188
+ function fromLegacy(loaded: LoadedToolkitConfig, model: WebSearchModel | undefined): ResolvedToolkitPolicy {
189
+ const config = loaded.config;
190
+ const compaction = config.compaction;
191
+ const modelKey = getExactModelKey(model);
192
+ const route = resolveWebSearchRoute({ model, config: config.webSearch });
193
+ const policy = createPolicyDefaults();
194
+ policy.context = {
195
+ mode: !compaction.enabled ? "pi" : compaction.contextManagement === "remote" ? "remote-windows" : "remote-compaction",
196
+ remoteCompaction: {
197
+ model: compaction.remoteCompactModel ?? null,
198
+ inputSource: compaction.remoteV2ContextSource,
199
+ allowContinuityBreak: compaction.allowCompactionContinuityBreak,
200
+ apis: [...compaction.responsesApis],
201
+ },
202
+ nativeFallback: { ...compaction.nativeFallback, model: compaction.nativeFallback.model ?? null },
203
+ remoteWindows: { leaveManagedMode: compaction.leaveManagedMode, reminderThresholdPercent: compaction.contextReminderThresholdPercent },
204
+ };
205
+ policy.webSearch.route = route.route === "none" ? "unmanaged" : route.route;
206
+ policy.imageGeneration = {
207
+ enabled: config.imageGeneration.enabled,
208
+ defaultModel: config.imageGeneration.models[0],
209
+ allowedModels: [...config.imageGeneration.models],
210
+ };
211
+ const { enabled, models, reviewerModel, classifier, ...auto } = config.autoMode;
212
+ policy.autoMode = {
213
+ ...structuredClone(auto),
214
+ available: enabled && modelKey !== undefined && models.includes(modelKey),
215
+ reviewerModel: reviewerModel ?? null,
216
+ classifier: { ...classifier, model: classifier.model ?? null },
217
+ };
218
+ policy.compatibility.transport = modelKey && compaction.gatewayContextModels.includes(modelKey) ? "codex-gateway" : "standard";
219
+ policy.diagnostics = {
220
+ level: compaction.debug ? "debug" : "info",
221
+ notifyOnLoad: compaction.notifyOnLoad,
222
+ captureRequests: compaction.logProviderPayloads,
223
+ captureResponses: compaction.logCompactResponses,
224
+ redactSensitiveData: compaction.redactSensitiveData,
225
+ artifactRoot: compaction.artifactRoot,
226
+ };
227
+ const origins: Record<string, ConfigOrigin> = Object.create(null);
228
+ const raw = loaded.document?.raw;
229
+ for (const leaf of leafPaths(policy)) {
230
+ let input = LEGACY_PATHS[leaf] ?? leaf;
231
+ if (leaf === "context.mode" && !compaction.enabled) input = "compaction.enabled";
232
+ if (leaf === "autoMode.available" && !enabled) input = "autoMode.enabled";
233
+ if (leaf === "webSearch.route") {
234
+ if (!config.webSearch.enabled) input = "webSearch.enabled";
235
+ else if (route.source === "exact") {
236
+ const rawKey = legacyRouteKey(raw, modelKey);
237
+ if (rawKey !== undefined) {
238
+ origins[leaf] = { kind: "legacy", path: `webSearch.routes[${JSON.stringify(rawKey)}]`, source: loaded.source };
239
+ continue;
240
+ }
241
+ }
242
+ else if (route.source === "default") input = "webSearch.defaultRoute";
291
243
  }
292
- const route = toWebSearchRoute(rawRoute, `${fieldPath}.${rawKey}`, warnings);
293
- if (route !== undefined) routes[key] = route;
244
+ const ignored = loaded.warnings.some((warning) => warning.startsWith(`Ignoring ${input}:`));
245
+ origins[leaf] = !ignored && ((raw && containsPath(raw, input)) || (!loaded.document && loaded.source))
246
+ ? { kind: "legacy", path: input, source: loaded.source } : { kind: "builtin" };
294
247
  }
295
- return routes;
248
+ const issues = [...(loaded.document?.issues ?? []), ...legacyIssues(loaded, modelKey)];
249
+ return { policy, origins, issues, invalidFeatures: selectedInvalidFeatures(issues, modelKey) };
296
250
  }
297
251
 
298
- function toAutoModeGate(value: unknown, fieldPath: string, warnings: string[]): AutoModeGate | undefined {
299
- if (value === undefined) return undefined;
300
- if (value === "side-effect" || value === "all") return value;
301
- warnings.push(`Ignoring ${fieldPath}: expected one of side-effect, all.`);
302
- return undefined;
303
- }
304
-
305
- function toReviewerTimeoutMs(value: unknown, fieldPath: string, warnings: string[]): number | undefined {
306
- if (value === undefined) return undefined;
307
- if (
308
- typeof value === "number" &&
309
- Number.isInteger(value) &&
310
- value >= REVIEWER_TIMEOUT_MIN_MS &&
311
- value <= REVIEWER_TIMEOUT_MAX_MS
312
- ) {
313
- return value;
314
- }
315
- warnings.push(
316
- `Ignoring ${fieldPath}: expected an integer between ${REVIEWER_TIMEOUT_MIN_MS} and ${REVIEWER_TIMEOUT_MAX_MS} ms.`,
317
- );
318
- return undefined;
319
- }
320
-
321
- function cloneDefaults(): ToolkitConfig {
252
+ /** Internal engine adapter. All interpretation of document names stays in this module. */
253
+ function engineConfig(policy: EffectiveToolkitPolicy, modelKey: string | undefined, gatewayKeys: string[]): ToolkitConfig {
254
+ const { context, diagnostics, imageGeneration, autoMode } = policy;
255
+ const { available, reviewerModel, classifier, ...auto } = autoMode;
322
256
  return {
323
257
  compaction: {
324
- ...DEFAULT_COMPACTION_CONFIG,
325
- nativeFallback: { ...DEFAULT_NATIVE_FALLBACK_CONFIG },
326
- responsesApis: [...DEFAULT_COMPACTION_CONFIG.responsesApis],
327
- },
328
- webSearch: {
329
- ...DEFAULT_WEB_SEARCH_CONFIG,
330
- models: [...DEFAULT_WEB_SEARCH_CONFIG.models],
331
- ...(DEFAULT_WEB_SEARCH_CONFIG.defaultRoute
332
- ? { defaultRoute: DEFAULT_WEB_SEARCH_CONFIG.defaultRoute }
333
- : {}),
334
- ...(DEFAULT_WEB_SEARCH_CONFIG.routes
335
- ? { routes: { ...DEFAULT_WEB_SEARCH_CONFIG.routes } }
336
- : {}),
337
- },
338
- imageGeneration: {
339
- ...DEFAULT_IMAGE_GENERATION_CONFIG,
340
- models: [...DEFAULT_IMAGE_GENERATION_CONFIG.models],
258
+ enabled: context.mode !== "pi",
259
+ contextManagement: context.mode === "remote-windows" ? "remote" : "off",
260
+ remoteCompactModel: context.remoteCompaction.model ?? undefined,
261
+ remoteV2ContextSource: context.remoteCompaction.inputSource,
262
+ allowCompactionContinuityBreak: context.remoteCompaction.allowContinuityBreak,
263
+ responsesApis: [...context.remoteCompaction.apis],
264
+ gatewayContextModels: [...gatewayKeys],
265
+ nativeFallback: { ...context.nativeFallback, model: context.nativeFallback.model ?? undefined },
266
+ leaveManagedMode: context.remoteWindows.leaveManagedMode,
267
+ contextReminderThresholdPercent: context.remoteWindows.reminderThresholdPercent,
268
+ debug: diagnostics.level === "debug",
269
+ notifyOnLoad: diagnostics.notifyOnLoad,
270
+ logProviderPayloads: diagnostics.captureRequests,
271
+ logCompactResponses: diagnostics.captureResponses,
272
+ redactSensitiveData: diagnostics.redactSensitiveData,
273
+ artifactRoot: diagnostics.artifactRoot,
341
274
  },
275
+ webSearch: policy.webSearch.route === "unmanaged" ? { enabled: false, models: [] }
276
+ : { enabled: true, models: [], defaultRoute: policy.webSearch.route },
277
+ imageGeneration: { enabled: imageGeneration.enabled, models: [...imageGeneration.allowedModels] },
342
278
  autoMode: {
343
- ...DEFAULT_AUTO_MODE_CONFIG,
344
- models: [...DEFAULT_AUTO_MODE_CONFIG.models],
345
- extraTools: [...DEFAULT_AUTO_MODE_CONFIG.extraTools],
346
- classifier: { ...DEFAULT_AUTO_MODE_CONFIG.classifier },
347
- circuitBreaker: { ...DEFAULT_AUTO_MODE_CONFIG.circuitBreaker },
279
+ ...structuredClone(auto), enabled: available, models: available && modelKey ? [modelKey] : [],
280
+ reviewerModel: reviewerModel ?? undefined, classifier: { ...classifier, model: classifier.model ?? undefined },
348
281
  },
349
282
  };
350
283
  }
351
284
 
352
- function applyNativeFallbackConfig(
353
- raw: Record<string, unknown>,
354
- resolved: NativeFallbackConfig,
355
- warnings: string[],
356
- ): void {
357
- warnUnknownFields(raw, NATIVE_FALLBACK_FIELDS, "compaction.nativeFallback", warnings);
358
- resolved.enabled =
359
- toBoolean(raw.enabled, "compaction.nativeFallback.enabled", warnings) ?? resolved.enabled;
360
-
361
- const modelSpec = toModelSpec(raw.model, "compaction.nativeFallback.model", warnings);
362
- if (modelSpec !== undefined) {
363
- resolved.model = modelSpec === null ? undefined : modelSpec;
364
- }
365
-
366
- resolved.thinkingLevel =
367
- toThinkingLevel(raw.thinkingLevel, "compaction.nativeFallback.thinkingLevel", warnings) ??
368
- resolved.thinkingLevel;
369
- }
370
-
371
- function applyCompactionConfig(
372
- raw: Record<string, unknown>,
373
- resolved: CompactionConfig,
374
- warnings: string[],
375
- ): void {
376
- warnUnknownFields(raw, COMPACTION_FIELDS, "compaction", warnings);
377
-
378
- resolved.enabled = toBoolean(raw.enabled, "compaction.enabled", warnings) ?? resolved.enabled;
379
- resolved.contextManagement =
380
- toContextManagementMode(raw.contextManagement, "compaction.contextManagement", warnings) ??
381
- resolved.contextManagement;
382
- resolved.allowCompactionContinuityBreak =
383
- toBoolean(
384
- raw.allowCompactionContinuityBreak,
385
- "compaction.allowCompactionContinuityBreak",
386
- warnings,
387
- ) ?? resolved.allowCompactionContinuityBreak;
388
- resolved.notifyOnLoad =
389
- toBoolean(raw.notifyOnLoad, "compaction.notifyOnLoad", warnings) ?? resolved.notifyOnLoad;
390
- const reminderPercent = toBoundedInteger(
391
- raw.contextReminderThresholdPercent,
392
- "compaction.contextReminderThresholdPercent",
393
- warnings,
394
- 0,
395
- 100,
396
- );
397
- if (reminderPercent !== undefined) {
398
- resolved.contextReminderThresholdPercent = reminderPercent;
399
- }
400
- resolved.debug = toBoolean(raw.debug, "compaction.debug", warnings) ?? resolved.debug;
401
- resolved.logProviderPayloads =
402
- toBoolean(raw.logProviderPayloads, "compaction.logProviderPayloads", warnings) ??
403
- resolved.logProviderPayloads;
404
- resolved.logCompactResponses =
405
- toBoolean(raw.logCompactResponses, "compaction.logCompactResponses", warnings) ??
406
- resolved.logCompactResponses;
407
- resolved.redactSensitiveData =
408
- toBoolean(raw.redactSensitiveData, "compaction.redactSensitiveData", warnings) ??
409
- resolved.redactSensitiveData;
410
-
411
- const remoteCompactModelSpec = toModelSpec(
412
- raw.remoteCompactModel,
413
- "compaction.remoteCompactModel",
414
- warnings,
415
- );
416
- if (remoteCompactModelSpec !== undefined) {
417
- resolved.remoteCompactModel = remoteCompactModelSpec === null ? undefined : remoteCompactModelSpec;
418
- }
419
-
420
- resolved.remoteV2ContextSource =
421
- toRemoteV2ContextSource(raw.remoteV2ContextSource, "compaction.remoteV2ContextSource", warnings) ??
422
- resolved.remoteV2ContextSource;
423
-
424
- if (raw.nativeFallback !== undefined) {
425
- if (isRecord(raw.nativeFallback)) {
426
- applyNativeFallbackConfig(raw.nativeFallback, resolved.nativeFallback, warnings);
427
- } else {
428
- warnings.push("Ignoring compaction.nativeFallback: expected a JSON object.");
429
- }
430
- }
431
-
432
- const apis = toSupportedApis(
433
- raw.responsesApis,
434
- "compaction.responsesApis",
435
- RESPONSES_COMPACT_CAPABLE_APIS,
436
- warnings,
437
- );
438
- if (apis !== undefined) {
439
- resolved.responsesApis = apis;
440
- }
441
-
442
- const gatewayContextModels = toStringList(
443
- raw.gatewayContextModels,
444
- "compaction.gatewayContextModels",
445
- warnings,
446
- );
447
- if (gatewayContextModels !== undefined) {
448
- resolved.gatewayContextModels = gatewayContextModels;
449
- }
450
-
451
- if (typeof raw.artifactRoot === "string" && raw.artifactRoot.trim().length > 0) {
452
- resolved.artifactRoot = raw.artifactRoot.trim();
453
- } else if (raw.artifactRoot !== undefined) {
454
- warnings.push("Ignoring compaction.artifactRoot: expected a non-empty string.");
455
- }
456
- }
457
-
458
- function applyWebSearchConfig(
459
- raw: Record<string, unknown>,
460
- resolved: WebSearchConfig,
461
- warnings: string[],
462
- ): void {
463
- warnUnknownFields(raw, WEB_SEARCH_FIELDS, "webSearch", warnings);
464
- resolved.enabled = toBoolean(raw.enabled, "webSearch.enabled", warnings) ?? resolved.enabled;
465
-
466
- const models = toStringList(raw.models, "webSearch.models", warnings);
467
- if (models !== undefined) {
468
- resolved.models = models;
469
- }
470
-
471
- const defaultRoute = toWebSearchRoute(raw.defaultRoute, "webSearch.defaultRoute", warnings);
472
- if (defaultRoute !== undefined) {
473
- resolved.defaultRoute = defaultRoute;
474
- }
475
-
476
- const routes = toWebSearchRoutes(raw.routes, "webSearch.routes", warnings);
477
- if (routes !== undefined) {
478
- resolved.routes = routes;
479
- }
480
-
481
- if (defaultRoute !== undefined && resolved.models.length > 0) {
482
- warnings.push(
483
- "webSearch.defaultRoute overrides legacy webSearch.models for models without an exact webSearch.routes entry.",
484
- );
485
- }
486
- if (routes !== undefined) {
487
- for (const key of Object.keys(routes)) {
488
- if (resolved.models.includes(key)) {
489
- warnings.push(
490
- `webSearch.routes.${key} overrides the legacy webSearch.models entry for the same exact model.`,
491
- );
492
- }
493
- }
494
- }
495
- }
496
-
497
- function applyImageGenerationConfig(
498
- raw: Record<string, unknown>,
499
- resolved: ImageGenerationConfig,
500
- warnings: string[],
501
- ): void {
502
- warnUnknownFields(raw, IMAGE_GENERATION_FIELDS, "imageGeneration", warnings);
503
- resolved.enabled =
504
- toBoolean(raw.enabled, "imageGeneration.enabled", warnings) ?? resolved.enabled;
505
-
506
- const models = toImageGenerationModels(raw.models, "imageGeneration.models", warnings);
507
- if (models !== undefined) {
508
- // An empty or blank-only list cannot express a default, so keep the shipped model.
509
- if (models.length === 0) {
510
- warnings.push(
511
- `Ignoring imageGeneration.models: expected at least one model id; using ${DEFAULT_IMAGE_GENERATION_MODEL}.`,
512
- );
513
- } else {
514
- resolved.models = models;
515
- }
516
- }
517
- }
518
-
519
- function applyAutoModeConfig(
520
- raw: Record<string, unknown>,
521
- resolved: AutoModeConfig,
522
- warnings: string[],
523
- ): void {
524
- warnUnknownFields(raw, AUTO_MODE_FIELDS, "autoMode", warnings);
525
- resolved.enabled = toBoolean(raw.enabled, "autoMode.enabled", warnings) ?? resolved.enabled;
526
-
527
- const models = toStringList(raw.models, "autoMode.models", warnings);
528
- if (models !== undefined) {
529
- resolved.models = models;
530
- }
531
-
532
- const reviewerModel = toModelSpec(raw.reviewerModel, "autoMode.reviewerModel", warnings);
533
- if (reviewerModel !== undefined) {
534
- resolved.reviewerModel = reviewerModel === null ? undefined : reviewerModel;
535
- }
536
-
537
- resolved.gate = toAutoModeGate(raw.gate, "autoMode.gate", warnings) ?? resolved.gate;
538
-
539
- const extraTools = toStringList(raw.extraTools, "autoMode.extraTools", warnings);
540
- if (extraTools !== undefined) {
541
- resolved.extraTools = extraTools;
542
- }
543
-
544
- const timeoutMs = toReviewerTimeoutMs(raw.timeoutMs, "autoMode.timeoutMs", warnings);
545
- if (timeoutMs !== undefined) {
546
- resolved.timeoutMs = timeoutMs;
547
- }
548
-
549
- resolved.transcript = toBoolean(raw.transcript, "autoMode.transcript", warnings) ?? resolved.transcript;
550
- resolved.evidenceTools =
551
- toBoolean(raw.evidenceTools, "autoMode.evidenceTools", warnings) ?? resolved.evidenceTools;
552
-
553
- const maxEvidenceRounds = toBoundedInteger(
554
- raw.maxEvidenceRounds,
555
- "autoMode.maxEvidenceRounds",
556
- warnings,
557
- EVIDENCE_ROUNDS_MIN,
558
- EVIDENCE_ROUNDS_MAX,
559
- );
560
- if (maxEvidenceRounds !== undefined) {
561
- resolved.maxEvidenceRounds = maxEvidenceRounds;
562
- }
563
-
564
- if (raw.classifier !== undefined) {
565
- if (isRecord(raw.classifier)) {
566
- applyAutoModeClassifierConfig(raw.classifier, resolved.classifier, warnings);
567
- } else {
568
- warnings.push("Ignoring autoMode.classifier: expected a JSON object.");
569
- }
570
- }
571
-
572
- if (raw.circuitBreaker !== undefined) {
573
- if (isRecord(raw.circuitBreaker)) {
574
- applyAutoModeBreakerConfig(raw.circuitBreaker, resolved.circuitBreaker, warnings);
575
- } else {
576
- warnings.push("Ignoring autoMode.circuitBreaker: expected a JSON object.");
577
- }
578
- }
579
- }
580
-
581
- function applyAutoModeClassifierConfig(
582
- raw: Record<string, unknown>,
583
- resolved: AutoModeClassifierConfig,
584
- warnings: string[],
585
- ): void {
586
- warnUnknownFields(raw, AUTO_MODE_CLASSIFIER_FIELDS, "autoMode.classifier", warnings);
587
- resolved.enabled =
588
- toBoolean(raw.enabled, "autoMode.classifier.enabled", warnings) ?? resolved.enabled;
589
-
590
- const model = toModelSpec(raw.model, "autoMode.classifier.model", warnings);
591
- if (model !== undefined) {
592
- resolved.model = model === null ? undefined : model;
593
- }
594
-
595
- const timeoutMs =
596
- toBoundedInteger(
597
- raw.timeoutMs,
598
- "autoMode.classifier.timeoutMs",
599
- warnings,
600
- REVIEWER_TIMEOUT_MIN_MS,
601
- REVIEWER_TIMEOUT_MAX_MS,
602
- ) ?? DEFAULT_CLASSIFIER_TIMEOUT_MS;
603
- if (raw.timeoutMs !== undefined) {
604
- resolved.timeoutMs = timeoutMs;
605
- }
606
-
607
- const maxLag = toBoundedInteger(
608
- raw.maxLag,
609
- "autoMode.classifier.maxLag",
610
- warnings,
611
- CLASSIFIER_MAX_LAG_MIN,
612
- CLASSIFIER_MAX_LAG_MAX,
613
- );
614
- if (maxLag !== undefined) {
615
- resolved.maxLag = maxLag;
616
- }
285
+ /** Resolve one immutable snapshot for one model. Never reads disk or a model registry. */
286
+ export function resolveToolkitConfig(loaded: LoadedToolkitConfig, model?: ExactModelScopeModel & { api?: string }): ResolvedToolkitConfig {
287
+ // Injected readers are also decoded at the module seam; they may omit optional feature sections.
288
+ if (!loaded.document) {
289
+ const configPath = loaded.source ?? CONFIG_PATH;
290
+ const normalized = normalizeLegacyConfig(loaded.config, configPath);
291
+ loaded = { ...normalized, source: loaded.source, warnings: [...loaded.warnings, ...normalized.warnings],
292
+ document: { format: "legacy", configPath, raw: loaded.config, issues: [] } };
293
+ }
294
+ const modelKey = getExactModelKey(model);
295
+ const format = loaded.document?.format ?? "legacy";
296
+ const v2 = format === "v2" ? resolveV2Config(loaded.document?.raw, modelKey) : undefined;
297
+ const resolution = v2 ?? fromLegacy(loaded, model);
298
+ const source = loaded.source;
299
+ if (format === "v2") {
300
+ resolution.policy.diagnostics.artifactRoot = configuredPath(resolution.policy.diagnostics.artifactRoot, loaded.document?.configPath ?? CONFIG_PATH);
301
+ for (const origin of Object.values(resolution.origins)) if (origin.kind !== "builtin") origin.source = source;
302
+ }
303
+ const gatewayModelKeys = v2 ? v2.gatewayModelKeys : [...loaded.config.compaction.gatewayContextModels];
304
+ const config = format === "v2" ? engineConfig(resolution.policy, modelKey, gatewayModelKeys) : structuredClone(loaded.config);
305
+ return freeze({ ...resolution, config, format, source, modelKey, gatewayModelKeys, snapshot: structuredClone(loaded) });
617
306
  }
618
307
 
619
- function applyAutoModeBreakerConfig(
620
- raw: Record<string, unknown>,
621
- resolved: AutoModeCircuitBreakerConfig,
622
- warnings: string[],
623
- ): void {
624
- warnUnknownFields(raw, AUTO_MODE_BREAKER_FIELDS, "autoMode.circuitBreaker", warnings);
625
-
626
- const consecutiveDenials = toBoundedInteger(
627
- raw.consecutiveDenials,
628
- "autoMode.circuitBreaker.consecutiveDenials",
629
- warnings,
630
- BREAKER_LIMIT_MIN,
631
- BREAKER_LIMIT_MAX,
632
- );
633
- if (consecutiveDenials !== undefined) {
634
- resolved.consecutiveDenials = consecutiveDenials;
635
- }
636
-
637
- const recentDenials = toBoundedInteger(
638
- raw.recentDenials,
639
- "autoMode.circuitBreaker.recentDenials",
640
- warnings,
641
- BREAKER_LIMIT_MIN,
642
- BREAKER_LIMIT_MAX,
643
- );
644
- if (recentDenials !== undefined) {
645
- resolved.recentDenials = recentDenials;
646
- }
647
-
648
- const windowSize = toBoundedInteger(
649
- raw.windowSize,
650
- "autoMode.circuitBreaker.windowSize",
651
- warnings,
652
- BREAKER_WINDOW_MIN,
653
- BREAKER_WINDOW_MAX,
654
- );
655
- if (windowSize !== undefined) {
656
- resolved.windowSize = windowSize;
308
+ export class ToolkitConfigurationError extends Error {
309
+ constructor(readonly features: readonly ConfigFeature[]) {
310
+ super(`Toolkit configuration is invalid for ${features.join(", ")}; use /toolkit-config validate. No fallback policy was selected.`);
311
+ this.name = "ToolkitConfigurationError";
657
312
  }
658
313
  }
659
314
 
660
- /**
661
- * Load the canonical toolkit config from
662
- * `~/.pi/agent/extensions/pi-openai-toolkit/config.json`.
663
- * A missing file silently yields defaults; legacy branded paths are never read.
664
- */
665
- export function loadToolkitConfig(configPath: string = CONFIG_PATH): LoadedToolkitConfig {
666
- const warnings: string[] = [];
667
- const resolved = cloneDefaults();
668
- let source: string | undefined;
669
-
670
- const raw = readJsonObject(configPath, warnings);
671
- if (raw) {
672
- source = configPath;
673
- warnUnknownFields(raw, TOP_LEVEL_FIELDS, "", warnings);
674
-
675
- if (raw.compaction !== undefined) {
676
- if (isRecord(raw.compaction)) {
677
- applyCompactionConfig(raw.compaction, resolved.compaction, warnings);
678
- } else {
679
- warnings.push("Ignoring compaction: expected a JSON object.");
680
- }
681
- }
682
-
683
- if (raw.webSearch !== undefined) {
684
- if (isRecord(raw.webSearch)) {
685
- applyWebSearchConfig(raw.webSearch, resolved.webSearch, warnings);
686
- } else {
687
- warnings.push("Ignoring webSearch: expected a JSON object.");
688
- }
689
- }
690
-
691
- if (raw.imageGeneration !== undefined) {
692
- if (isRecord(raw.imageGeneration)) {
693
- applyImageGenerationConfig(raw.imageGeneration, resolved.imageGeneration, warnings);
694
- } else {
695
- warnings.push("Ignoring imageGeneration: expected a JSON object.");
696
- }
697
- }
698
-
699
- if (raw.autoMode !== undefined) {
700
- if (isRecord(raw.autoMode)) {
701
- applyAutoModeConfig(raw.autoMode, resolved.autoMode, warnings);
702
- } else {
703
- warnings.push("Ignoring autoMode: expected a JSON object.");
704
- }
705
- }
706
- }
707
-
708
- resolved.compaction.artifactRoot = resolveConfiguredPath(
709
- resolved.compaction.artifactRoot,
710
- path.dirname(configPath),
711
- );
712
-
713
- return {
714
- config: resolved,
715
- source,
716
- warnings,
717
- };
315
+ export function assertConfigValid(resolved: ResolvedToolkitConfig, ...features: ConfigFeature[]): void {
316
+ const invalid = features.filter((feature) => resolved.invalidFeatures.includes(feature));
317
+ if (invalid.length > 0) throw new ToolkitConfigurationError(invalid);
718
318
  }
719
-
720
- export { DEFAULT_TOOLKIT_CONFIG };