pantheon-opencode 1.1.3-beta.0 → 1.2.0-beta.9.7bef03e

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.
Files changed (38) hide show
  1. package/README.md +74 -1
  2. package/bin/pantheon-init.mjs +79 -0
  3. package/commands/pantheon-vision.md +67 -0
  4. package/opencode.json +12 -41
  5. package/package.json +5 -1
  6. package/scripts/doctor.mjs +4 -2
  7. package/scripts/install/health-check.mjs +101 -1
  8. package/scripts/install/model-picker.mjs +118 -0
  9. package/scripts/install/opencode.mjs +98 -17
  10. package/scripts/install-mcp.mjs +98 -13
  11. package/scripts/mcp_persistence_server.py +331 -1
  12. package/scripts/secret-scan.mjs +76 -0
  13. package/scripts/validate-routing.mjs +15 -0
  14. package/src/agents/aphrodite.md +7 -0
  15. package/src/agents/demeter.md +7 -0
  16. package/src/agents/hephaestus.md +7 -0
  17. package/src/agents/hermes.md +7 -0
  18. package/src/agents/zeus.md +31 -18
  19. package/src/instructions/agent-return-format.instructions.md +1 -0
  20. package/src/instructions/zeus-anti-stall.instructions.md +43 -5
  21. package/src/instructions/zeus-timeout-retry.instructions.md +47 -10
  22. package/src/mcp/install-mcp.mjs +6 -913
  23. package/src/mcp/mcp_persistence_server.py +332 -0
  24. package/src/mcp/pantheon_vision_server.py +495 -0
  25. package/src/mcp/requirements-vision.txt +3 -0
  26. package/src/pantheon/auto-wake.ts +91 -0
  27. package/src/pantheon/background-job-board.ts +568 -0
  28. package/src/pantheon/file-persistence.ts +95 -0
  29. package/src/pantheon/presets.d.mts +99 -0
  30. package/src/pantheon/presets.mjs +491 -0
  31. package/src/pantheon/vision.ts +1340 -0
  32. package/src/plugin.ts +40 -25
  33. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  34. package/src/plugins/tui/dist/tui.js +806 -222
  35. package/src/plugins/tui/dist/tui.tsx +866 -121
  36. package/src/plugins/tui/package.json +2 -2
  37. package/src/plugins/tui/src/index.tsx +866 -121
  38. package/src/routing.yml +179 -0
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Type declarations for presets.mjs (model routing presets).
3
+ * Consumed by src/plugin.ts via `import ... from './pantheon/presets.mjs'`.
4
+ */
5
+
6
+ export type Effort = 'low' | 'medium' | 'high'
7
+
8
+ export declare const EFFORT_RANK: Record<Effort, number>
9
+
10
+ export interface CapabilityEntry {
11
+ prefix: string
12
+ maxEffort: Effort | null
13
+ stripEffort: boolean
14
+ vision: boolean
15
+ }
16
+
17
+ export declare const CAPABILITY_TABLE: CapabilityEntry[]
18
+
19
+ export interface PresetAgentSpec {
20
+ model: string
21
+ reasoning_effort?: Effort
22
+ fallback_models?: string[]
23
+ [key: string]: unknown
24
+ }
25
+
26
+ export interface PresetProviderDef {
27
+ baseURL: string
28
+ apiKeyEnv: string
29
+ [key: string]: unknown
30
+ }
31
+
32
+ export interface PresetDef {
33
+ description?: string
34
+ providers?: Record<string, PresetProviderDef>
35
+ agents?: Record<string, PresetAgentSpec>
36
+ [key: string]: unknown
37
+ }
38
+
39
+ export interface ResolvedPreset {
40
+ name: string
41
+ source: 'env' | 'file'
42
+ agents: Record<string, PresetAgentSpec>
43
+ providers: Record<string, PresetProviderDef>
44
+ overrides: unknown
45
+ vision: { model: string; reasoning_effort?: string } | null
46
+ }
47
+
48
+ export interface PresetFileOverrides {
49
+ agents?: Record<string, PresetAgentSpec & { variant?: Effort }>
50
+ providers?: Record<string, PresetProviderDef>
51
+ }
52
+
53
+ export interface ActivePresetFile {
54
+ version: number
55
+ preset: string
56
+ source: string
57
+ updated_at: string
58
+ overrides?: PresetFileOverrides
59
+ }
60
+
61
+ export interface ResolveOptions {
62
+ env?: Record<string, string | undefined>
63
+ candidates?: Array<string | null | undefined>
64
+ routingPath?: string
65
+ logger?: {
66
+ warn?: (msg: string) => void
67
+ log?: (msg: string) => void
68
+ error?: (msg: string) => void
69
+ }
70
+ }
71
+
72
+ export declare function capabilityEntry(model: string): CapabilityEntry
73
+
74
+ export declare function normalizeCapability(
75
+ model: string,
76
+ requestedEffort?: Effort | null,
77
+ ): { variant: Effort | null; clamped: boolean }
78
+
79
+ export declare function hasVision(model: string): boolean
80
+
81
+ export declare function loadPresetDefs(routingPath?: string): Record<string, PresetDef>
82
+
83
+ export declare function resolveActivePreset(options?: ResolveOptions): ResolvedPreset | null
84
+
85
+ export interface MissingApiKeyError extends Error {
86
+ code: 'PANTHEON_MISSING_API_KEY'
87
+ envVar: string
88
+ }
89
+
90
+ export declare function applyPreset<C extends object>(
91
+ config: C,
92
+ resolved: ResolvedPreset | null,
93
+ options?: { env?: Record<string, string | undefined> },
94
+ ): C
95
+
96
+ export declare function validatePresetDefs(
97
+ presets: Record<string, PresetDef>,
98
+ options?: { agents?: string[] },
99
+ ): { ok: boolean; errors: string[]; warnings: string[] }
@@ -0,0 +1,491 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * presets.mjs — model routing presets for Pantheon OpenCode
4
+ *
5
+ * Pure resolver/validator module (NO writes). Provides:
6
+ * - capability table + normalizeCapability() (model → variant/clamp)
7
+ * - hasVision() (model → image-input support)
8
+ * - loadPresetDefs() (routing.yml presets: block)
9
+ * - resolveActivePreset() (env PANTHEON_MODEL_PRESET > file)
10
+ * - applyPreset() (mutate opencode config)
11
+ * - validatePresetDefs() (schema checks for CI)
12
+ *
13
+ * The active-preset.json file format (version 1):
14
+ * {
15
+ * "version": 1,
16
+ * "preset": "go-deepseek",
17
+ * "source": "cli",
18
+ * "updated_at": "2026-08-02T12:00:00.000Z",
19
+ * "overrides": {
20
+ * "agents": { "hermes": { "model": "...", "variant": "high" } },
21
+ * "providers": { "deepseek": { "baseURL": "...", "apiKeyEnv": "..." } }
22
+ * }
23
+ * }
24
+ * Override agent `variant` is mapped to `reasoning_effort` during merge.
25
+ */
26
+ import { readFileSync } from 'node:fs'
27
+ import { join } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+ import yaml from 'js-yaml'
30
+
31
+ export const EFFORT_RANK = { low: 0, medium: 1, high: 2 }
32
+
33
+ /**
34
+ * Capability table: reasoning-effort ceiling + image-input (vision) support.
35
+ *
36
+ * `vision` flags mark models that accept image input. Verified against
37
+ * models.dev api.json on 2026-08-02 (council decision): qwen3.7-max,
38
+ * minimax-m2.5 and minimax-m2.7 and glm-5.1/glm-5.2 are TEXT-ONLY (vision:
39
+ * false); mimo-v2.5 / mimo-v2.5-free / qwen3.7-plus / minimax-m3 and the
40
+ * o-series (o1/o3/o4) are multimodal (models.dev input: text+image).
41
+ * claude-* and gpt-5.6-* carry native image support.
42
+ */
43
+ export const CAPABILITY_TABLE = [
44
+ { prefix: 'deepseek/deepseek-v4-pro', maxEffort: 'high', stripEffort: false, vision: false },
45
+ // prefix also covers -free suffix (e.g. deepseek/deepseek-v4-flash-free)
46
+ { prefix: 'deepseek/deepseek-v4-flash', maxEffort: 'medium', stripEffort: false, vision: false },
47
+ { prefix: 'claude', maxEffort: null, stripEffort: true, vision: true },
48
+ // gpt-5.6 family: longest-prefix match picks luna-fast over generic gpt-5.6
49
+ { prefix: 'gpt-5.6-sol', maxEffort: 'high', stripEffort: false, vision: true },
50
+ { prefix: 'gpt-5.6-terra', maxEffort: 'medium', stripEffort: false, vision: true },
51
+ { prefix: 'gpt-5.6-luna-fast', maxEffort: 'low', stripEffort: false, vision: true },
52
+ { prefix: 'gpt-5.6-luna', maxEffort: 'low', stripEffort: false, vision: true },
53
+ { prefix: 'gpt-5.6', maxEffort: 'medium', stripEffort: false, vision: true },
54
+ // o-series are multimodal (models.dev input: text+image+pdf)
55
+ { prefix: 'o1', maxEffort: 'high', stripEffort: false, vision: true },
56
+ { prefix: 'o3', maxEffort: 'high', stripEffort: false, vision: true },
57
+ { prefix: 'o4', maxEffort: 'high', stripEffort: false, vision: true },
58
+ { prefix: 'mimo/', maxEffort: 'low', stripEffort: false, vision: true },
59
+ // opencode/mimo-v2.5 fallback (OpenCode Zen): bare segment — the 'mimo/'
60
+ // entry above matches 'mimo/v2.5' (full-string) but NOT 'mimo-v2.5'
61
+ // (segment 'mimo-v2.5' has no slash, so 'mimo/' prefix cannot match).
62
+ { prefix: 'mimo-v2.5', maxEffort: 'low', stripEffort: false, vision: true },
63
+ // mimo-v2.5-pro is text-only per models.dev api.json (2026-08-02).
64
+ // Explicit entry needed: the 'mimo-v2.5' prefix substring-matches
65
+ // 'mimo-v2.5-pro' (longest-prefix wins), which would wrongly report
66
+ // vision: true for a text-only model.
67
+ { prefix: 'mimo-v2.5-pro', maxEffort: 'low', stripEffort: false, vision: false },
68
+ // opencode-go (OpenCode Go subscription) — bare segment prefixes so
69
+ // opencode-go/<model> IDs match; the provider-scoped deepseek/ entries
70
+ // above still win by length for deepseek/ models. glm-5.1/glm-5.2 are
71
+ // text-only per models.dev api.json (2026-08-02).
72
+ { prefix: 'glm-5.2', maxEffort: 'medium', stripEffort: false, vision: false },
73
+ { prefix: 'glm-5.1', maxEffort: 'medium', stripEffort: false, vision: false },
74
+ { prefix: 'kimi-k2.6', maxEffort: 'high', stripEffort: false, vision: true },
75
+ // qwen3.7-max is text-only per models.dev api.json (2026-08-02); the
76
+ // multimodal variant is qwen3.7-plus.
77
+ { prefix: 'qwen3.7-max', maxEffort: 'high', stripEffort: false, vision: false },
78
+ // qwen3.7-plus IS multimodal (models.dev input: text+image) but image
79
+ // turns through the OpenCode Go gateway return HTTP 500 (opencode#33942
80
+ // + #29956). Flag stays true per models.dev; presets must NOT use it as a
81
+ // vision fallback — minimax-m3 is the confirmed opencode-go fallback.
82
+ { prefix: 'qwen3.7-plus', maxEffort: 'medium', stripEffort: false, vision: true },
83
+ // minimax-m2.7 / minimax-m2.5 are text-only per models.dev api.json
84
+ // (2026-08-02). minimax-m3 is the vision-capable MiniMax on opencode-go
85
+ // (confirmed multimodal via the Go gateway, opencode#29956).
86
+ { prefix: 'minimax-m2.7', maxEffort: 'medium', stripEffort: false, vision: false },
87
+ { prefix: 'minimax-m2.5', maxEffort: 'medium', stripEffort: false, vision: false },
88
+ { prefix: 'minimax-m3', maxEffort: 'high', stripEffort: false, vision: true },
89
+ // BARE prefix — needed for opencode-go/deepseek-v4-flash (segment match);
90
+ // provider-scoped deepseek/deepseek-v4-flash entry still wins by length
91
+ // for deepseek/ models. Covers -free suffix too.
92
+ { prefix: 'deepseek-v4-flash', maxEffort: 'medium', stripEffort: false, vision: false },
93
+ // BARE prefix — needed for opencode-go/deepseek-v4-pro (segment match);
94
+ // provider-scoped deepseek/deepseek-v4-pro entry still wins by length.
95
+ { prefix: 'deepseek-v4-pro', maxEffort: 'high', stripEffort: false, vision: false },
96
+ // opencode (Zen free tier) — bare segment prefixes for opencode/<model>.
97
+ // big-pickle / nemotron-3-ultra-free / north-mini-code-free verified
98
+ // text-only via models.dev (2026-08-02).
99
+ { prefix: 'big-pickle', maxEffort: 'medium', stripEffort: false, vision: false },
100
+ { prefix: 'nemotron-3-ultra-free', maxEffort: 'high', stripEffort: false, vision: false },
101
+ { prefix: 'north-mini-code-free', maxEffort: 'high', stripEffort: false, vision: false },
102
+ ]
103
+
104
+ /**
105
+ * Find the capability entry for a model ID.
106
+ * Matches when the model starts with the prefix OR the segment after the
107
+ * last '/' starts with the prefix. Longest matching prefix wins.
108
+ *
109
+ * @param {string} model model ID (e.g. "deepseek/deepseek-v4-flash")
110
+ * @returns {{prefix: string, maxEffort: 'low'|'medium'|'high'|null, stripEffort: boolean, vision: boolean}}
111
+ */
112
+ export function capabilityEntry(model) {
113
+ const matches = CAPABILITY_TABLE.filter((entry) => {
114
+ if (model.startsWith(entry.prefix)) return true
115
+ const segment = model.slice(model.lastIndexOf('/') + 1)
116
+ return segment.startsWith(entry.prefix)
117
+ })
118
+ if (matches.length === 0) {
119
+ throw new Error(`presets: no capability entry for model ${model}`)
120
+ }
121
+ matches.sort((a, b) => b.prefix.length - a.prefix.length)
122
+ return matches[0]
123
+ }
124
+
125
+ /**
126
+ * Normalize a requested reasoning effort to a model variant, clamping to the
127
+ * model's capability ceiling.
128
+ *
129
+ * - stripEffort models (claude) → variant null (no variant key).
130
+ * - undefined/null requested → entry maxEffort (or null when stripping).
131
+ * - rank(requested) > rank(maxEffort) → clamp to maxEffort.
132
+ *
133
+ * @param {string} model
134
+ * @param {'low'|'medium'|'high'|null|undefined} [requestedEffort]
135
+ * @returns {{variant: 'low'|'medium'|'high'|null, clamped: boolean}}
136
+ */
137
+ export function normalizeCapability(model, requestedEffort) {
138
+ const entry = capabilityEntry(model)
139
+ if (entry.stripEffort) {
140
+ return { variant: null, clamped: requestedEffort != null }
141
+ }
142
+ const maxEffort = entry.maxEffort
143
+ if (requestedEffort === undefined || requestedEffort === null) {
144
+ return { variant: maxEffort, clamped: false }
145
+ }
146
+ if (EFFORT_RANK[requestedEffort] > EFFORT_RANK[maxEffort]) {
147
+ return { variant: maxEffort, clamped: true }
148
+ }
149
+ return { variant: requestedEffort, clamped: false }
150
+ }
151
+
152
+ /**
153
+ * Whether a model accepts image input (vision) per CAPABILITY_TABLE.
154
+ * Throws on models with no capability entry (same as normalizeCapability).
155
+ *
156
+ * @param {string} model
157
+ * @returns {boolean}
158
+ */
159
+ export function hasVision(model) {
160
+ return capabilityEntry(model).vision
161
+ }
162
+
163
+ /**
164
+ * Load preset definitions from routing.yml (top-level `presets:` key).
165
+ *
166
+ * @param {string} [routingPath] defaults to src/routing.yml
167
+ * @returns {Record<string, object>} preset name → definition ({} when absent)
168
+ */
169
+ export function loadPresetDefs(routingPath) {
170
+ const path = routingPath ?? fileURLToPath(new URL('../routing.yml', import.meta.url))
171
+ let raw
172
+ try {
173
+ raw = readFileSync(path, 'utf8')
174
+ } catch (err) {
175
+ throw new Error(`presets: failed to load routing.yml: ${path} — ${err.message}`)
176
+ }
177
+ let routing
178
+ try {
179
+ routing = yaml.load(raw)
180
+ } catch (err) {
181
+ throw new Error(`presets: failed to load routing.yml: ${err.message}`)
182
+ }
183
+ return (routing && typeof routing === 'object' && routing.presets) || {}
184
+ }
185
+
186
+ /**
187
+ * Resolve the active preset: env PANTHEON_MODEL_PRESET > first existing
188
+ * candidate file > null. "none" disables. Unknown names warn and return null
189
+ * WITHOUT falling through to lower-priority sources.
190
+ *
191
+ * @param {object} [opts]
192
+ * @param {Record<string, string|undefined>} [opts.env]
193
+ * @param {string[]} [opts.candidates]
194
+ * @param {string} [opts.routingPath]
195
+ * @param {{warn?: Function, log?: Function, error?: Function}} [opts.logger]
196
+ * @returns {{name: string, source: 'env'|'file', agents: object, providers: object, overrides: object|null, vision: {model: string, reasoning_effort?: 'low'|'medium'|'high'}|null}}
197
+ */
198
+ export function resolveActivePreset({
199
+ env = process.env,
200
+ candidates = [join(process.cwd(), '.pantheon', 'active-preset.json')],
201
+ routingPath,
202
+ logger = console,
203
+ } = {}) {
204
+ const defs = loadPresetDefs(routingPath)
205
+
206
+ const envName = env.PANTHEON_MODEL_PRESET
207
+ if (envName !== undefined && envName !== '') {
208
+ if (envName === 'none') return null
209
+ const def = defs[envName]
210
+ if (!def) {
211
+ logger.warn?.(
212
+ `presets: unknown preset "${envName}" (PANTHEON_MODEL_PRESET). Available: ${Object.keys(defs).join(', ') || 'none'}`,
213
+ )
214
+ return null
215
+ }
216
+ return buildResolved(defs, envName, 'env', null)
217
+ }
218
+
219
+ for (const candidate of candidates) {
220
+ if (!candidate) continue
221
+ let exists
222
+ try {
223
+ exists = readFileSync(candidate)
224
+ } catch {
225
+ continue // candidate does not exist
226
+ }
227
+ let raw
228
+ try {
229
+ raw = JSON.parse(exists.toString('utf8'))
230
+ } catch {
231
+ logger.warn?.(`presets: malformed active preset file: ${candidate}`)
232
+ return null
233
+ }
234
+ const name = raw && typeof raw === 'object' ? raw.preset : undefined
235
+ if (!name || name === 'none') return null
236
+ const def = defs[name]
237
+ if (!def) {
238
+ logger.warn?.(
239
+ `presets: unknown preset "${name}" in ${candidate}. Available: ${Object.keys(defs).join(', ') || 'none'}`,
240
+ )
241
+ return null
242
+ }
243
+ return buildResolved(defs, name, 'file', raw)
244
+ }
245
+
246
+ return null
247
+ }
248
+
249
+ /**
250
+ * Build the resolved preset by merging preset defs with file overrides.
251
+ * Overrides win per-agent / per-provider; override `variant` → reasoning_effort.
252
+ * `vision` comes from the preset def, overridable via `overrides.vision`
253
+ * (an explicit null in the file clears the fallback).
254
+ *
255
+ * @param {Record<string, object>} defs
256
+ * @param {string} name
257
+ * @param {'env'|'file'} source
258
+ * @param {object|null} rawFile
259
+ */
260
+ function buildResolved(defs, name, source, rawFile) {
261
+ const def = defs[name] || {}
262
+ const overrides = rawFile && typeof rawFile === 'object' ? rawFile.overrides : null
263
+ const agentOverrides = overrides?.agents ?? {}
264
+ const providerOverrides = overrides?.providers ?? {}
265
+ // Vision override shallow-merges with the preset def (a partial
266
+ // `{model}` keeps the preset's reasoning_effort) — mirrors how agent
267
+ // overrides merge. An explicit `overrides.vision: null` clears the
268
+ // fallback; a truthy non-object override is ignored like agents do.
269
+ let vision = def.vision ?? null
270
+ if (overrides && Object.hasOwn(overrides, 'vision')) {
271
+ const override = overrides.vision
272
+ if (override === null) {
273
+ vision = null
274
+ } else if (override && typeof override === 'object') {
275
+ vision = { ...(def.vision ?? {}), ...override }
276
+ }
277
+ }
278
+
279
+ const agents = {}
280
+ for (const [agent, spec] of Object.entries(def.agents ?? {})) {
281
+ let merged = { ...spec }
282
+ const override = agentOverrides[agent]
283
+ if (override && typeof override === 'object') {
284
+ merged = { ...merged, ...override }
285
+ if (override.variant !== undefined) {
286
+ merged.reasoning_effort = override.variant
287
+ }
288
+ }
289
+ agents[agent] = merged
290
+ }
291
+
292
+ const providers = { ...(def.providers ?? {}) }
293
+ for (const [id, provider] of Object.entries(providerOverrides)) {
294
+ if (provider && typeof provider === 'object') {
295
+ providers[id] = { ...(providers[id] ?? {}), ...provider }
296
+ }
297
+ }
298
+
299
+ return { name, source, agents, providers, overrides, vision }
300
+ }
301
+
302
+ /**
303
+ * Apply a resolved preset to an opencode config object (mutates `config`).
304
+ * - Providers: config.provider[id].options.baseURL + apiKey (env-gated).
305
+ * - Agents: config.agent[agent].model / .variant / .fallback_models.
306
+ * Only agents listed in the preset are touched (partial semantics).
307
+ *
308
+ * @throws {Error} code=PANTHEON_MISSING_API_KEY, envVar set, when a provider
309
+ * key env var is unset/empty.
310
+ */
311
+ export function applyPreset(config, resolved, { env = process.env } = {}) {
312
+ if (!resolved) return config
313
+
314
+ for (const [id, provider] of Object.entries(resolved.providers ?? {})) {
315
+ const apiKey = env[provider.apiKeyEnv]
316
+ if (!apiKey) {
317
+ const err = new Error(`presets: provider "${id}" requires env var ${provider.apiKeyEnv}`)
318
+ err.code = 'PANTHEON_MISSING_API_KEY'
319
+ err.envVar = provider.apiKeyEnv
320
+ throw err
321
+ }
322
+ config.provider ??= {}
323
+ config.provider[id] ??= {}
324
+ config.provider[id].options ??= {}
325
+ config.provider[id].options.baseURL = provider.baseURL
326
+ config.provider[id].options.apiKey = apiKey
327
+ }
328
+
329
+ for (const [agent, spec] of Object.entries(resolved.agents ?? {})) {
330
+ config.agent ??= {}
331
+ config.agent[agent] ??= {}
332
+ config.agent[agent].model = spec.model
333
+ const { variant } = normalizeCapability(spec.model, spec.reasoning_effort)
334
+ if (variant === null) {
335
+ delete config.agent[agent].variant
336
+ } else {
337
+ config.agent[agent].variant = variant
338
+ }
339
+ if (spec.fallback_models !== undefined) {
340
+ config.agent[agent].fallback_models = [...spec.fallback_models]
341
+ }
342
+ }
343
+
344
+ return config
345
+ }
346
+
347
+ const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/.+$/
348
+
349
+ /**
350
+ * Validate preset definitions against the schema.
351
+ *
352
+ * @param {Record<string, object>} presets
353
+ * @param {{agents?: string[]}} [opts] known agent names (routing minus legacy)
354
+ * @returns {{ok: boolean, errors: string[], warnings: string[]}}
355
+ */
356
+ export function validatePresetDefs(presets, { agents } = {}) {
357
+ const errors = []
358
+ const warnings = []
359
+ const knownAgents = agents ? new Set(agents) : null
360
+
361
+ for (const [name, def] of Object.entries(presets)) {
362
+ if (name === 'none') {
363
+ errors.push(`preset "${name}": name "none" is reserved`)
364
+ continue
365
+ }
366
+ if (!def || typeof def !== 'object' || Array.isArray(def)) {
367
+ errors.push(`preset "${name}": definition must be a plain object`)
368
+ continue
369
+ }
370
+
371
+ if (typeof def.description === 'string' && !/\b(19|20)\d{2}\b/.test(def.description)) {
372
+ warnings.push(`preset "${name}": description missing a 4-digit year`)
373
+ }
374
+
375
+ for (const [id, provider] of Object.entries(def.providers ?? {})) {
376
+ if (!provider || typeof provider !== 'object' || Array.isArray(provider)) {
377
+ errors.push(`preset "${name}": provider "${id}" must be an object`)
378
+ continue
379
+ }
380
+ if (!provider.baseURL) {
381
+ errors.push(`preset "${name}": provider "${id}" missing baseURL`)
382
+ } else if (!/^https?:\/\//.test(provider.baseURL)) {
383
+ errors.push(`preset "${name}": provider "${id}" baseURL must start with http(s)://`)
384
+ }
385
+ if (typeof provider.apiKeyEnv !== 'string' || provider.apiKeyEnv.length === 0) {
386
+ errors.push(`preset "${name}": provider "${id}" apiKeyEnv must be a non-empty string`)
387
+ } else if (!provider.apiKeyEnv.startsWith('PANTHEON_')) {
388
+ warnings.push(`preset "${name}": provider "${id}" apiKeyEnv should start with PANTHEON_`)
389
+ }
390
+ }
391
+
392
+ for (const [agent, spec] of Object.entries(def.agents ?? {})) {
393
+ if (knownAgents && !knownAgents.has(agent)) {
394
+ errors.push(`preset "${name}": unknown agent "${agent}"`)
395
+ }
396
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
397
+ errors.push(`preset "${name}": agent "${agent}" spec must be an object`)
398
+ continue
399
+ }
400
+ if (typeof spec.model !== 'string' || !MODEL_RE.test(spec.model)) {
401
+ errors.push(
402
+ `preset "${name}": agent "${agent}" model must match provider/model (got "${spec.model}")`,
403
+ )
404
+ } else {
405
+ try {
406
+ capabilityEntry(spec.model)
407
+ } catch (err) {
408
+ errors.push(`preset "${name}": agent "${agent}" ${err.message}`)
409
+ }
410
+ }
411
+ if (
412
+ spec.reasoning_effort !== undefined &&
413
+ !Object.hasOwn(EFFORT_RANK, spec.reasoning_effort)
414
+ ) {
415
+ errors.push(
416
+ `preset "${name}": agent "${agent}" reasoning_effort must be one of low, medium, high`,
417
+ )
418
+ }
419
+ if (spec.fallback_models !== undefined) {
420
+ if (
421
+ !Array.isArray(spec.fallback_models) ||
422
+ spec.fallback_models.some((m) => typeof m !== 'string')
423
+ ) {
424
+ errors.push(
425
+ `preset "${name}": agent "${agent}" fallback_models must be an array of strings`,
426
+ )
427
+ }
428
+ }
429
+ }
430
+
431
+ // Optional top-level vision fallback (routes image turns to a
432
+ // vision-capable model when the agent models are text-only).
433
+ if (def.vision !== undefined) {
434
+ const v = def.vision
435
+ if (!v || typeof v !== 'object' || Array.isArray(v)) {
436
+ errors.push(
437
+ `preset "${name}": vision must be an object with model (+ optional reasoning_effort)`,
438
+ )
439
+ } else {
440
+ if (typeof v.model !== 'string' || !MODEL_RE.test(v.model)) {
441
+ errors.push(`preset "${name}": vision.model must match provider/model (got "${v.model}")`)
442
+ } else {
443
+ try {
444
+ const entry = capabilityEntry(v.model)
445
+ if (!entry.vision) {
446
+ errors.push(`preset "${name}": vision model "${v.model}" is not vision-capable`)
447
+ }
448
+ } catch (err) {
449
+ errors.push(`preset "${name}": vision ${err.message}`)
450
+ }
451
+ // The vision model's provider (segment before the first '/') MUST
452
+ // be declared in the preset's providers block (council 2026-08-02):
453
+ // a vision fallback is unusable if applyPreset never injects the
454
+ // provider config for it.
455
+ const provider = v.model.slice(0, v.model.indexOf('/'))
456
+ const declared = Object.keys(def.providers ?? {})
457
+ if (!declared.includes(provider)) {
458
+ errors.push(
459
+ `preset "${name}": vision model ${v.model} uses provider ${provider} not declared in providers`,
460
+ )
461
+ }
462
+ }
463
+ if (v.reasoning_effort !== undefined && !Object.hasOwn(EFFORT_RANK, v.reasoning_effort)) {
464
+ errors.push(`preset "${name}": vision reasoning_effort must be one of low, medium, high`)
465
+ }
466
+ }
467
+ } else {
468
+ // No vision fallback declared: warn when every primary agent model is
469
+ // text-only — image turns would have no vision-capable route.
470
+ const agentModels = Object.values(def.agents ?? {})
471
+ .map((s) => (s && typeof s === 'object' ? s.model : undefined))
472
+ .filter((m) => typeof m === 'string')
473
+ if (
474
+ agentModels.length > 0 &&
475
+ agentModels.every((m) => {
476
+ try {
477
+ return !hasVision(m)
478
+ } catch {
479
+ return true
480
+ }
481
+ })
482
+ ) {
483
+ warnings.push(
484
+ `preset "${name}": all primary agent models are text-only — consider adding a vision fallback`,
485
+ )
486
+ }
487
+ }
488
+ }
489
+
490
+ return { ok: errors.length === 0, errors, warnings }
491
+ }