pantheon-opencode 1.2.0 → 1.2.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.
Files changed (43) hide show
  1. package/README.md +101 -3
  2. package/bin/pantheon-init.mjs +79 -0
  3. package/commands/pantheon-vision.md +67 -0
  4. package/opencode.json +13 -9
  5. package/package.json +12 -5
  6. package/scripts/_pantheon_paths.py +0 -2
  7. package/scripts/doctor.mjs +4 -2
  8. package/scripts/install/health-check.mjs +101 -1
  9. package/scripts/install/model-picker.mjs +118 -0
  10. package/scripts/install/opencode.mjs +102 -19
  11. package/scripts/install/venv.mjs +25 -2
  12. package/scripts/install-mcp.mjs +98 -13
  13. package/scripts/mcp_persistence_server.py +1 -1
  14. package/scripts/secret-scan.mjs +88 -0
  15. package/scripts/uninstall.mjs +3 -3
  16. package/scripts/validate-routing.mjs +15 -0
  17. package/src/agents/hermes.md +2 -2
  18. package/src/agents/mnemosyne.md +1 -1
  19. package/src/agents/zeus.md +1 -1
  20. package/src/mcp/_pantheon_paths.py +0 -2
  21. package/src/mcp/install-mcp.mjs +6 -913
  22. package/src/mcp/mcp_persistence_server.py +2 -1
  23. package/src/mcp/pantheon_vision_server.py +495 -0
  24. package/src/mcp/requirements-vision.txt +3 -0
  25. package/src/pantheon/auto-wake.ts +91 -0
  26. package/src/pantheon/background-job-board.ts +568 -0
  27. package/src/pantheon/file-persistence.ts +95 -0
  28. package/src/pantheon/presets.d.mts +108 -0
  29. package/src/pantheon/presets.mjs +554 -0
  30. package/src/pantheon/vision.ts +1437 -0
  31. package/src/plugin.ts +69 -25
  32. package/src/plugins/tui/package.json +3 -3
  33. package/src/plugins/tui/src/index.tsx +866 -121
  34. package/src/routing.yml +148 -0
  35. package/src/skills/context-compression/SKILL.md +1 -1
  36. package/src/skills/loop-engineering/SKILL.md +1 -1
  37. package/src/skills/orchestration-workflow/SKILL.md +11 -11
  38. package/scripts/scrub_secrets.py +0 -281
  39. package/src/plugins/tui/dist/tui.d.ts +0 -8
  40. package/src/plugins/tui/dist/tui.d.ts.map +0 -1
  41. package/src/plugins/tui/dist/tui.js +0 -609
  42. package/src/plugins/tui/dist/tui.js.map +0 -1
  43. package/src/plugins/tui/dist/tui.tsx +0 -444
