@lunchbox-labs/opencode-model-profiles 0.0.0-stage → 0.1.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.
package/dist/server.js ADDED
@@ -0,0 +1,280 @@
1
+ import { modelsFilePath, resolveModelProfiles, stripModelRefs, } from "./apply.js";
2
+ import { assignmentsAreStale, availabilityKey, availableModels, fallbackModel, knownAvailableModels, knownRefModels, readConnectedProviders, recentModels, rememberAvailableModels, } from "./fallback.js";
3
+ import { isRecord } from "./models-file.js";
4
+ import { publishReport } from "./report.js";
5
+ import { selectionFilePath, stateDirectory, worktreeRoot, } from "./selection.js";
6
+ const logService = "model-profiles";
7
+ /** Availability each directory was last reloaded for, so a reload that changes nothing is never repeated. */
8
+ const reloadedFor = new Map();
9
+ function modelName(value) {
10
+ if (isRecord(value) &&
11
+ typeof value.providerID === "string" &&
12
+ typeof value.modelID === "string") {
13
+ return `${value.providerID}/${value.modelID}`;
14
+ }
15
+ // OpenCode stores a session's model as { providerID, id }.
16
+ if (isRecord(value) &&
17
+ typeof value.providerID === "string" &&
18
+ typeof value.id === "string") {
19
+ return `${value.providerID}/${value.id}`;
20
+ }
21
+ return undefined;
22
+ }
23
+ /**
24
+ * Keeps agents usable when a ref names a model no connected provider offers. The config hook runs before
25
+ * providers load, so it cannot check models itself: it uses the availability saved by an earlier check, and until
26
+ * there is one, a prompt to such a model is moved to the model OpenCode would use without the ref. When the
27
+ * applied refs no longer match what is available, the project reloads once nothing is running, so the config hook
28
+ * leaves those refs unapplied (which also covers built-in agents such as compaction that never pass through a
29
+ * prompt).
30
+ */
31
+ function createFallbacks(input, getApplied) {
32
+ const { client, directory } = input;
33
+ /** Each session's model after this hook, for subagents that inherit their parent's. */
34
+ const sessionModels = new Map();
35
+ let reloadWanted = false;
36
+ let reloading = false;
37
+ let checkedOnce = false;
38
+ let lastAvailable;
39
+ const log = (message) => client.app
40
+ .log({
41
+ body: { service: logService, level: "warn", message },
42
+ query: { directory },
43
+ })
44
+ .catch(() => undefined);
45
+ const check = async () => {
46
+ const response = await client.config.providers({ query: { directory } });
47
+ const connected = response.error === undefined
48
+ ? readConnectedProviders(response.data)
49
+ : undefined;
50
+ if (connected === undefined) {
51
+ return undefined;
52
+ }
53
+ const available = availableModels(connected);
54
+ lastAvailable = available;
55
+ await rememberAvailableModels(stateDirectory(), directory, available, getApplied().assignments.map((assignment) => assignment.model));
56
+ if (assignmentsAreStale(getApplied().assignments, available) &&
57
+ reloadedFor.get(directory) !== availabilityKey(available)) {
58
+ reloadWanted = true;
59
+ }
60
+ return { connected, available };
61
+ };
62
+ const parentModel = async (sessionID) => {
63
+ const session = await client.session.get({
64
+ path: { id: sessionID },
65
+ query: { directory },
66
+ });
67
+ const parentID = isRecord(session.data) ? session.data.parentID : undefined;
68
+ if (typeof parentID !== "string") {
69
+ return undefined;
70
+ }
71
+ const remembered = sessionModels.get(parentID);
72
+ if (remembered !== undefined) {
73
+ return remembered;
74
+ }
75
+ const parent = await client.session.get({
76
+ path: { id: parentID },
77
+ query: { directory },
78
+ });
79
+ // OpenCode 1.19 includes the session's model, which the SDK's type does not list yet.
80
+ const data = parent.data;
81
+ return isRecord(data) ? modelName(data.model) : undefined;
82
+ };
83
+ const checkMessage = async (hookInput, message) => {
84
+ const applied = getApplied();
85
+ const requested = modelName(message.model);
86
+ const sessionID = typeof hookInput.sessionID === "string" ? hookInput.sessionID : undefined;
87
+ const result = await check();
88
+ if (result === undefined || requested === undefined) {
89
+ return;
90
+ }
91
+ const { connected, available } = result;
92
+ const remember = (model) => {
93
+ if (sessionID !== undefined) {
94
+ sessionModels.set(sessionID, model);
95
+ }
96
+ };
97
+ if (available.has(requested)) {
98
+ remember(requested);
99
+ return;
100
+ }
101
+ // A model the caller chose (as the task tool does for a subagent) is moved only when a ref put it there. One
102
+ // OpenCode took from the agent or the session is moved either way, because OpenCode stores a session's model
103
+ // before this hook runs, so a session keeps asking for it after the ref is fixed or left out.
104
+ const assignment = applied.assignments.find((candidate) => candidate.agent === message.agent && candidate.model === requested) ??
105
+ applied.assignments.find((candidate) => candidate.model === requested);
106
+ if (assignment === undefined &&
107
+ (hookInput.model !== undefined ||
108
+ !knownRefModels(directory).has(requested))) {
109
+ return;
110
+ }
111
+ const own = assignment !== undefined &&
112
+ assignment.agent === message.agent &&
113
+ assignment.ownModel !== undefined
114
+ ? {
115
+ model: assignment.ownModel,
116
+ ...(assignment.ownVariant === undefined
117
+ ? {}
118
+ : { variant: assignment.ownVariant }),
119
+ }
120
+ : undefined;
121
+ const inherited = sessionID === undefined
122
+ ? undefined
123
+ : await parentModel(sessionID).catch(() => undefined);
124
+ const fallback = fallbackModel({
125
+ ...(own === undefined ? {} : { own }),
126
+ ...(inherited === undefined ? {} : { parentModel: inherited }),
127
+ available,
128
+ connected,
129
+ ...(applied.configuredModel === undefined
130
+ ? {}
131
+ : { configuredModel: applied.configuredModel }),
132
+ configuredProviders: applied.configuredProviders,
133
+ recent: await recentModels(stateDirectory()),
134
+ });
135
+ if (fallback === undefined) {
136
+ return;
137
+ }
138
+ message.model = fallback;
139
+ remember(`${fallback.providerID}/${fallback.modelID}`);
140
+ const agent = typeof message.agent === "string" ? message.agent : "the agent";
141
+ await log(assignment === undefined
142
+ ? `${agent} asked for ${requested}, which no connected provider offers; it uses ${fallback.providerID}/${fallback.modelID} instead.`
143
+ : `Ref "${assignment.ref}" uses ${requested}, which no connected provider offers; ${agent} uses ${fallback.providerID}/${fallback.modelID} instead.`);
144
+ };
145
+ /** Reloads only while no session is busy and no terminal is open, since a reload ends running terminals. */
146
+ const reloadWhenIdle = async () => {
147
+ const status = await client.session.status({ query: { directory } });
148
+ if (status.error !== undefined ||
149
+ !isRecord(status.data) ||
150
+ Object.values(status.data).some((session) => !isRecord(session) || session.type !== "idle")) {
151
+ return;
152
+ }
153
+ const terminals = await client.pty.list({ query: { directory } });
154
+ if (terminals.error !== undefined ||
155
+ !Array.isArray(terminals.data) ||
156
+ terminals.data.length > 0) {
157
+ return;
158
+ }
159
+ const available = lastAvailable;
160
+ const disposed = await client.instance.dispose({ query: { directory } });
161
+ if (disposed.error === undefined) {
162
+ reloadWanted = false;
163
+ if (available !== undefined) {
164
+ reloadedFor.set(directory, availabilityKey(available));
165
+ }
166
+ }
167
+ };
168
+ const scheduleReload = () => {
169
+ if (!reloadWanted || reloading) {
170
+ return;
171
+ }
172
+ reloading = true;
173
+ // Disposing from inside the event that reports idleness would wait on this very hook.
174
+ setTimeout(() => {
175
+ reloadWhenIdle()
176
+ .catch(() => undefined)
177
+ .finally(() => {
178
+ reloading = false;
179
+ });
180
+ }, 0);
181
+ };
182
+ return {
183
+ message: async (hookInput, message) => {
184
+ if (isRecord(message)) {
185
+ await checkMessage(hookInput, message);
186
+ }
187
+ },
188
+ forget: (sessionID) => {
189
+ sessionModels.delete(sessionID);
190
+ },
191
+ event: (type) => {
192
+ if (!checkedOnce) {
193
+ // Saves availability before the first prompt too, so the next start leaves unavailable refs out. Reloading
194
+ // waits for an idle session, since the project may still be starting.
195
+ checkedOnce = true;
196
+ setTimeout(() => {
197
+ check().catch(() => undefined);
198
+ }, 0);
199
+ }
200
+ if (type === "session.idle") {
201
+ scheduleReload();
202
+ }
203
+ },
204
+ };
205
+ }
206
+ const ModelProfilesServer = (input, options) => {
207
+ const worktree = worktreeRoot(input.worktree, input.directory);
208
+ let applied = { assignments: [], configuredProviders: [] };
209
+ const fallbacks = createFallbacks(input, () => applied);
210
+ return Promise.resolve({
211
+ config: async (config) => {
212
+ const target = config;
213
+ const configFile = modelsFilePath(options);
214
+ try {
215
+ const available = await knownAvailableModels(stateDirectory(), input.directory);
216
+ const resolved = await resolveModelProfiles(target, {
217
+ configFile,
218
+ worktree,
219
+ ...(available === undefined ? {} : { availableModels: available }),
220
+ });
221
+ applied = {
222
+ assignments: resolved.assignments,
223
+ ...(typeof target.model === "string"
224
+ ? { configuredModel: target.model }
225
+ : {}),
226
+ configuredProviders: isRecord(target.provider)
227
+ ? Object.keys(target.provider)
228
+ : [],
229
+ };
230
+ publishReport(target, options, resolved.report);
231
+ }
232
+ catch (error) {
233
+ try {
234
+ applied = { assignments: [], configuredProviders: [] };
235
+ stripModelRefs(target);
236
+ publishReport(target, options, {
237
+ version: 1,
238
+ configFile,
239
+ selectionFile: selectionFilePath(stateDirectory(), worktree),
240
+ worktree,
241
+ profiles: [],
242
+ profile: null,
243
+ profileSource: "none",
244
+ refs: [],
245
+ notices: [
246
+ `Model refs were not applied (${error instanceof Error ? error.message : String(error)}); agents keep OpenCode's models.`,
247
+ ],
248
+ });
249
+ }
250
+ catch {
251
+ // A failing config hook must never stop OpenCode; agents then keep OpenCode's models.
252
+ }
253
+ }
254
+ },
255
+ "chat.message": async (hookInput, output) => {
256
+ try {
257
+ await fallbacks.message(hookInput, output.message);
258
+ }
259
+ catch {
260
+ // A failed check leaves the prompt as OpenCode built it.
261
+ }
262
+ },
263
+ event: ({ event }) => {
264
+ try {
265
+ fallbacks.event(event.type);
266
+ if (event.type === "session.deleted") {
267
+ fallbacks.forget(event.properties.info.id);
268
+ }
269
+ }
270
+ catch {
271
+ // Fallback checks never stop OpenCode's events.
272
+ }
273
+ return Promise.resolve();
274
+ },
275
+ });
276
+ };
277
+ export default {
278
+ id: "model-profiles",
279
+ server: ModelProfilesServer,
280
+ };
package/dist/tui.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { TuiPlugin } from "@opencode-ai/plugin/tui";
2
+ declare const _default: {
3
+ id: string;
4
+ tui: TuiPlugin;
5
+ };
6
+ export default _default;
package/dist/tui.js ADDED
@@ -0,0 +1,204 @@
1
+ import { fallbackToastLines, modelConfigOptions, profileLabel, selectedChoice, selectedNotice, unavailableModelNotices, } from "./model-config-view.js";
2
+ import { loadModelsDocument } from "./models-file.js";
3
+ import { readReport } from "./report.js";
4
+ import { clearSelection, processProfileVariable, readSavedSelection, saveSelection, } from "./selection.js";
5
+ const toastTitle = "Model Profiles";
6
+ function errorText(error) {
7
+ return error instanceof Error ? error.message : String(error);
8
+ }
9
+ function registerModelConfig(api) {
10
+ const directory = () => api.state.path.directory;
11
+ let shownNotices = new Set();
12
+ let reading = Promise.resolve();
13
+ let reloadsInFlight = 0;
14
+ const toast = (variant, lines) => {
15
+ api.ui.toast({
16
+ variant,
17
+ title: toastTitle,
18
+ message: lines.join("\n"),
19
+ duration: variant === "warning" ? 10_000 : 5_000,
20
+ });
21
+ };
22
+ /** Reads what the server applied; a fallback is new until it disappears from a later report. */
23
+ const readApplied = () => {
24
+ const next = reading.then(async () => {
25
+ const response = await api.client.config.get({ directory: directory() });
26
+ const report = response.error === undefined ? readReport(response.data) : undefined;
27
+ if (report === undefined) {
28
+ return undefined;
29
+ }
30
+ const notices = [
31
+ ...unavailableModelNotices(report.refs, api.state.provider),
32
+ ...report.notices,
33
+ ];
34
+ const newNotices = notices.filter((notice) => !shownNotices.has(notice));
35
+ shownNotices = new Set(notices);
36
+ return { report, notices, newNotices };
37
+ });
38
+ reading = next.catch(() => undefined);
39
+ return next;
40
+ };
41
+ const announceFallbacks = async () => {
42
+ const applied = await readApplied();
43
+ if (applied !== undefined && applied.newNotices.length > 0) {
44
+ toast("warning", fallbackToastLines(applied.newNotices));
45
+ }
46
+ };
47
+ const reload = async (label) => {
48
+ const status = await api.client.session.status({ directory: directory() });
49
+ if (status.error !== undefined) {
50
+ toast("warning", [
51
+ `Saved ${label} for this worktree, but OpenCode could not report whether a session is busy, so it did not reload. It applies on the next reload.`,
52
+ ]);
53
+ return;
54
+ }
55
+ if (Object.values(status.data).some((session) => session.type !== "idle")) {
56
+ toast("info", [
57
+ `Saved ${label} for this worktree. A session is busy, so it applies the next time OpenCode reloads this project, or pick it again in /model-config once the session is idle.`,
58
+ ]);
59
+ return;
60
+ }
61
+ reloadsInFlight += 1;
62
+ const disposed = await api.client.instance
63
+ .dispose({ directory: directory() })
64
+ .finally(() => {
65
+ reloadsInFlight -= 1;
66
+ });
67
+ if (disposed.error !== undefined) {
68
+ toast("warning", [
69
+ `Saved ${label}, but OpenCode did not reload. It applies on the next reload.`,
70
+ ]);
71
+ return;
72
+ }
73
+ const applied = await readApplied();
74
+ if (applied === undefined) {
75
+ toast("success", [`Saved ${label}.`]);
76
+ return;
77
+ }
78
+ toast(applied.newNotices.length > 0 ? "warning" : "success", [
79
+ `Using ${profileLabel(applied.report.profile)}.`,
80
+ ...fallbackToastLines(applied.newNotices),
81
+ ]);
82
+ };
83
+ const choose = async (report, choice, defaultProfile) => {
84
+ const label = choice.kind === "save"
85
+ ? profileLabel(choice.profile)
86
+ : `defaultProfile (${defaultProfile ?? "none"})`;
87
+ try {
88
+ await (choice.kind === "save"
89
+ ? saveSelection(report.selectionFile, report.worktree, choice.profile)
90
+ : clearSelection(report.selectionFile));
91
+ }
92
+ catch (error) {
93
+ toast("warning", [`Could not save ${label}: ${errorText(error)}`]);
94
+ return;
95
+ }
96
+ if (report.environmentProfile !== undefined) {
97
+ toast("info", [
98
+ `Saved ${label} for this worktree. ${processProfileVariable}=${report.environmentProfile} still applies to this OpenCode process.`,
99
+ ]);
100
+ return;
101
+ }
102
+ try {
103
+ await reload(label);
104
+ }
105
+ catch (error) {
106
+ toast("warning", [
107
+ `Saved ${label}, but could not reload OpenCode (${errorText(error)}). It applies on the next reload.`,
108
+ ]);
109
+ }
110
+ };
111
+ const open = async () => {
112
+ const applied = await readApplied().catch(() => undefined);
113
+ if (applied === undefined) {
114
+ toast("warning", [
115
+ "The server plugin has not reported model refs. Add @lunchbox-labs/opencode-model-profiles to plugin in opencode.jsonc.",
116
+ ]);
117
+ return;
118
+ }
119
+ const { report, notices } = applied;
120
+ const [current, saved] = await Promise.all([
121
+ loadModelsDocument(report.configFile),
122
+ readSavedSelection(report.selectionFile),
123
+ ]);
124
+ const loaded = current.status === "loaded";
125
+ const defaultProfile = loaded ? current.document.defaultProfile : undefined;
126
+ api.ui.dialog.replace(() => api.ui.DialogSelect({
127
+ title: "Model config",
128
+ options: modelConfigOptions({
129
+ report,
130
+ profiles: loaded
131
+ ? Object.keys(current.document.profiles)
132
+ : report.profiles,
133
+ savedProfile: saved.profile,
134
+ defaultProfile,
135
+ notices,
136
+ }),
137
+ current: report.profile === null ? "defaults" : `profile:${report.profile}`,
138
+ onSelect: (option) => {
139
+ const notice = selectedNotice(option.value, notices);
140
+ if (notice !== undefined) {
141
+ toast("warning", [notice]);
142
+ return;
143
+ }
144
+ const choice = selectedChoice(option.value);
145
+ if (choice === undefined) {
146
+ return;
147
+ }
148
+ api.ui.dialog.clear();
149
+ choose(report, choice, defaultProfile).catch(() => undefined);
150
+ },
151
+ }));
152
+ api.ui.dialog.setSize("large");
153
+ };
154
+ const unregister = api.keymap.registerLayer({
155
+ commands: [
156
+ {
157
+ name: "model-profiles.config",
158
+ title: "Model config",
159
+ category: "Model",
160
+ namespace: "palette",
161
+ slashName: "model-config",
162
+ run: () => {
163
+ open().catch(() => undefined);
164
+ },
165
+ },
166
+ ],
167
+ });
168
+ const unsubscribeInstance = api.event.on("server.instance.disposed", (event) => {
169
+ if (reloadsInFlight === 0 && event.properties.directory === directory()) {
170
+ announceFallbacks().catch(() => undefined);
171
+ }
172
+ });
173
+ const unsubscribeGlobal = api.event.on("global.disposed", () => {
174
+ announceFallbacks().catch(() => undefined);
175
+ });
176
+ /** Models are checked against connected providers, which may not have loaded when the TUI starts. */
177
+ let providerChecks = 0;
178
+ const providerTimer = setInterval(() => {
179
+ providerChecks += 1;
180
+ if (api.state.provider.length > 0) {
181
+ clearInterval(providerTimer);
182
+ announceFallbacks().catch(() => undefined);
183
+ }
184
+ else if (providerChecks >= 60) {
185
+ clearInterval(providerTimer);
186
+ }
187
+ }, 500);
188
+ providerTimer.unref();
189
+ api.lifecycle.onDispose(() => {
190
+ clearInterval(providerTimer);
191
+ unregister();
192
+ unsubscribeInstance();
193
+ unsubscribeGlobal();
194
+ });
195
+ announceFallbacks().catch(() => undefined);
196
+ }
197
+ const ModelProfilesTui = (api) => {
198
+ registerModelConfig(api);
199
+ return Promise.resolve();
200
+ };
201
+ export default {
202
+ id: "model-profiles",
203
+ tui: ModelProfilesTui,
204
+ };
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "@lunchbox-labs/opencode-model-profiles",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Pick agent models by name, with defaults and per-worktree profiles",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/lunchbox-labs/opencode-model-profiles.git"
10
+ },
11
+ "engines": {
12
+ "node": ">=22.18.0"
13
+ },
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/server.d.ts",
17
+ "default": "./dist/server.js"
18
+ },
19
+ "./server": {
20
+ "types": "./dist/server.d.ts",
21
+ "default": "./dist/server.js"
22
+ },
23
+ "./tui": {
24
+ "types": "./dist/tui.d.ts",
25
+ "default": "./dist/tui.js"
26
+ },
27
+ "./schema.json": "./schema.json"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "schema.json",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.build.json",
37
+ "typecheck": "tsc -p tsconfig.json",
38
+ "test": "node --experimental-strip-types --test tests/*.test.ts tests/*.test.mjs",
39
+ "lint": "eslint . --max-warnings 0",
40
+ "format": "prettier --write src tests scripts package.json tsconfig*.json *.config.mjs schema.json",
41
+ "format:check": "prettier --check src tests scripts package.json tsconfig*.json *.config.mjs schema.json",
42
+ "check": "npm run check:static && npm test",
43
+ "prepack": "npm run build",
44
+ "test:package": "npm run build && node --experimental-strip-types --test tests/package.integration.ts",
45
+ "check:static": "concurrently --names typecheck,lint,format --kill-others-on-fail \"npm run typecheck\" \"npm run lint\" \"npm run format:check\"",
46
+ "prebuild": "node --input-type=module -e \"import { rm } from 'node:fs/promises'; await rm('dist', { recursive: true, force: true });\""
47
+ },
48
+ "devDependencies": {
49
+ "@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
50
+ "@eslint/js": "10.0.1",
51
+ "@opencode-ai/plugin": "1.19.5",
52
+ "@opencode-ai/sdk": "1.19.5",
53
+ "@opentui/core": "0.4.5",
54
+ "@opentui/keymap": "0.4.5",
55
+ "@opentui/solid": "0.4.5",
56
+ "@types/node": "24.19.2",
57
+ "concurrently": "10.0.6",
58
+ "eslint": "10.14.0",
59
+ "eslint-config-prettier": "10.1.8",
60
+ "eslint-plugin-regexp": "3.3.3",
61
+ "globals": "17.14.0",
62
+ "prettier": "3.9.10",
63
+ "solid-js": "1.9.12",
64
+ "typescript": "6.0.3",
65
+ "typescript-eslint": "8.72.1",
66
+ "yaml": "2.9.1"
67
+ },
68
+ "publishConfig": {
69
+ "access": "public",
70
+ "registry": "https://registry.npmjs.org"
71
+ },
72
+ "dependencies": {
73
+ "jsonc-parser": "3.3.1"
74
+ }
75
+ }
package/schema.json ADDED
@@ -0,0 +1,97 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://cdn.jsdelivr.net/npm/@lunchbox-labs/opencode-model-profiles/schema.json",
4
+ "title": "OpenCode Model Profiles",
5
+ "description": "Named model refs for OpenCode agents: defaults, per-worktree profiles and built-in agent refs.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string"
11
+ },
12
+ "defaultProfile": {
13
+ "description": "Profile used when no profile is saved for the worktree and OPENCODE_MODEL_PROFILE is unset.",
14
+ "type": "string",
15
+ "minLength": 1
16
+ },
17
+ "defaults": {
18
+ "description": "Model for each ref when the selected profile does not define it.",
19
+ "$ref": "#/$defs/refs"
20
+ },
21
+ "profiles": {
22
+ "description": "Profiles by name, each mapping ref names to models.",
23
+ "type": "object",
24
+ "propertyNames": {
25
+ "minLength": 1
26
+ },
27
+ "additionalProperties": {
28
+ "$ref": "#/$defs/refs"
29
+ }
30
+ },
31
+ "builtins": {
32
+ "description": "Ref for each built-in OpenCode agent. Built-in agents without a ref keep OpenCode's default model.",
33
+ "type": "object",
34
+ "additionalProperties": false,
35
+ "properties": {
36
+ "build": {
37
+ "$ref": "#/$defs/refName"
38
+ },
39
+ "plan": {
40
+ "$ref": "#/$defs/refName"
41
+ },
42
+ "general": {
43
+ "$ref": "#/$defs/refName"
44
+ },
45
+ "explore": {
46
+ "$ref": "#/$defs/refName"
47
+ },
48
+ "compaction": {
49
+ "$ref": "#/$defs/refName"
50
+ },
51
+ "title": {
52
+ "$ref": "#/$defs/refName"
53
+ },
54
+ "summary": {
55
+ "$ref": "#/$defs/refName"
56
+ }
57
+ }
58
+ }
59
+ },
60
+ "$defs": {
61
+ "refName": {
62
+ "type": "string",
63
+ "minLength": 1
64
+ },
65
+ "modelIdentifier": {
66
+ "type": "string",
67
+ "pattern": "^[^\\s/]+/\\S+$"
68
+ },
69
+ "refs": {
70
+ "type": "object",
71
+ "propertyNames": {
72
+ "minLength": 1
73
+ },
74
+ "additionalProperties": {
75
+ "oneOf": [
76
+ {
77
+ "$ref": "#/$defs/modelIdentifier"
78
+ },
79
+ {
80
+ "type": "object",
81
+ "additionalProperties": false,
82
+ "required": ["model"],
83
+ "properties": {
84
+ "model": {
85
+ "$ref": "#/$defs/modelIdentifier"
86
+ },
87
+ "variant": {
88
+ "type": "string",
89
+ "minLength": 1
90
+ }
91
+ }
92
+ }
93
+ ]
94
+ }
95
+ }
96
+ }
97
+ }