@lunchbox-labs/opencode-model-profiles 0.1.0 → 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/README.md CHANGED
@@ -86,6 +86,6 @@ Precedence: `OPENCODE_MODEL_PROFILE`, then the saved worktree choice, then `defa
86
86
 
87
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
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.
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
90
 
91
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.
package/dist/apply.d.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import type { ModelProfilesReport } from "./report.ts";
2
+ import { type RefAssignment } from "./resolution.ts";
2
3
  export declare function modelsFilePath(options: unknown, environment?: NodeJS.ProcessEnv): string;
3
4
  export interface ResolveInput {
4
5
  configFile: string;
5
6
  worktree: string;
6
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[];
7
14
  }
8
15
  /** 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>;
16
+ export declare function resolveModelProfiles(config: Record<string, unknown>, input: ResolveInput): Promise<ResolvedModelProfiles>;
10
17
  /** The last resort when resolution fails unexpectedly: OpenCode's models stay, and modelRef never reaches a provider. */
11
18
  export declare function stripModelRefs(config: Record<string, unknown>): void;
package/dist/apply.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { isRecord, loadModelsDocument, } from "./models-file.js";
3
- import { applyModelRefs, selectProfile } from "./resolution.js";
3
+ import { applyModelRefs, selectProfile, } from "./resolution.js";
4
4
  import { configurationDirectory, processProfile, readSavedSelection, selectionFilePath, stateDirectory, } from "./selection.js";
5
5
  export function modelsFilePath(options, environment = process.env) {
6
6
  const directory = configurationDirectory(environment);
@@ -31,14 +31,14 @@ export async function resolveModelProfiles(config, input) {
31
31
  notices.push(saved.notice);
32
32
  }
33
33
  const selection = selectProfile(loaded.document, environmentProfile, saved.profile);
34
- const applied = applyModelRefs(config.agent, loaded.document, selection.profile);
34
+ const applied = applyModelRefs(config.agent, loaded.document, selection.profile, input.availableModels);
35
35
  if (loaded.status === "loaded") {
36
36
  notices.push(...selection.notices, ...applied.notices);
37
37
  }
38
38
  if (isRecord(config.agent) || Object.keys(applied.agents).length > 0) {
39
39
  config.agent = applied.agents;
40
40
  }
41
- return {
41
+ const report = {
42
42
  version: 1,
43
43
  configFile: input.configFile,
44
44
  selectionFile,
@@ -50,6 +50,7 @@ export async function resolveModelProfiles(config, input) {
50
50
  refs: applied.refs,
51
51
  notices,
52
52
  };
53
+ return { report, assignments: applied.assignments };
53
54
  }
54
55
  /** The last resort when resolution fails unexpectedly: OpenCode's models stay, and modelRef never reaches a provider. */
55
56
  export function stripModelRefs(config) {
@@ -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
+ }
@@ -8,8 +8,8 @@ export interface ConnectedProvider {
8
8
  models: Record<string, unknown>;
9
9
  }
10
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.
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
13
  */
14
14
  export declare function unavailableModelNotices(refs: readonly RefResolution[], providers: readonly ConnectedProvider[]): string[];
15
15
  export interface ModelConfigViewInput {
@@ -1,3 +1,4 @@
1
+ import { unavailableModelNotice } from "./resolution.js";
1
2
  import { processProfileVariable } from "./selection.js";
2
3
  export function profileLabel(profile) {
3
4
  return profile ?? "Defaults only";
@@ -6,20 +7,23 @@ function modelLabel(ref) {
6
7
  if (ref.model === undefined) {
7
8
  return "not defined, OpenCode's model";
8
9
  }
9
- return ref.variant === undefined
10
- ? ref.model
11
- : `${ref.model} (${ref.variant})`;
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;
12
14
  }
13
15
  /**
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
+ * 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.
16
18
  */
17
19
  export function unavailableModelNotices(refs, providers) {
18
20
  if (providers.length === 0) {
19
21
  return [];
20
22
  }
21
23
  return refs.flatMap((ref) => {
22
- if (ref.model === undefined || ref.agents.length === 0) {
24
+ if (ref.model === undefined ||
25
+ ref.unavailable === true ||
26
+ ref.agents.length === 0) {
23
27
  return [];
24
28
  }
25
29
  const separator = ref.model.indexOf("/");
@@ -29,11 +33,8 @@ export function unavailableModelNotices(refs, providers) {
29
33
  if (provider !== undefined && Object.hasOwn(provider.models, modelId)) {
30
34
  return [];
31
35
  }
32
- const reason = provider === undefined
33
- ? `provider "${providerId}" is not connected`
34
- : `"${providerId}" has no model "${modelId}"`;
35
36
  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
+ unavailableModelNotice(ref.ref, ref.model, ref.agents, provider !== undefined),
37
38
  ];
38
39
  });
39
40
  }
@@ -90,7 +91,9 @@ export function modelConfigOptions(input) {
90
91
  value: `ref:${ref.ref}`,
91
92
  category: "Model refs",
92
93
  description: ref.agents.length === 0 ? "No agents" : ref.agents.join(", "),
93
- footer: ref.source === "unresolved" ? "fallback" : ref.source,
94
+ footer: ref.source === "unresolved" || ref.unavailable === true
95
+ ? "fallback"
96
+ : ref.source,
94
97
  })),
95
98
  ...input.notices.map((notice, index) => ({
96
99
  title: notice,
@@ -8,20 +8,39 @@ export interface ProfileSelection {
8
8
  }
9
9
  /** Takes the first choice that names a defined profile (or "defaults only"); undefined profiles fall through to the next. */
10
10
  export declare function selectProfile(document: ModelsDocument, environmentProfile: string | undefined, savedProfile: SavedProfile): ProfileSelection;
11
+ /**
12
+ * The fallback notice for a ref whose model no connected provider offers. The server and the TUI word it the same
13
+ * way, so the TUI does not toast it again when the reload that drops the ref replaces its own check with the server's.
14
+ */
15
+ export declare function unavailableModelNotice(ref: string, model: string, agents: readonly string[], providerConnected: boolean): string;
11
16
  export interface RefResolution {
12
17
  ref: string;
13
18
  model?: string;
14
19
  variant?: string;
15
20
  source: "profile" | "defaults" | "unresolved";
21
+ /** Set when no connected provider offers the model, so agents keep OpenCode's model. */
22
+ unavailable?: true;
16
23
  agents: string[];
17
24
  }
25
+ /** A ref that resolved for an agent, and the model the agent had without it, for falling back at runtime. */
26
+ export interface RefAssignment {
27
+ agent: string;
28
+ ref: string;
29
+ model: string;
30
+ variant?: string;
31
+ ownModel?: string;
32
+ ownVariant?: string;
33
+ /** False when the model was known to be unavailable, so the agent kept its own model. */
34
+ applied: boolean;
35
+ }
18
36
  export interface AppliedModelRefs {
19
37
  agents: Record<string, unknown>;
20
38
  refs: RefResolution[];
39
+ assignments: RefAssignment[];
21
40
  notices: string[];
22
41
  }
23
42
  /**
24
43
  * Returns a new agent map and leaves the given one untouched: OpenCode keeps the global config's
25
44
  * agent objects for the whole process, so mutating them would lose each modelRef on the next reload.
26
45
  */
27
- export declare function applyModelRefs(configuredAgents: unknown, document: ModelsDocument, profile: string | null): AppliedModelRefs;
46
+ export declare function applyModelRefs(configuredAgents: unknown, document: ModelsDocument, profile: string | null, availableModels?: ReadonlySet<string>): AppliedModelRefs;
@@ -34,6 +34,20 @@ export function selectProfile(document, environmentProfile, savedProfile) {
34
34
  notices: skipped.map(([requested, source]) => `Profile "${requested}" from ${profileOrigins[source]} is not defined in models.jsonc; using ${using}.`),
35
35
  };
36
36
  }
37
+ function providerOf(model) {
38
+ return model.slice(0, model.indexOf("/"));
39
+ }
40
+ /**
41
+ * The fallback notice for a ref whose model no connected provider offers. The server and the TUI word it the same
42
+ * way, so the TUI does not toast it again when the reload that drops the ref replaces its own check with the server's.
43
+ */
44
+ export function unavailableModelNotice(ref, model, agents, providerConnected) {
45
+ const providerId = providerOf(model);
46
+ const reason = providerConnected
47
+ ? `"${providerId}" has no model "${model.slice(providerId.length + 1)}"`
48
+ : `provider "${providerId}" is not connected`;
49
+ return `Ref "${ref}" uses ${model}, but ${reason}; ${agents.join(", ")} ${agents.length === 1 ? "falls" : "fall"} back to OpenCode's model until you fix the ref or pick another profile.`;
50
+ }
37
51
  function resolveRef(document, profile, ref) {
38
52
  const profileRefs = profile === null ? undefined : ownValue(document.profiles, profile);
39
53
  const profileChoice = profileRefs === undefined ? undefined : ownValue(profileRefs, ref);
@@ -59,8 +73,10 @@ function withoutModelRef(agent) {
59
73
  * Returns a new agent map and leaves the given one untouched: OpenCode keeps the global config's
60
74
  * agent objects for the whole process, so mutating them would lose each modelRef on the next reload.
61
75
  */
62
- export function applyModelRefs(configuredAgents, document, profile) {
76
+ export function applyModelRefs(configuredAgents, document, profile, availableModels) {
63
77
  const notices = [];
78
+ const assignments = [];
79
+ const isAvailable = (model) => availableModels === undefined || availableModels.has(model);
64
80
  const agents = {};
65
81
  const refAgents = new Map();
66
82
  const builtIns = builtInAgentNames;
@@ -100,12 +116,29 @@ export function applyModelRefs(configuredAgents, document, profile) {
100
116
  refAgents.set(ref, [...(refAgents.get(ref) ?? []), name]);
101
117
  const resolved = resolveRef(document, profile, ref);
102
118
  if (resolved !== undefined) {
103
- agent.model = resolved.choice.model;
104
- if (resolved.choice.variant !== undefined) {
105
- agent.variant = resolved.choice.variant;
119
+ const applied = isAvailable(resolved.choice.model);
120
+ assignments.push({
121
+ agent: name,
122
+ ref,
123
+ model: resolved.choice.model,
124
+ ...(resolved.choice.variant === undefined
125
+ ? {}
126
+ : { variant: resolved.choice.variant }),
127
+ ...(typeof agent.model === "string" ? { ownModel: agent.model } : {}),
128
+ ...(typeof agent.variant === "string"
129
+ ? { ownVariant: agent.variant }
130
+ : {}),
131
+ applied,
132
+ });
133
+ if (applied) {
134
+ agent.model = resolved.choice.model;
135
+ if (resolved.choice.variant !== undefined) {
136
+ agent.variant = resolved.choice.variant;
137
+ }
106
138
  }
107
139
  }
108
- if (original !== undefined || resolved !== undefined) {
140
+ if (original !== undefined ||
141
+ (resolved !== undefined && isAvailable(resolved.choice.model))) {
109
142
  agents[name] = agent;
110
143
  }
111
144
  }
@@ -126,6 +159,11 @@ export function applyModelRefs(configuredAgents, document, profile) {
126
159
  notices.push(`Ref "${ref}" is not defined in ${where}; ${users.join(", ")} ${users.length === 1 ? "keeps" : "keep"} OpenCode's model.`);
127
160
  return { ref, source: "unresolved", agents: users };
128
161
  }
162
+ const unavailable = !isAvailable(resolved.choice.model);
163
+ if (unavailable && users.length > 0) {
164
+ notices.push(unavailableModelNotice(ref, resolved.choice.model, users, availableModels !== undefined &&
165
+ [...availableModels].some((model) => model.startsWith(`${providerOf(resolved.choice.model)}/`))));
166
+ }
129
167
  return {
130
168
  ref,
131
169
  model: resolved.choice.model,
@@ -133,8 +171,9 @@ export function applyModelRefs(configuredAgents, document, profile) {
133
171
  ? {}
134
172
  : { variant: resolved.choice.variant }),
135
173
  source: resolved.source,
174
+ ...(unavailable ? { unavailable: true } : {}),
136
175
  agents: users,
137
176
  };
138
177
  });
139
- return { agents, refs, notices };
178
+ return { agents, refs, assignments, notices };
140
179
  }
package/dist/server.js CHANGED
@@ -1,17 +1,237 @@
1
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";
2
4
  import { publishReport } from "./report.js";
3
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
+ }
4
206
  const ModelProfilesServer = (input, options) => {
5
207
  const worktree = worktreeRoot(input.worktree, input.directory);
208
+ let applied = { assignments: [], configuredProviders: [] };
209
+ const fallbacks = createFallbacks(input, () => applied);
6
210
  return Promise.resolve({
7
211
  config: async (config) => {
8
212
  const target = config;
9
213
  const configFile = modelsFilePath(options);
10
214
  try {
11
- publishReport(target, options, await resolveModelProfiles(target, { configFile, worktree }));
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);
12
231
  }
13
232
  catch (error) {
14
233
  try {
234
+ applied = { assignments: [], configuredProviders: [] };
15
235
  stripModelRefs(target);
16
236
  publishReport(target, options, {
17
237
  version: 1,
@@ -32,6 +252,26 @@ const ModelProfilesServer = (input, options) => {
32
252
  }
33
253
  }
34
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
+ },
35
275
  });
36
276
  };
37
277
  export default {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunchbox-labs/opencode-model-profiles",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Pick agent models by name, with defaults and per-worktree profiles",
5
5
  "license": "MIT",
6
6
  "type": "module",