pantheon-opencode 1.2.0 → 1.2.2
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/AGENTS.md +9 -0
- package/README.md +112 -15
- package/bin/pantheon-init.mjs +93 -0
- package/commands/pantheon-vision.md +67 -0
- package/opencode.json +14 -10
- package/package.json +20 -4
- package/scripts/_pantheon_paths.py +0 -2
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +307 -15
- package/scripts/hooks/audit-imports.sh +43 -0
- package/scripts/hooks/format-multi-language.sh +96 -0
- package/scripts/hooks/log-session-start.sh +24 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
- package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
- package/scripts/hooks/run-type-check.sh +21 -0
- package/scripts/hooks/scan-secrets.sh +55 -0
- package/scripts/hooks/validate-post-conditions.sh +21 -0
- package/scripts/hooks/validate-talos-scope.sh +43 -0
- package/scripts/hooks/validate-tool-safety.sh +46 -0
- package/scripts/install/health-check.mjs +101 -1
- package/scripts/install/model-picker.mjs +118 -0
- package/scripts/install/opencode.mjs +155 -43
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install/venv.mjs +25 -2
- package/scripts/install-mcp.mjs +170 -37
- package/scripts/mcp_persistence_server.py +2 -1
- package/scripts/prune.mjs +359 -0
- package/scripts/secret-scan.mjs +88 -0
- package/scripts/uninstall.mjs +3 -3
- package/scripts/validate-routing.mjs +15 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/agents/hermes.md +2 -2
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +1 -1
- package/src/mcp/_pantheon_paths.py +0 -2
- package/src/mcp/install-mcp.mjs +6 -913
- package/src/mcp/mcp_persistence_server.py +2 -1
- package/src/mcp/pantheon_vision_server.py +495 -0
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -0
- package/src/pantheon/auto-wake.ts +91 -0
- package/src/pantheon/background-job-board.ts +568 -0
- package/src/pantheon/file-persistence.ts +95 -0
- package/src/pantheon/presets.d.mts +108 -0
- package/src/pantheon/presets.mjs +554 -0
- package/src/pantheon/vision.ts +1437 -0
- package/src/plugin.ts +69 -25
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +153 -0
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +812 -223
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +881 -121
- package/src/plugins/tui/package.json +3 -3
- package/src/plugins/tui/src/index.tsx +881 -121
- package/src/routing.yml +148 -0
- package/src/skills/context-compression/SKILL.md +1 -1
- package/src/skills/loop-engineering/SKILL.md +1 -1
- package/src/skills/orchestration-workflow/SKILL.md +11 -11
- package/scripts/scrub_secrets.py +0 -281
- package/src/mcp/requirements-mcp-core.txt +0 -5
|
@@ -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
|
+
}
|