@@ -0,0 +1,108 @@
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 const GATEWAY_BROKEN_VISION_MODELS: Set<string>
82
+
83
+ export declare function visionBrokenOnGateway(model: string, providerID: string): boolean
84
+
85
+ export declare function loadPresetDefs(routingPath?: string): Record<string, PresetDef>
86
+
87
+ export declare function resolveActivePreset(options?: ResolveOptions): ResolvedPreset | null
88
+
89
+ export declare function applyActivePresetToConfig<C extends object>(
90
+ config: C,
91
+ options?: ResolveOptions & { env?: Record<string, string | undefined> },
92
+ ): ResolvedPreset | null
93
+
94
+ export interface MissingApiKeyError extends Error {
95
+ code: 'PANTHEON_MISSING_API_KEY'
96
+ envVar: string
97
+ }
98
+
99
+ export declare function applyPreset<C extends object>(
100
+ config: C,
101
+ resolved: ResolvedPreset | null,
102
+ options?: { env?: Record<string, string | undefined> },
103
+ ): C
104
+
105
+ export declare function validatePresetDefs(
106
+ presets: Record<string, PresetDef>,
107
+ options?: { agents?: string[] },
108
+ ): { ok: boolean; errors: string[]; warnings: string[] }
@@ -0,0 +1,554 @@
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
+ * Models that ARE vision-capable per models.dev but whose image turns FAIL
165
+ * on the OpenCode Go gateway (HTTP 500 — opencode#33942 + opencode#29956).
166
+ * Runtime image routing treats them as TEXT-ONLY so image turns are
167
+ * intercepted and routed to the confirmed multimodal fallback (minimax-m3)
168
+ * instead of being sent directly to the gateway. Keyed by the model segment
169
+ * (after the last '/') so bare and provider-qualified IDs both match.
170
+ */
171
+ export const GATEWAY_BROKEN_VISION_MODELS = new Set(['qwen3.7-plus'])
172
+
173
+ /**
174
+ * Whether a model's image input is BROKEN on the given provider's runtime
175
+ * gateway — vision per models.dev, but image turns fail. Provider-scoped:
176
+ * only the OpenCode Go gateway (opencode-go) has a documented breakage; the
177
+ * same model elsewhere (Zen free `opencode`, anthropic, openai) keeps its
178
+ * native bypass.
179
+ *
180
+ * @param {string} model model ID (e.g. "opencode-go/qwen3.7-plus")
181
+ * @param {string} providerID provider the model runs on
182
+ * @returns {boolean}
183
+ */
184
+ export function visionBrokenOnGateway(model, providerID) {
185
+ if (!/^opencode-go$/.test(providerID ?? '')) return false
186
+ const segment = model.slice(model.lastIndexOf('/') + 1)
187
+ return GATEWAY_BROKEN_VISION_MODELS.has(segment)
188
+ }
189
+
190
+ /**
191
+ * Load preset definitions from routing.yml (top-level `presets:` key).
192
+ *
193
+ * @param {string} [routingPath] defaults to src/routing.yml
194
+ * @returns {Record<string, object>} preset name → definition ({} when absent)
195
+ */
196
+ export function loadPresetDefs(routingPath) {
197
+ const path = routingPath ?? fileURLToPath(new URL('../routing.yml', import.meta.url))
198
+ let raw
199
+ try {
200
+ raw = readFileSync(path, 'utf8')
201
+ } catch (err) {
202
+ throw new Error(`presets: failed to load routing.yml: ${path} — ${err.message}`)
203
+ }
204
+ let routing
205
+ try {
206
+ routing = yaml.load(raw)
207
+ } catch (err) {
208
+ throw new Error(`presets: failed to load routing.yml: ${err.message}`)
209
+ }
210
+ return (routing && typeof routing === 'object' && routing.presets) || {}
211
+ }
212
+
213
+ /**
214
+ * Resolve the active preset: env PANTHEON_MODEL_PRESET > first existing
215
+ * candidate file > null. "none" disables. Unknown names warn and return null
216
+ * WITHOUT falling through to lower-priority sources.
217
+ *
218
+ * @param {object} [opts]
219
+ * @param {Record<string, string|undefined>} [opts.env]
220
+ * @param {string[]} [opts.candidates]
221
+ * @param {string} [opts.routingPath]
222
+ * @param {{warn?: Function, log?: Function, error?: Function}} [opts.logger]
223
+ * @returns {{name: string, source: 'env'|'file', agents: object, providers: object, overrides: object|null, vision: {model: string, reasoning_effort?: 'low'|'medium'|'high'}|null}}
224
+ */
225
+ export function resolveActivePreset({
226
+ env = process.env,
227
+ candidates = [join(process.cwd(), '.pantheon', 'active-preset.json')],
228
+ routingPath,
229
+ logger = console,
230
+ } = {}) {
231
+ const defs = loadPresetDefs(routingPath)
232
+
233
+ const envName = env.PANTHEON_MODEL_PRESET
234
+ if (envName !== undefined && envName !== '') {
235
+ if (envName === 'none') return null
236
+ const def = defs[envName]
237
+ if (!def) {
238
+ logger.warn?.(
239
+ `presets: unknown preset "${envName}" (PANTHEON_MODEL_PRESET). Available: ${Object.keys(defs).join(', ') || 'none'}`,
240
+ )
241
+ return null
242
+ }
243
+ return buildResolved(defs, envName, 'env', null)
244
+ }
245
+
246
+ for (const candidate of candidates) {
247
+ if (!candidate) continue
248
+ let exists
249
+ try {
250
+ exists = readFileSync(candidate)
251
+ } catch {
252
+ continue // candidate does not exist
253
+ }
254
+ let raw
255
+ try {
256
+ raw = JSON.parse(exists.toString('utf8'))
257
+ } catch {
258
+ logger.warn?.(`presets: malformed active preset file: ${candidate}`)
259
+ return null
260
+ }
261
+ const name = raw && typeof raw === 'object' ? raw.preset : undefined
262
+ if (!name || name === 'none') return null
263
+ const def = defs[name]
264
+ if (!def) {
265
+ logger.warn?.(
266
+ `presets: unknown preset "${name}" in ${candidate}. Available: ${Object.keys(defs).join(', ') || 'none'}`,
267
+ )
268
+ return null
269
+ }
270
+ return buildResolved(defs, name, 'file', raw)
271
+ }
272
+
273
+ return null
274
+ }
275
+
276
+ /**
277
+ * Build the resolved preset by merging preset defs with file overrides.
278
+ * Overrides win per-agent / per-provider; override `variant` → reasoning_effort.
279
+ * `vision` comes from the preset def, overridable via `overrides.vision`
280
+ * (an explicit null in the file clears the fallback).
281
+ *
282
+ * @param {Record<string, object>} defs
283
+ * @param {string} name
284
+ * @param {'env'|'file'} source
285
+ * @param {object|null} rawFile
286
+ */
287
+ function buildResolved(defs, name, source, rawFile) {
288
+ const def = defs[name] || {}
289
+ const overrides = rawFile && typeof rawFile === 'object' ? rawFile.overrides : null
290
+ const agentOverrides = overrides?.agents ?? {}
291
+ const providerOverrides = overrides?.providers ?? {}
292
+ // Vision override shallow-merges with the preset def (a partial
293
+ // `{model}` keeps the preset's reasoning_effort) — mirrors how agent
294
+ // overrides merge. An explicit `overrides.vision: null` clears the
295
+ // fallback; a truthy non-object override is ignored like agents do.
296
+ let vision = def.vision ?? null
297
+ if (overrides && Object.hasOwn(overrides, 'vision')) {
298
+ const override = overrides.vision
299
+ if (override === null) {
300
+ vision = null
301
+ } else if (override && typeof override === 'object') {
302
+ vision = { ...(def.vision ?? {}), ...override }
303
+ }
304
+ }
305
+
306
+ const agents = {}
307
+ for (const [agent, spec] of Object.entries(def.agents ?? {})) {
308
+ let merged = { ...spec }
309
+ const override = agentOverrides[agent]
310
+ if (override && typeof override === 'object') {
311
+ merged = { ...merged, ...override }
312
+ if (override.variant !== undefined) {
313
+ merged.reasoning_effort = override.variant
314
+ }
315
+ }
316
+ agents[agent] = merged
317
+ }
318
+
319
+ const providers = { ...(def.providers ?? {}) }
320
+ for (const [id, provider] of Object.entries(providerOverrides)) {
321
+ if (provider && typeof provider === 'object') {
322
+ providers[id] = { ...(providers[id] ?? {}), ...provider }
323
+ }
324
+ }
325
+
326
+ return { name, source, agents, providers, overrides, vision }
327
+ }
328
+
329
+ /**
330
+ * Apply a resolved preset to an opencode config object (mutates `config`).
331
+ * - Providers: config.provider[id].options.baseURL + apiKey (env-gated).
332
+ * - Agents: config.agent[agent].model / .variant / .fallback_models.
333
+ * Only agents listed in the preset are touched (partial semantics).
334
+ *
335
+ * @throws {Error} code=PANTHEON_MISSING_API_KEY, envVar set, when a provider
336
+ * key env var is unset/empty.
337
+ */
338
+ export function applyPreset(config, resolved, { env = process.env } = {}) {
339
+ if (!resolved) return config
340
+
341
+ for (const [id, provider] of Object.entries(resolved.providers ?? {})) {
342
+ const apiKey = env[provider.apiKeyEnv]
343
+ if (!apiKey) {
344
+ const err = new Error(`presets: provider "${id}" requires env var ${provider.apiKeyEnv}`)
345
+ err.code = 'PANTHEON_MISSING_API_KEY'
346
+ err.envVar = provider.apiKeyEnv
347
+ throw err
348
+ }
349
+ config.provider ??= {}
350
+ config.provider[id] ??= {}
351
+ config.provider[id].options ??= {}
352
+ config.provider[id].options.baseURL = provider.baseURL
353
+ config.provider[id].options.apiKey = apiKey
354
+ }
355
+
356
+ for (const [agent, spec] of Object.entries(resolved.agents ?? {})) {
357
+ config.agent ??= {}
358
+ config.agent[agent] ??= {}
359
+ config.agent[agent].model = spec.model
360
+ const { variant } = normalizeCapability(spec.model, spec.reasoning_effort)
361
+ if (variant === null) {
362
+ delete config.agent[agent].variant
363
+ } else {
364
+ config.agent[agent].variant = variant
365
+ }
366
+ if (spec.fallback_models !== undefined) {
367
+ config.agent[agent].fallback_models = [...spec.fallback_models]
368
+ }
369
+ }
370
+
371
+ return config
372
+ }
373
+
374
+ /**
375
+ * Resolve the active preset and apply it to an opencode config in ONE call —
376
+ * the entry point used by the plugin `config` hook (P1-1) and CLI flows.
377
+ *
378
+ * Fail-safe: without an active preset (env unset, no candidate file,
379
+ * "none") the config is returned UNMUTATED and null is returned. When a
380
+ * preset resolves but its provider key env var is missing, `applyPreset`
381
+ * throws PANTHEON_MISSING_API_KEY — the hook catches and skips.
382
+ *
383
+ * Default candidates mirror the plugin hook (project .pantheon file); the
384
+ * plugin passes the full project/XDG/HOME list via `activePresetCandidates`
385
+ * (vision.ts) for exact agreement with the native vision model resolution.
386
+ *
387
+ * @param {object} config opencode config object (mutated on success)
388
+ * @param {object} [opts]
389
+ * @param {Record<string, string|undefined>} [opts.env]
390
+ * @param {Array<string|null|undefined>} [opts.candidates]
391
+ * @param {string} [opts.routingPath]
392
+ * @param {{warn?: Function, log?: Function, error?: Function}} [opts.logger]
393
+ * @returns {ResolvedPreset|null}
394
+ */
395
+ export function applyActivePresetToConfig(
396
+ config,
397
+ { env = process.env, candidates, routingPath, logger } = {},
398
+ ) {
399
+ const resolved = resolveActivePreset({
400
+ env,
401
+ candidates: candidates ?? [join(process.cwd(), '.pantheon', 'active-preset.json')],
402
+ routingPath,
403
+ logger,
404
+ })
405
+ if (!resolved) return null
406
+ applyPreset(config, resolved, { env })
407
+ return resolved
408
+ }
409
+
410
+ const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/.+$/
411
+
412
+ /**
413
+ * Validate preset definitions against the schema.
414
+ *
415
+ * @param {Record<string, object>} presets
416
+ * @param {{agents?: string[]}} [opts] known agent names (routing minus legacy)
417
+ * @returns {{ok: boolean, errors: string[], warnings: string[]}}
418
+ */
419
+ export function validatePresetDefs(presets, { agents } = {}) {
420
+ const errors = []
421
+ const warnings = []
422
+ const knownAgents = agents ? new Set(agents) : null
423
+
424
+ for (const [name, def] of Object.entries(presets)) {
425
+ if (name === 'none') {
426
+ errors.push(`preset "${name}": name "none" is reserved`)
427
+ continue
428
+ }
429
+ if (!def || typeof def !== 'object' || Array.isArray(def)) {
430
+ errors.push(`preset "${name}": definition must be a plain object`)
431
+ continue
432
+ }
433
+
434
+ if (typeof def.description === 'string' && !/\b(19|20)\d{2}\b/.test(def.description)) {
435
+ warnings.push(`preset "${name}": description missing a 4-digit year`)
436
+ }
437
+
438
+ for (const [id, provider] of Object.entries(def.providers ?? {})) {
439
+ if (!provider || typeof provider !== 'object' || Array.isArray(provider)) {
440
+ errors.push(`preset "${name}": provider "${id}" must be an object`)
441
+ continue
442
+ }
443
+ if (!provider.baseURL) {
444
+ errors.push(`preset "${name}": provider "${id}" missing baseURL`)
445
+ } else if (!/^https?:\/\//.test(provider.baseURL)) {
446
+ errors.push(`preset "${name}": provider "${id}" baseURL must start with http(s)://`)
447
+ }
448
+ if (typeof provider.apiKeyEnv !== 'string' || provider.apiKeyEnv.length === 0) {
449
+ errors.push(`preset "${name}": provider "${id}" apiKeyEnv must be a non-empty string`)
450
+ } else if (!provider.apiKeyEnv.startsWith('PANTHEON_')) {
451
+ warnings.push(`preset "${name}": provider "${id}" apiKeyEnv should start with PANTHEON_`)
452
+ }
453
+ }
454
+
455
+ for (const [agent, spec] of Object.entries(def.agents ?? {})) {
456
+ if (knownAgents && !knownAgents.has(agent)) {
457
+ errors.push(`preset "${name}": unknown agent "${agent}"`)
458
+ }
459
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
460
+ errors.push(`preset "${name}": agent "${agent}" spec must be an object`)
461
+ continue
462
+ }
463
+ if (typeof spec.model !== 'string' || !MODEL_RE.test(spec.model)) {
464
+ errors.push(
465
+ `preset "${name}": agent "${agent}" model must match provider/model (got "${spec.model}")`,
466
+ )
467
+ } else {
468
+ try {
469
+ capabilityEntry(spec.model)
470
+ } catch (err) {
471
+ errors.push(`preset "${name}": agent "${agent}" ${err.message}`)
472
+ }
473
+ }
474
+ if (
475
+ spec.reasoning_effort !== undefined &&
476
+ !Object.hasOwn(EFFORT_RANK, spec.reasoning_effort)
477
+ ) {
478
+ errors.push(
479
+ `preset "${name}": agent "${agent}" reasoning_effort must be one of low, medium, high`,
480
+ )
481
+ }
482
+ if (spec.fallback_models !== undefined) {
483
+ if (
484
+ !Array.isArray(spec.fallback_models) ||
485
+ spec.fallback_models.some((m) => typeof m !== 'string')
486
+ ) {
487
+ errors.push(
488
+ `preset "${name}": agent "${agent}" fallback_models must be an array of strings`,
489
+ )
490
+ }
491
+ }
492
+ }
493
+
494
+ // Optional top-level vision fallback (routes image turns to a
495
+ // vision-capable model when the agent models are text-only).
496
+ if (def.vision !== undefined) {
497
+ const v = def.vision
498
+ if (!v || typeof v !== 'object' || Array.isArray(v)) {
499
+ errors.push(
500
+ `preset "${name}": vision must be an object with model (+ optional reasoning_effort)`,
501
+ )
502
+ } else {
503
+ if (typeof v.model !== 'string' || !MODEL_RE.test(v.model)) {
504
+ errors.push(`preset "${name}": vision.model must match provider/model (got "${v.model}")`)
505
+ } else {
506
+ try {
507
+ const entry = capabilityEntry(v.model)
508
+ if (!entry.vision) {
509
+ errors.push(`preset "${name}": vision model "${v.model}" is not vision-capable`)
510
+ }
511
+ } catch (err) {
512
+ errors.push(`preset "${name}": vision ${err.message}`)
513
+ }
514
+ // The vision model's provider (segment before the first '/') MUST
515
+ // be declared in the preset's providers block (council 2026-08-02):
516
+ // a vision fallback is unusable if applyPreset never injects the
517
+ // provider config for it.
518
+ const provider = v.model.slice(0, v.model.indexOf('/'))
519
+ const declared = Object.keys(def.providers ?? {})
520
+ if (!declared.includes(provider)) {
521
+ errors.push(
522
+ `preset "${name}": vision model ${v.model} uses provider ${provider} not declared in providers`,
523
+ )
524
+ }
525
+ }
526
+ if (v.reasoning_effort !== undefined && !Object.hasOwn(EFFORT_RANK, v.reasoning_effort)) {
527
+ errors.push(`preset "${name}": vision reasoning_effort must be one of low, medium, high`)
528
+ }
529
+ }
530
+ } else {
531
+ // No vision fallback declared: warn when every primary agent model is
532
+ // text-only — image turns would have no vision-capable route.
533
+ const agentModels = Object.values(def.agents ?? {})
534
+ .map((s) => (s && typeof s === 'object' ? s.model : undefined))
535
+ .filter((m) => typeof m === 'string')
536
+ if (
537
+ agentModels.length > 0 &&
538
+ agentModels.every((m) => {
539
+ try {
540
+ return !hasVision(m)
541
+ } catch {
542
+ return true
543
+ }
544
+ })
545
+ ) {
546
+ warnings.push(
547
+ `preset "${name}": all primary agent models are text-only — consider adding a vision fallback`,
548
+ )
549
+ }
550
+ }
551
+ }
552
+
553
+ return { ok: errors.length === 0, errors, warnings }
554
+ }