@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 +1 -1
- package/dist/apply.d.ts +8 -1
- package/dist/apply.js +4 -3
- package/dist/fallback.d.ts +49 -0
- package/dist/fallback.js +169 -0
- package/dist/model-config-view.d.ts +2 -2
- package/dist/model-config-view.js +14 -11
- package/dist/resolution.d.ts +20 -1
- package/dist/resolution.js +45 -6
- package/dist/server.js +241 -1
- package/package.json +1 -1
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
|
|
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<
|
|
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
|
-
|
|
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;
|
package/dist/fallback.js
ADDED
|
@@ -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
|
|
12
|
-
* With no provider list (not loaded yet) nothing is reported
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
15
|
-
* With no provider list (not loaded yet) nothing is reported
|
|
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 ||
|
|
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
|
-
|
|
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"
|
|
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,
|
package/dist/resolution.d.ts
CHANGED
|
@@ -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;
|
package/dist/resolution.js
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
agent
|
|
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 ||
|
|
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
|
-
|
|
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 {
|