@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lunchbox Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,91 @@
1
- # Temporary Holding Version
1
+ # OpenCode Model Profiles
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Choose agent models by name: give each agent a `modelRef`, map refs to models in one `models.jsonc`, and switch between profiles of concrete models per worktree with `/model-config`.
4
+
5
+ Requires Node **22.18+**. Built for OpenCode **1.19.x**, starting at **1.19.0**.
6
+
7
+ ## Install
8
+
9
+ Add the package to `plugin` in both `opencode.jsonc` (the server plugin resolves models) and `tui.jsonc` (the `/model-config` dialog and fallback toasts):
10
+
11
+ ```jsonc
12
+ { "plugin": ["@lunchbox-labs/opencode-model-profiles"] }
13
+ ```
14
+
15
+ ## Name the model an agent uses
16
+
17
+ Set `modelRef` in an agent's Markdown frontmatter:
18
+
19
+ ```markdown
20
+ ---
21
+ description: Plans and implements changes
22
+ mode: primary
23
+ modelRef: reasoning
24
+ ---
25
+ ```
26
+
27
+ or on an agent entry in `opencode.jsonc`:
28
+
29
+ ```jsonc
30
+ { "agent": { "reviewer": { "modelRef": "reasoning" } } }
31
+ ```
32
+
33
+ A resolved ref sets the agent's `model`, replacing any model the agent sets itself, and its `variant` when the ref names one; otherwise the agent's own `variant` stays. `modelRef` is removed before OpenCode builds the agent, so it never reaches a provider as an option.
34
+
35
+ ## models.jsonc
36
+
37
+ The plugin reads `models.jsonc` from OpenCode's global config directory (`OPENCODE_CONFIG_DIR`, else `$XDG_CONFIG_HOME/opencode`, else `~/.config/opencode`) each time OpenCode loads a project. To use another file, pass `configFile`, resolved against that directory:
38
+
39
+ ```jsonc
40
+ {
41
+ "plugin": [
42
+ [
43
+ "@lunchbox-labs/opencode-model-profiles",
44
+ { "configFile": "models/work.jsonc" },
45
+ ],
46
+ ],
47
+ }
48
+ ```
49
+
50
+ ```jsonc
51
+ {
52
+ "$schema": "https://cdn.jsdelivr.net/npm/@lunchbox-labs/opencode-model-profiles/schema.json",
53
+ "defaults": {
54
+ "reasoning": { "model": "openai/gpt-6-astra", "variant": "high" },
55
+ "coding": "openai/gpt-5.6-sol",
56
+ "system": "openai/gpt-5.6-terra",
57
+ },
58
+ "profiles": {
59
+ "anthropic-latest": {
60
+ "reasoning": { "model": "anthropic/claude-opus-5-5", "variant": "high" },
61
+ "coding": "anthropic/claude-sonnet-5-5",
62
+ },
63
+ },
64
+ "builtins": { "general": "coding", "title": "system" },
65
+ }
66
+ ```
67
+
68
+ - `defaults` maps ref names to models. A model is `"provider/model"` or `{ "model": "provider/model", "variant": "high" }`.
69
+ - `profiles` maps profile names to refs. A ref the selected profile leaves out comes from `defaults`.
70
+ - `builtins` gives refs to OpenCode's built-in agents: `build`, `plan`, `general`, `explore`, `compaction`, `title` and `summary`. An agent entry that sets its own `modelRef` or `model` wins. Built-ins without a ref keep OpenCode's default model choice.
71
+ - `defaultProfile` (optional) names the profile used when none is saved for the worktree.
72
+
73
+ A fuller example is in [docs/examples/models.jsonc](docs/examples/models.jsonc).
74
+
75
+ ## Pick a profile
76
+
77
+ Run `/model-config` (**Model config** in the command palette). It lists **Defaults only** and each profile, with the current one marked along with where it came from, then every ref with the model it resolves to and the agents that use it, then any fallbacks.
78
+
79
+ Picking a profile saves it for the current worktree in OpenCode's state directory and reloads the project so it applies at once. If a session is busy, the choice is saved, marked **saved, applies on reload**, and applies the next time OpenCode reloads the project; pick it again once the session is idle to apply it now. When `defaultProfile` is set and a choice is saved, **Follow defaultProfile** forgets the saved choice.
80
+
81
+ `OPENCODE_MODEL_PROFILE=<profile>` selects a profile for one OpenCode process and overrides the saved choice. `/model-config` shows when it is set; a pick made then is saved for later processes.
82
+
83
+ Precedence: `OPENCODE_MODEL_PROFILE`, then the saved worktree choice, then `defaultProfile`, then defaults only. A profile that `models.jsonc` does not define is skipped in favour of the next.
84
+
85
+ ## Fallbacks
86
+
87
+ Problems in the configuration never stop OpenCode. A missing or unparsable `models.jsonc`, an invalid entry or a ref that resolves nowhere leaves the affected agents as OpenCode would configure them; invalid entries are skipped one by one, so the rest of the file still applies. An undefined profile is skipped as described above.
88
+
89
+ A ref whose model no connected provider offers falls back too, to the model OpenCode would use without the ref: the agent's own `model`, a subagent's parent model, else OpenCode's default. The plugin applies refs before OpenCode loads providers, so it saves which models were available in OpenCode's state directory and leaves unavailable refs out from the next start. Until it has checked once, prompts to such a model are moved to the fallback, and once no session is busy and no terminal is open the plugin reloads the project, which restarts its MCP and LSP servers. That covers built-in agents such as `compaction` and `title` that never receive a prompt of their own; in the first process that meets a broken ref they, and commands that name the agent, can still fail until that reload. A prompt that reuses a ref's unavailable model after the ref is fixed falls back too, while a model you set on an agent yourself is left as OpenCode would use it. If a provider connects after the last check, its refs apply again after the first reload.
90
+
91
+ The TUI shows a warning toast with the first new problem and how many more there are. A problem is toasted once per OpenCode launch, and again after it changes; `/model-config` lists all current ones.
@@ -0,0 +1,18 @@
1
+ import type { ModelProfilesReport } from "./report.ts";
2
+ import { type RefAssignment } from "./resolution.ts";
3
+ export declare function modelsFilePath(options: unknown, environment?: NodeJS.ProcessEnv): string;
4
+ export interface ResolveInput {
5
+ configFile: string;
6
+ worktree: string;
7
+ environment?: NodeJS.ProcessEnv;
8
+ /** Models connected providers offered when last checked; refs to others are not applied. Unknown until checked. */
9
+ availableModels?: ReadonlySet<string>;
10
+ }
11
+ export interface ResolvedModelProfiles {
12
+ report: ModelProfilesReport;
13
+ assignments: RefAssignment[];
14
+ }
15
+ /** Replaces `config.agent` with resolved copies and returns what was applied; never throws for bad input files. */
16
+ export declare function resolveModelProfiles(config: Record<string, unknown>, input: ResolveInput): Promise<ResolvedModelProfiles>;
17
+ /** The last resort when resolution fails unexpectedly: OpenCode's models stay, and modelRef never reaches a provider. */
18
+ export declare function stripModelRefs(config: Record<string, unknown>): void;
package/dist/apply.js ADDED
@@ -0,0 +1,71 @@
1
+ import path from "node:path";
2
+ import { isRecord, loadModelsDocument, } from "./models-file.js";
3
+ import { applyModelRefs, selectProfile, } from "./resolution.js";
4
+ import { configurationDirectory, processProfile, readSavedSelection, selectionFilePath, stateDirectory, } from "./selection.js";
5
+ export function modelsFilePath(options, environment = process.env) {
6
+ const directory = configurationDirectory(environment);
7
+ const configured = isRecord(options) ? options.configFile : undefined;
8
+ return typeof configured === "string" && configured.trim() !== ""
9
+ ? path.resolve(directory, configured)
10
+ : path.join(directory, "models.jsonc");
11
+ }
12
+ function usesModelRefs(agents) {
13
+ return (isRecord(agents) &&
14
+ Object.values(agents).some((agent) => isRecord(agent) &&
15
+ (Object.hasOwn(agent, "modelRef") ||
16
+ (isRecord(agent.options) &&
17
+ Object.hasOwn(agent.options, "modelRef")))));
18
+ }
19
+ /** Replaces `config.agent` with resolved copies and returns what was applied; never throws for bad input files. */
20
+ export async function resolveModelProfiles(config, input) {
21
+ const environment = input.environment ?? process.env;
22
+ const selectionFile = selectionFilePath(stateDirectory(environment), input.worktree);
23
+ const loaded = await loadModelsDocument(input.configFile);
24
+ const notices = [...loaded.notices];
25
+ if (loaded.status === "missing" && usesModelRefs(config.agent)) {
26
+ notices.push(`No models file at ${input.configFile}; agents with a modelRef keep OpenCode's model.`);
27
+ }
28
+ const environmentProfile = processProfile(environment);
29
+ const saved = await readSavedSelection(selectionFile);
30
+ if (saved.notice !== undefined) {
31
+ notices.push(saved.notice);
32
+ }
33
+ const selection = selectProfile(loaded.document, environmentProfile, saved.profile);
34
+ const applied = applyModelRefs(config.agent, loaded.document, selection.profile, input.availableModels);
35
+ if (loaded.status === "loaded") {
36
+ notices.push(...selection.notices, ...applied.notices);
37
+ }
38
+ if (isRecord(config.agent) || Object.keys(applied.agents).length > 0) {
39
+ config.agent = applied.agents;
40
+ }
41
+ const report = {
42
+ version: 1,
43
+ configFile: input.configFile,
44
+ selectionFile,
45
+ worktree: input.worktree,
46
+ profiles: Object.keys(loaded.document.profiles),
47
+ profile: selection.profile,
48
+ profileSource: selection.source,
49
+ ...(environmentProfile === undefined ? {} : { environmentProfile }),
50
+ refs: applied.refs,
51
+ notices,
52
+ };
53
+ return { report, assignments: applied.assignments };
54
+ }
55
+ /** The last resort when resolution fails unexpectedly: OpenCode's models stay, and modelRef never reaches a provider. */
56
+ export function stripModelRefs(config) {
57
+ if (!isRecord(config.agent)) {
58
+ return;
59
+ }
60
+ config.agent = Object.fromEntries(Object.entries(config.agent).map(([name, agent]) => {
61
+ if (!isRecord(agent)) {
62
+ return [name, agent];
63
+ }
64
+ const { modelRef: _modelRef, ...rest } = agent;
65
+ if (isRecord(rest.options)) {
66
+ const { modelRef: _optionsModelRef, ...options } = rest.options;
67
+ rest.options = options;
68
+ }
69
+ return [name, rest];
70
+ }));
71
+ }
@@ -0,0 +1,49 @@
1
+ import type { RefAssignment } from "./resolution.ts";
2
+ export declare function knownRefModels(directory: string): ReadonlySet<string>;
3
+ export declare function availabilityFilePath(state: string, directory: string): string;
4
+ /** The models last seen for this directory, from memory or the state file; undefined when never checked. */
5
+ export declare function knownAvailableModels(state: string, directory: string): Promise<ReadonlySet<string> | undefined>;
6
+ export declare function availabilityKey(models: ReadonlySet<string>): string;
7
+ /** Remembers the models for this directory and the ref models seen, saving them when they changed; best-effort. */
8
+ export declare function rememberAvailableModels(state: string, directory: string, models: ReadonlySet<string>, assigned?: Iterable<string>): Promise<void>;
9
+ export interface ConnectedProviders {
10
+ providers: {
11
+ id: string;
12
+ models: Record<string, unknown>;
13
+ }[];
14
+ default: Record<string, string>;
15
+ }
16
+ /** Reads OpenCode's provider list response; anything unexpected yields undefined so nothing changes. */
17
+ export declare function readConnectedProviders(data: unknown): ConnectedProviders | undefined;
18
+ export declare function availableModels(connected: ConnectedProviders): ReadonlySet<string>;
19
+ /** True when the agents the config hook built no longer match what is available, so a reload would change them. */
20
+ export declare function assignmentsAreStale(assignments: readonly RefAssignment[], available: ReadonlySet<string>): boolean;
21
+ /** OpenCode's recently used models, which it tries before a provider's default when no `model` is set. */
22
+ export declare function recentModels(state: string): Promise<string[]>;
23
+ export interface FallbackInput {
24
+ /** The agent's own model and variant, when it had one besides its ref. */
25
+ own?: {
26
+ model: string;
27
+ variant?: string;
28
+ };
29
+ /** The model of the session that started this subagent, which OpenCode gives a subagent without a model. */
30
+ parentModel?: string;
31
+ available: ReadonlySet<string>;
32
+ connected: ConnectedProviders;
33
+ /** The top-level `model` from OpenCode's config. */
34
+ configuredModel?: string;
35
+ /** Provider ids configured under `provider`, which OpenCode prefers for its default. */
36
+ configuredProviders: readonly string[];
37
+ recent: readonly string[];
38
+ }
39
+ export interface FallbackModel {
40
+ providerID: string;
41
+ modelID: string;
42
+ variant?: string;
43
+ }
44
+ /**
45
+ * The model OpenCode would use for the agent without its ref: the agent's own `model`, else for a subagent its
46
+ * parent's model, else OpenCode's default (the configured `model`, a recently used model, then a connected
47
+ * provider's default), skipping unavailable ones.
48
+ */
49
+ export declare function fallbackModel(input: FallbackInput): FallbackModel | undefined;
@@ -0,0 +1,169 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { isRecord } from "./models-file.js";
5
+ /**
6
+ * Models connected providers offered when last checked, per instance directory. OpenCode keeps the plugin module
7
+ * loaded when it reloads a project, so this outlives the reload that lets the config hook leave unavailable refs
8
+ * unapplied. It is also saved in OpenCode's state directory, so a new process (such as `opencode run`) leaves them
9
+ * out from the start.
10
+ */
11
+ const availability = new Map();
12
+ /**
13
+ * Every model a ref has put on an agent, per directory, also saved. A session can keep asking for one after its ref
14
+ * is fixed or left out, and only those are moved when OpenCode took the model from the agent or the session, so a
15
+ * model the user set by hand still fails as OpenCode would.
16
+ */
17
+ const refModels = new Map();
18
+ export function knownRefModels(directory) {
19
+ return refModels.get(directory) ?? new Set();
20
+ }
21
+ export function availabilityFilePath(state, directory) {
22
+ const key = createHash("sha256")
23
+ .update(process.platform === "win32"
24
+ ? path.resolve(directory).toLowerCase()
25
+ : path.resolve(directory))
26
+ .digest("hex")
27
+ .slice(0, 32);
28
+ return path.join(state, "model-profiles", "available", `${key}.json`);
29
+ }
30
+ /** The models last seen for this directory, from memory or the state file; undefined when never checked. */
31
+ export async function knownAvailableModels(state, directory) {
32
+ const known = availability.get(directory);
33
+ if (known !== undefined) {
34
+ return known;
35
+ }
36
+ try {
37
+ const value = JSON.parse(await readFile(availabilityFilePath(state, directory), "utf8"));
38
+ if (isRecord(value) &&
39
+ value.version === 1 &&
40
+ Array.isArray(value.models) &&
41
+ value.models.every((model) => typeof model === "string")) {
42
+ const models = new Set(value.models);
43
+ availability.set(directory, models);
44
+ if (!refModels.has(directory) &&
45
+ Array.isArray(value.refModels) &&
46
+ value.refModels.every((model) => typeof model === "string")) {
47
+ refModels.set(directory, new Set(value.refModels));
48
+ }
49
+ return models;
50
+ }
51
+ }
52
+ catch {
53
+ // A missing or unreadable file means availability is not known yet.
54
+ }
55
+ return undefined;
56
+ }
57
+ export function availabilityKey(models) {
58
+ return [...models].sort().join("\n");
59
+ }
60
+ /** Remembers the models for this directory and the ref models seen, saving them when they changed; best-effort. */
61
+ export async function rememberAvailableModels(state, directory, models, assigned = []) {
62
+ const previous = availability.get(directory);
63
+ const previousRefs = knownRefModels(directory);
64
+ const refs = new Set([...previousRefs, ...assigned]);
65
+ availability.set(directory, models);
66
+ refModels.set(directory, refs);
67
+ if (previous !== undefined &&
68
+ availabilityKey(previous) === availabilityKey(models) &&
69
+ refs.size === previousRefs.size) {
70
+ return;
71
+ }
72
+ const file = availabilityFilePath(state, directory);
73
+ const temporary = `${file}.${randomUUID()}.tmp`;
74
+ try {
75
+ await mkdir(path.dirname(file), { recursive: true });
76
+ await writeFile(temporary, `${JSON.stringify({ version: 1, directory, models: [...models].sort(), refModels: [...refs].sort() })}\n`, "utf8");
77
+ await rename(temporary, file);
78
+ }
79
+ catch {
80
+ // Without the file the next process learns availability on its first prompt instead.
81
+ }
82
+ finally {
83
+ await rm(temporary, { force: true }).catch(() => undefined);
84
+ }
85
+ }
86
+ /** Reads OpenCode's provider list response; anything unexpected yields undefined so nothing changes. */
87
+ export function readConnectedProviders(data) {
88
+ if (!isRecord(data) || !Array.isArray(data.providers)) {
89
+ return undefined;
90
+ }
91
+ const providers = data.providers.flatMap((provider) => isRecord(provider) &&
92
+ typeof provider.id === "string" &&
93
+ isRecord(provider.models)
94
+ ? [{ id: provider.id, models: provider.models }]
95
+ : []);
96
+ const defaults = isRecord(data.default)
97
+ ? Object.fromEntries(Object.entries(data.default).filter((entry) => typeof entry[1] === "string"))
98
+ : {};
99
+ return { providers, default: defaults };
100
+ }
101
+ export function availableModels(connected) {
102
+ return new Set(connected.providers.flatMap((provider) => Object.keys(provider.models).map((model) => `${provider.id}/${model}`)));
103
+ }
104
+ /** True when the agents the config hook built no longer match what is available, so a reload would change them. */
105
+ export function assignmentsAreStale(assignments, available) {
106
+ return assignments.some((assignment) => assignment.applied !== available.has(assignment.model));
107
+ }
108
+ /** OpenCode's recently used models, which it tries before a provider's default when no `model` is set. */
109
+ export async function recentModels(state) {
110
+ try {
111
+ const value = JSON.parse(await readFile(path.join(state, "model.json"), "utf8"));
112
+ if (!isRecord(value) || !Array.isArray(value.recent)) {
113
+ return [];
114
+ }
115
+ return value.recent.flatMap((entry) => isRecord(entry) &&
116
+ typeof entry.providerID === "string" &&
117
+ typeof entry.modelID === "string"
118
+ ? [`${entry.providerID}/${entry.modelID}`]
119
+ : []);
120
+ }
121
+ catch {
122
+ return [];
123
+ }
124
+ }
125
+ function split(model) {
126
+ const separator = model.indexOf("/");
127
+ return {
128
+ providerID: model.slice(0, separator),
129
+ modelID: model.slice(separator + 1),
130
+ };
131
+ }
132
+ function hasVariant(connected, model, variant) {
133
+ const { providerID, modelID } = split(model);
134
+ const info = connected.providers.find((provider) => provider.id === providerID)?.models[modelID];
135
+ // Only a model that lists its variants can be known not to offer one.
136
+ return (!isRecord(info) ||
137
+ !isRecord(info.variants) ||
138
+ Object.hasOwn(info.variants, variant));
139
+ }
140
+ /**
141
+ * The model OpenCode would use for the agent without its ref: the agent's own `model`, else for a subagent its
142
+ * parent's model, else OpenCode's default (the configured `model`, a recently used model, then a connected
143
+ * provider's default), skipping unavailable ones.
144
+ */
145
+ export function fallbackModel(input) {
146
+ const { own, available, connected } = input;
147
+ if (own !== undefined && available.has(own.model)) {
148
+ return {
149
+ ...split(own.model),
150
+ ...(own.variant !== undefined &&
151
+ hasVariant(connected, own.model, own.variant)
152
+ ? { variant: own.variant }
153
+ : {}),
154
+ };
155
+ }
156
+ const provider = connected.providers.find((candidate) => input.configuredProviders.length === 0 ||
157
+ input.configuredProviders.includes(candidate.id));
158
+ const providerDefault = provider === undefined ? undefined : connected.default[provider.id];
159
+ const candidates = [
160
+ input.parentModel,
161
+ input.configuredModel,
162
+ ...input.recent,
163
+ provider === undefined || providerDefault === undefined
164
+ ? undefined
165
+ : `${provider.id}/${providerDefault}`,
166
+ ];
167
+ const model = candidates.find((candidate) => candidate !== undefined && available.has(candidate));
168
+ return model === undefined ? undefined : split(model);
169
+ }
@@ -0,0 +1,36 @@
1
+ import type { TuiDialogSelectOption } from "@opencode-ai/plugin/tui";
2
+ import type { ModelProfilesReport } from "./report.ts";
3
+ import { type SavedProfile } from "./selection.ts";
4
+ export declare function profileLabel(profile: string | null): string;
5
+ type RefResolution = ModelProfilesReport["refs"][number];
6
+ export interface ConnectedProvider {
7
+ id: string;
8
+ models: Record<string, unknown>;
9
+ }
10
+ /**
11
+ * The server's config hook runs before providers load, so the TUI checks resolved models against them until
12
+ * the server has marked a ref unavailable itself. With no provider list (not loaded yet) nothing is reported.
13
+ */
14
+ export declare function unavailableModelNotices(refs: readonly RefResolution[], providers: readonly ConnectedProvider[]): string[];
15
+ export interface ModelConfigViewInput {
16
+ report: ModelProfilesReport;
17
+ /** Profiles in models.jsonc now, which may be newer than the report. */
18
+ profiles: string[];
19
+ /** The worktree's saved choice now; it differs from the report while a pick waits for a reload. */
20
+ savedProfile: SavedProfile;
21
+ defaultProfile: string | undefined;
22
+ notices: string[];
23
+ }
24
+ export type ModelConfigChoice = {
25
+ kind: "save";
26
+ profile: string | null;
27
+ } | {
28
+ kind: "followDefaultProfile";
29
+ };
30
+ export declare function modelConfigOptions(input: ModelConfigViewInput): TuiDialogSelectOption<string>[];
31
+ /** Fallback rows can be cut off in the list, so choosing one shows its full text. */
32
+ export declare function selectedNotice(value: string, notices: readonly string[]): string | undefined;
33
+ export declare function selectedChoice(value: string): ModelConfigChoice | undefined;
34
+ /** One toast line per problem would bury the message, so a toast names the first and points to the dialog. */
35
+ export declare function fallbackToastLines(notices: readonly string[]): string[];
36
+ export {};
@@ -0,0 +1,131 @@
1
+ import { unavailableModelNotice } from "./resolution.js";
2
+ import { processProfileVariable } from "./selection.js";
3
+ export function profileLabel(profile) {
4
+ return profile ?? "Defaults only";
5
+ }
6
+ function modelLabel(ref) {
7
+ if (ref.model === undefined) {
8
+ return "not defined, OpenCode's model";
9
+ }
10
+ const model = ref.variant === undefined ? ref.model : `${ref.model} (${ref.variant})`;
11
+ return ref.unavailable === true
12
+ ? `${model}, unavailable, OpenCode's model`
13
+ : model;
14
+ }
15
+ /**
16
+ * The server's config hook runs before providers load, so the TUI checks resolved models against them until
17
+ * the server has marked a ref unavailable itself. With no provider list (not loaded yet) nothing is reported.
18
+ */
19
+ export function unavailableModelNotices(refs, providers) {
20
+ if (providers.length === 0) {
21
+ return [];
22
+ }
23
+ return refs.flatMap((ref) => {
24
+ if (ref.model === undefined ||
25
+ ref.unavailable === true ||
26
+ ref.agents.length === 0) {
27
+ return [];
28
+ }
29
+ const separator = ref.model.indexOf("/");
30
+ const providerId = ref.model.slice(0, separator);
31
+ const modelId = ref.model.slice(separator + 1);
32
+ const provider = providers.find((candidate) => candidate.id === providerId);
33
+ if (provider !== undefined && Object.hasOwn(provider.models, modelId)) {
34
+ return [];
35
+ }
36
+ return [
37
+ unavailableModelNotice(ref.ref, ref.model, ref.agents, provider !== undefined),
38
+ ];
39
+ });
40
+ }
41
+ const sourceLabels = {
42
+ environment: processProfileVariable,
43
+ saved: "saved",
44
+ defaultProfile: "defaultProfile",
45
+ none: "no profile chosen",
46
+ };
47
+ const followDefaultProfileValue = "follow-default-profile";
48
+ export function modelConfigOptions(input) {
49
+ const { report, savedProfile, defaultProfile } = input;
50
+ const profileCategory = report.environmentProfile === undefined
51
+ ? "Profile for this worktree"
52
+ : `Profile for this worktree (${processProfileVariable}=${report.environmentProfile} overrides it)`;
53
+ const pending = savedProfile !== undefined &&
54
+ (report.profileSource !== "saved" || savedProfile !== report.profile);
55
+ const footer = (profile) => {
56
+ const marks = [
57
+ ...(profile === report.profile
58
+ ? [`current, ${sourceLabels[report.profileSource]}`]
59
+ : []),
60
+ ...(pending && profile === savedProfile
61
+ ? [
62
+ report.environmentProfile === undefined
63
+ ? "saved, applies on reload"
64
+ : "saved for later processes",
65
+ ]
66
+ : []),
67
+ ];
68
+ return marks.length === 0 ? {} : { footer: marks.join("; ") };
69
+ };
70
+ const choices = [null, ...input.profiles];
71
+ return [
72
+ ...(defaultProfile === undefined || savedProfile === undefined
73
+ ? []
74
+ : [
75
+ {
76
+ title: `Follow defaultProfile (${defaultProfile})`,
77
+ value: followDefaultProfileValue,
78
+ category: profileCategory,
79
+ description: "Forget the saved choice for this worktree",
80
+ },
81
+ ]),
82
+ ...choices.map((profile) => ({
83
+ title: profileLabel(profile),
84
+ value: profile === null ? "defaults" : `profile:${profile}`,
85
+ category: profileCategory,
86
+ ...footer(profile),
87
+ description: profile === null ? "Use only the defaults in models.jsonc" : undefined,
88
+ })),
89
+ ...report.refs.map((ref) => ({
90
+ title: `${ref.ref} → ${modelLabel(ref)}`,
91
+ value: `ref:${ref.ref}`,
92
+ category: "Model refs",
93
+ description: ref.agents.length === 0 ? "No agents" : ref.agents.join(", "),
94
+ footer: ref.source === "unresolved" || ref.unavailable === true
95
+ ? "fallback"
96
+ : ref.source,
97
+ })),
98
+ ...input.notices.map((notice, index) => ({
99
+ title: notice,
100
+ value: `notice:${index}`,
101
+ category: "Fallbacks",
102
+ })),
103
+ ];
104
+ }
105
+ /** Fallback rows can be cut off in the list, so choosing one shows its full text. */
106
+ export function selectedNotice(value, notices) {
107
+ return value.startsWith("notice:")
108
+ ? notices[Number(value.slice("notice:".length))]
109
+ : undefined;
110
+ }
111
+ export function selectedChoice(value) {
112
+ if (value === followDefaultProfileValue) {
113
+ return { kind: "followDefaultProfile" };
114
+ }
115
+ if (value === "defaults") {
116
+ return { kind: "save", profile: null };
117
+ }
118
+ return value.startsWith("profile:")
119
+ ? { kind: "save", profile: value.slice("profile:".length) }
120
+ : undefined;
121
+ }
122
+ /** One toast line per problem would bury the message, so a toast names the first and points to the dialog. */
123
+ export function fallbackToastLines(notices) {
124
+ const more = notices.length - 1;
125
+ return more <= 0
126
+ ? [...notices]
127
+ : [
128
+ ...notices.slice(0, 1),
129
+ `${more} more ${more === 1 ? "problem" : "problems"}; see /model-config.`,
130
+ ];
131
+ }
@@ -0,0 +1,22 @@
1
+ export declare const builtInAgentNames: readonly ["build", "plan", "general", "explore", "compaction", "title", "summary"];
2
+ export interface ModelChoice {
3
+ model: string;
4
+ variant?: string;
5
+ }
6
+ export interface ModelsDocument {
7
+ defaultProfile?: string;
8
+ defaults: Record<string, ModelChoice>;
9
+ profiles: Record<string, Record<string, ModelChoice>>;
10
+ builtins: Record<string, string>;
11
+ }
12
+ export interface LoadedModels {
13
+ document: ModelsDocument;
14
+ /** "unusable" means the file exists but could not be read or parsed, so it contributes nothing. */
15
+ status: "loaded" | "missing" | "unusable";
16
+ notices: string[];
17
+ }
18
+ export declare function emptyModelsDocument(): ModelsDocument;
19
+ export declare function ownValue<Value>(record: Record<string, Value>, key: string): Value | undefined;
20
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
21
+ export declare function parseModelsDocument(text: string, path: string): LoadedModels;
22
+ export declare function loadModelsDocument(path: string): Promise<LoadedModels>;