@lunchbox-labs/opencode-model-profiles 0.0.0-stage → 0.1.0

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
+ The plugin does not check that a model exists when it applies a ref, so an agent whose ref names a model no connected provider offers fails when used. The TUI warns about such refs.
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,11 @@
1
+ import type { ModelProfilesReport } from "./report.ts";
2
+ export declare function modelsFilePath(options: unknown, environment?: NodeJS.ProcessEnv): string;
3
+ export interface ResolveInput {
4
+ configFile: string;
5
+ worktree: string;
6
+ environment?: NodeJS.ProcessEnv;
7
+ }
8
+ /** Replaces `config.agent` with resolved copies and returns what was applied; never throws for bad input files. */
9
+ export declare function resolveModelProfiles(config: Record<string, unknown>, input: ResolveInput): Promise<ModelProfilesReport>;
10
+ /** The last resort when resolution fails unexpectedly: OpenCode's models stay, and modelRef never reaches a provider. */
11
+ export declare function stripModelRefs(config: Record<string, unknown>): void;
package/dist/apply.js ADDED
@@ -0,0 +1,70 @@
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);
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
+ return {
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
+ }
54
+ /** The last resort when resolution fails unexpectedly: OpenCode's models stay, and modelRef never reaches a provider. */
55
+ export function stripModelRefs(config) {
56
+ if (!isRecord(config.agent)) {
57
+ return;
58
+ }
59
+ config.agent = Object.fromEntries(Object.entries(config.agent).map(([name, agent]) => {
60
+ if (!isRecord(agent)) {
61
+ return [name, agent];
62
+ }
63
+ const { modelRef: _modelRef, ...rest } = agent;
64
+ if (isRecord(rest.options)) {
65
+ const { modelRef: _optionsModelRef, ...options } = rest.options;
66
+ rest.options = options;
67
+ }
68
+ return [name, rest];
69
+ }));
70
+ }
@@ -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 cannot see which providers are connected, so the TUI checks resolved models against them.
12
+ * With no provider list (not loaded yet) nothing is reported, rather than every ref.
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,128 @@
1
+ import { processProfileVariable } from "./selection.js";
2
+ export function profileLabel(profile) {
3
+ return profile ?? "Defaults only";
4
+ }
5
+ function modelLabel(ref) {
6
+ if (ref.model === undefined) {
7
+ return "not defined, OpenCode's model";
8
+ }
9
+ return ref.variant === undefined
10
+ ? ref.model
11
+ : `${ref.model} (${ref.variant})`;
12
+ }
13
+ /**
14
+ * The server cannot see which providers are connected, so the TUI checks resolved models against them.
15
+ * With no provider list (not loaded yet) nothing is reported, rather than every ref.
16
+ */
17
+ export function unavailableModelNotices(refs, providers) {
18
+ if (providers.length === 0) {
19
+ return [];
20
+ }
21
+ return refs.flatMap((ref) => {
22
+ if (ref.model === undefined || ref.agents.length === 0) {
23
+ return [];
24
+ }
25
+ const separator = ref.model.indexOf("/");
26
+ const providerId = ref.model.slice(0, separator);
27
+ const modelId = ref.model.slice(separator + 1);
28
+ const provider = providers.find((candidate) => candidate.id === providerId);
29
+ if (provider !== undefined && Object.hasOwn(provider.models, modelId)) {
30
+ return [];
31
+ }
32
+ const reason = provider === undefined
33
+ ? `provider "${providerId}" is not connected`
34
+ : `"${providerId}" has no model "${modelId}"`;
35
+ return [
36
+ `Ref "${ref.ref}" uses ${ref.model}, but ${reason}; ${ref.agents.join(", ")} will fail until you fix the ref or pick another profile.`,
37
+ ];
38
+ });
39
+ }
40
+ const sourceLabels = {
41
+ environment: processProfileVariable,
42
+ saved: "saved",
43
+ defaultProfile: "defaultProfile",
44
+ none: "no profile chosen",
45
+ };
46
+ const followDefaultProfileValue = "follow-default-profile";
47
+ export function modelConfigOptions(input) {
48
+ const { report, savedProfile, defaultProfile } = input;
49
+ const profileCategory = report.environmentProfile === undefined
50
+ ? "Profile for this worktree"
51
+ : `Profile for this worktree (${processProfileVariable}=${report.environmentProfile} overrides it)`;
52
+ const pending = savedProfile !== undefined &&
53
+ (report.profileSource !== "saved" || savedProfile !== report.profile);
54
+ const footer = (profile) => {
55
+ const marks = [
56
+ ...(profile === report.profile
57
+ ? [`current, ${sourceLabels[report.profileSource]}`]
58
+ : []),
59
+ ...(pending && profile === savedProfile
60
+ ? [
61
+ report.environmentProfile === undefined
62
+ ? "saved, applies on reload"
63
+ : "saved for later processes",
64
+ ]
65
+ : []),
66
+ ];
67
+ return marks.length === 0 ? {} : { footer: marks.join("; ") };
68
+ };
69
+ const choices = [null, ...input.profiles];
70
+ return [
71
+ ...(defaultProfile === undefined || savedProfile === undefined
72
+ ? []
73
+ : [
74
+ {
75
+ title: `Follow defaultProfile (${defaultProfile})`,
76
+ value: followDefaultProfileValue,
77
+ category: profileCategory,
78
+ description: "Forget the saved choice for this worktree",
79
+ },
80
+ ]),
81
+ ...choices.map((profile) => ({
82
+ title: profileLabel(profile),
83
+ value: profile === null ? "defaults" : `profile:${profile}`,
84
+ category: profileCategory,
85
+ ...footer(profile),
86
+ description: profile === null ? "Use only the defaults in models.jsonc" : undefined,
87
+ })),
88
+ ...report.refs.map((ref) => ({
89
+ title: `${ref.ref} → ${modelLabel(ref)}`,
90
+ value: `ref:${ref.ref}`,
91
+ category: "Model refs",
92
+ description: ref.agents.length === 0 ? "No agents" : ref.agents.join(", "),
93
+ footer: ref.source === "unresolved" ? "fallback" : ref.source,
94
+ })),
95
+ ...input.notices.map((notice, index) => ({
96
+ title: notice,
97
+ value: `notice:${index}`,
98
+ category: "Fallbacks",
99
+ })),
100
+ ];
101
+ }
102
+ /** Fallback rows can be cut off in the list, so choosing one shows its full text. */
103
+ export function selectedNotice(value, notices) {
104
+ return value.startsWith("notice:")
105
+ ? notices[Number(value.slice("notice:".length))]
106
+ : undefined;
107
+ }
108
+ export function selectedChoice(value) {
109
+ if (value === followDefaultProfileValue) {
110
+ return { kind: "followDefaultProfile" };
111
+ }
112
+ if (value === "defaults") {
113
+ return { kind: "save", profile: null };
114
+ }
115
+ return value.startsWith("profile:")
116
+ ? { kind: "save", profile: value.slice("profile:".length) }
117
+ : undefined;
118
+ }
119
+ /** One toast line per problem would bury the message, so a toast names the first and points to the dialog. */
120
+ export function fallbackToastLines(notices) {
121
+ const more = notices.length - 1;
122
+ return more <= 0
123
+ ? [...notices]
124
+ : [
125
+ ...notices.slice(0, 1),
126
+ `${more} more ${more === 1 ? "problem" : "problems"}; see /model-config.`,
127
+ ];
128
+ }
@@ -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>;
@@ -0,0 +1,155 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parse, printParseErrorCode } from "jsonc-parser";
3
+ export const builtInAgentNames = [
4
+ "build",
5
+ "plan",
6
+ "general",
7
+ "explore",
8
+ "compaction",
9
+ "title",
10
+ "summary",
11
+ ];
12
+ export function emptyModelsDocument() {
13
+ return { defaults: {}, profiles: {}, builtins: {} };
14
+ }
15
+ export function ownValue(record, key) {
16
+ return Object.hasOwn(record, key) ? record[key] : undefined;
17
+ }
18
+ export function isRecord(value) {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+ function isNonEmptyName(value) {
22
+ return typeof value === "string" && value.trim() !== "";
23
+ }
24
+ const modelIdentifierPattern = /^[^\s/]+\/\S+$/;
25
+ function readModelChoice(value) {
26
+ if (typeof value === "string") {
27
+ return modelIdentifierPattern.test(value) ? { model: value } : undefined;
28
+ }
29
+ if (!isRecord(value) ||
30
+ typeof value.model !== "string" ||
31
+ !modelIdentifierPattern.test(value.model)) {
32
+ return undefined;
33
+ }
34
+ if (value.variant === undefined) {
35
+ return { model: value.model };
36
+ }
37
+ return isNonEmptyName(value.variant)
38
+ ? { model: value.model, variant: value.variant }
39
+ : undefined;
40
+ }
41
+ function readModelChoices(value, location, notices) {
42
+ const choices = {};
43
+ if (value === undefined) {
44
+ return choices;
45
+ }
46
+ if (!isRecord(value)) {
47
+ notices.push(`${location} is not an object of model refs; ignored.`);
48
+ return choices;
49
+ }
50
+ for (const [ref, entry] of Object.entries(value)) {
51
+ const choice = readModelChoice(entry);
52
+ if (choice === undefined || !isNonEmptyName(ref)) {
53
+ notices.push(`${location}.${ref} is not "provider/model" or { "model", "variant" }; skipped.`);
54
+ continue;
55
+ }
56
+ choices[ref] = choice;
57
+ }
58
+ return choices;
59
+ }
60
+ function lineAndColumn(text, offset) {
61
+ const before = text.slice(0, offset).split("\n");
62
+ return `line ${before.length}, column ${(before.at(-1)?.length ?? 0) + 1}`;
63
+ }
64
+ export function parseModelsDocument(text, path) {
65
+ const notices = [];
66
+ const errors = [];
67
+ const value = parse(text, errors, {
68
+ allowTrailingComma: true,
69
+ disallowComments: false,
70
+ });
71
+ const firstError = errors.at(0);
72
+ if (firstError !== undefined) {
73
+ notices.push(`${path} has a syntax error (${printParseErrorCode(firstError.error)} at ${lineAndColumn(text, firstError.offset)}); its model refs are ignored.`);
74
+ return { document: emptyModelsDocument(), status: "unusable", notices };
75
+ }
76
+ if (!isRecord(value)) {
77
+ notices.push(`${path} is not a JSON object; its model refs are ignored.`);
78
+ return { document: emptyModelsDocument(), status: "unusable", notices };
79
+ }
80
+ const knownKeys = [
81
+ "$schema",
82
+ "defaultProfile",
83
+ "defaults",
84
+ "profiles",
85
+ "builtins",
86
+ ];
87
+ for (const key of Object.keys(value)) {
88
+ if (!knownKeys.includes(key)) {
89
+ notices.push(`Unknown top-level key "${key}" in ${path}; ignored.`);
90
+ }
91
+ }
92
+ const document = emptyModelsDocument();
93
+ document.defaults = readModelChoices(value.defaults, "defaults", notices);
94
+ if (value.profiles !== undefined && !isRecord(value.profiles)) {
95
+ notices.push("profiles is not an object of profiles; ignored.");
96
+ }
97
+ else {
98
+ for (const [name, refs] of Object.entries(value.profiles ?? {})) {
99
+ if (!isNonEmptyName(name)) {
100
+ notices.push("A profile with an empty name was skipped.");
101
+ continue;
102
+ }
103
+ document.profiles[name] = readModelChoices(refs, `profiles.${name}`, notices);
104
+ }
105
+ }
106
+ if (value.builtins !== undefined && !isRecord(value.builtins)) {
107
+ notices.push("builtins is not an object of agent names; ignored.");
108
+ }
109
+ else {
110
+ const knownBuiltIns = builtInAgentNames;
111
+ for (const [agent, ref] of Object.entries(value.builtins ?? {})) {
112
+ if (!knownBuiltIns.includes(agent)) {
113
+ notices.push(`builtins.${agent} is not a built-in agent (${builtInAgentNames.join(", ")}); skipped.`);
114
+ }
115
+ else if (!isNonEmptyName(ref)) {
116
+ notices.push(`builtins.${agent} is not a ref name; skipped.`);
117
+ }
118
+ else {
119
+ document.builtins[agent] = ref;
120
+ }
121
+ }
122
+ }
123
+ if (value.defaultProfile !== undefined) {
124
+ if (isNonEmptyName(value.defaultProfile)) {
125
+ document.defaultProfile = value.defaultProfile;
126
+ }
127
+ else {
128
+ notices.push("defaultProfile is not a profile name; ignored.");
129
+ }
130
+ }
131
+ return { document, status: "loaded", notices };
132
+ }
133
+ export async function loadModelsDocument(path) {
134
+ let text;
135
+ try {
136
+ text = await readFile(path, "utf8");
137
+ }
138
+ catch (error) {
139
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
140
+ return {
141
+ document: emptyModelsDocument(),
142
+ status: "missing",
143
+ notices: [],
144
+ };
145
+ }
146
+ return {
147
+ document: emptyModelsDocument(),
148
+ status: "unusable",
149
+ notices: [
150
+ `Could not read ${path} (${error instanceof Error ? error.message : String(error)}); its model refs are ignored.`,
151
+ ],
152
+ };
153
+ }
154
+ return parseModelsDocument(text, path);
155
+ }
@@ -0,0 +1,17 @@
1
+ import type { ProfileSource, RefResolution } from "./resolution.ts";
2
+ export declare const packageName = "@lunchbox-labs/opencode-model-profiles";
3
+ /** What the server applied, published in its own plugin entry so the TUI can read it through /config. */
4
+ export interface ModelProfilesReport {
5
+ version: 1;
6
+ configFile: string;
7
+ selectionFile: string;
8
+ worktree: string;
9
+ profiles: string[];
10
+ profile: string | null;
11
+ profileSource: ProfileSource;
12
+ environmentProfile?: string;
13
+ refs: RefResolution[];
14
+ notices: string[];
15
+ }
16
+ export declare function publishReport(config: Record<string, unknown>, options: unknown, report: ModelProfilesReport): boolean;
17
+ export declare function readReport(config: unknown): ModelProfilesReport | undefined;
package/dist/report.js ADDED
@@ -0,0 +1,73 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { isRecord } from "./models-file.js";
4
+ export const packageName = "@lunchbox-labs/opencode-model-profiles";
5
+ const reportKey = "modelProfilesReport";
6
+ const packageRoot = path.resolve(fileURLToPath(new URL("../", import.meta.url)));
7
+ function isOwnSpecifier(specifier) {
8
+ if (typeof specifier !== "string") {
9
+ return false;
10
+ }
11
+ if (specifier === packageName || specifier.startsWith(`${packageName}@`)) {
12
+ return true;
13
+ }
14
+ if (!specifier.startsWith("file:")) {
15
+ return false;
16
+ }
17
+ try {
18
+ const target = path.resolve(fileURLToPath(specifier));
19
+ return target === packageRoot || target.startsWith(packageRoot + path.sep);
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ export function publishReport(config, options, report) {
26
+ if (!Array.isArray(config.plugin)) {
27
+ return false;
28
+ }
29
+ const plugins = [...config.plugin];
30
+ const index = plugins.findIndex((entry) => Array.isArray(entry)
31
+ ? entry[1] === options || isOwnSpecifier(entry[0])
32
+ : isOwnSpecifier(entry));
33
+ if (index < 0) {
34
+ return false;
35
+ }
36
+ const entry = plugins[index];
37
+ const specifier = Array.isArray(entry) ? entry[0] : entry;
38
+ const entryOptions = Array.isArray(entry) ? entry[1] : undefined;
39
+ plugins[index] = [
40
+ specifier,
41
+ {
42
+ ...(isRecord(entryOptions) ? entryOptions : {}),
43
+ [reportKey]: JSON.parse(JSON.stringify(report)),
44
+ },
45
+ ];
46
+ config.plugin = plugins;
47
+ return true;
48
+ }
49
+ function isReport(value) {
50
+ return (isRecord(value) &&
51
+ value.version === 1 &&
52
+ typeof value.configFile === "string" &&
53
+ typeof value.selectionFile === "string" &&
54
+ typeof value.worktree === "string" &&
55
+ (value.profile === null || typeof value.profile === "string") &&
56
+ Array.isArray(value.profiles) &&
57
+ Array.isArray(value.refs) &&
58
+ Array.isArray(value.notices));
59
+ }
60
+ export function readReport(config) {
61
+ if (!isRecord(config) || !Array.isArray(config.plugin)) {
62
+ return undefined;
63
+ }
64
+ for (const entry of config.plugin) {
65
+ const report = Array.isArray(entry) && isRecord(entry[1])
66
+ ? entry[1][reportKey]
67
+ : undefined;
68
+ if (isReport(report)) {
69
+ return report;
70
+ }
71
+ }
72
+ return undefined;
73
+ }
@@ -0,0 +1,27 @@
1
+ import { type ModelsDocument } from "./models-file.ts";
2
+ import type { SavedProfile } from "./selection.ts";
3
+ export type ProfileSource = "environment" | "saved" | "defaultProfile" | "none";
4
+ export interface ProfileSelection {
5
+ profile: string | null;
6
+ source: ProfileSource;
7
+ notices: string[];
8
+ }
9
+ /** Takes the first choice that names a defined profile (or "defaults only"); undefined profiles fall through to the next. */
10
+ export declare function selectProfile(document: ModelsDocument, environmentProfile: string | undefined, savedProfile: SavedProfile): ProfileSelection;
11
+ export interface RefResolution {
12
+ ref: string;
13
+ model?: string;
14
+ variant?: string;
15
+ source: "profile" | "defaults" | "unresolved";
16
+ agents: string[];
17
+ }
18
+ export interface AppliedModelRefs {
19
+ agents: Record<string, unknown>;
20
+ refs: RefResolution[];
21
+ notices: string[];
22
+ }
23
+ /**
24
+ * Returns a new agent map and leaves the given one untouched: OpenCode keeps the global config's
25
+ * agent objects for the whole process, so mutating them would lose each modelRef on the next reload.
26
+ */
27
+ export declare function applyModelRefs(configuredAgents: unknown, document: ModelsDocument, profile: string | null): AppliedModelRefs;