@lunchbox-labs/opencode-model-profiles 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +90 -2
- package/dist/apply.d.ts +18 -0
- package/dist/apply.js +71 -0
- package/dist/fallback.d.ts +49 -0
- package/dist/fallback.js +169 -0
- package/dist/model-config-view.d.ts +36 -0
- package/dist/model-config-view.js +131 -0
- package/dist/models-file.d.ts +22 -0
- package/dist/models-file.js +155 -0
- package/dist/report.d.ts +17 -0
- package/dist/report.js +73 -0
- package/dist/resolution.d.ts +46 -0
- package/dist/resolution.js +179 -0
- package/dist/selection.d.ts +17 -0
- package/dist/selection.js +95 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +280 -0
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +204 -0
- package/package.json +73 -4
- package/schema.json +97 -0
|
@@ -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
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -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,46 @@
|
|
|
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
|
+
/**
|
|
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;
|
|
16
|
+
export interface RefResolution {
|
|
17
|
+
ref: string;
|
|
18
|
+
model?: string;
|
|
19
|
+
variant?: string;
|
|
20
|
+
source: "profile" | "defaults" | "unresolved";
|
|
21
|
+
/** Set when no connected provider offers the model, so agents keep OpenCode's model. */
|
|
22
|
+
unavailable?: true;
|
|
23
|
+
agents: string[];
|
|
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
|
+
}
|
|
36
|
+
export interface AppliedModelRefs {
|
|
37
|
+
agents: Record<string, unknown>;
|
|
38
|
+
refs: RefResolution[];
|
|
39
|
+
assignments: RefAssignment[];
|
|
40
|
+
notices: string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Returns a new agent map and leaves the given one untouched: OpenCode keeps the global config's
|
|
44
|
+
* agent objects for the whole process, so mutating them would lose each modelRef on the next reload.
|
|
45
|
+
*/
|
|
46
|
+
export declare function applyModelRefs(configuredAgents: unknown, document: ModelsDocument, profile: string | null, availableModels?: ReadonlySet<string>): AppliedModelRefs;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { builtInAgentNames, isRecord, ownValue, } from "./models-file.js";
|
|
2
|
+
const profileOrigins = {
|
|
3
|
+
environment: "OPENCODE_MODEL_PROFILE",
|
|
4
|
+
saved: "the saved worktree selection",
|
|
5
|
+
defaultProfile: "defaultProfile",
|
|
6
|
+
};
|
|
7
|
+
/** Takes the first choice that names a defined profile (or "defaults only"); undefined profiles fall through to the next. */
|
|
8
|
+
export function selectProfile(document, environmentProfile, savedProfile) {
|
|
9
|
+
const candidates = [
|
|
10
|
+
[environmentProfile, "environment"],
|
|
11
|
+
[savedProfile, "saved"],
|
|
12
|
+
[document.defaultProfile, "defaultProfile"],
|
|
13
|
+
];
|
|
14
|
+
const skipped = [];
|
|
15
|
+
let selected = {
|
|
16
|
+
profile: null,
|
|
17
|
+
source: "none",
|
|
18
|
+
};
|
|
19
|
+
for (const [requested, source] of candidates) {
|
|
20
|
+
if (requested === undefined || source === "none") {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (requested === null || Object.hasOwn(document.profiles, requested)) {
|
|
24
|
+
selected = { profile: requested, source };
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
skipped.push([requested, source]);
|
|
28
|
+
}
|
|
29
|
+
const using = selected.profile === null
|
|
30
|
+
? "defaults only"
|
|
31
|
+
: `profile "${selected.profile}"`;
|
|
32
|
+
return {
|
|
33
|
+
...selected,
|
|
34
|
+
notices: skipped.map(([requested, source]) => `Profile "${requested}" from ${profileOrigins[source]} is not defined in models.jsonc; using ${using}.`),
|
|
35
|
+
};
|
|
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
|
+
}
|
|
51
|
+
function resolveRef(document, profile, ref) {
|
|
52
|
+
const profileRefs = profile === null ? undefined : ownValue(document.profiles, profile);
|
|
53
|
+
const profileChoice = profileRefs === undefined ? undefined : ownValue(profileRefs, ref);
|
|
54
|
+
if (profileChoice !== undefined) {
|
|
55
|
+
return { choice: profileChoice, source: "profile" };
|
|
56
|
+
}
|
|
57
|
+
const defaultChoice = ownValue(document.defaults, ref);
|
|
58
|
+
return defaultChoice === undefined
|
|
59
|
+
? undefined
|
|
60
|
+
: { choice: defaultChoice, source: "defaults" };
|
|
61
|
+
}
|
|
62
|
+
function withoutModelRef(agent) {
|
|
63
|
+
const { modelRef: topLevel, ...rest } = agent;
|
|
64
|
+
let declared = topLevel;
|
|
65
|
+
if (isRecord(rest.options) && Object.hasOwn(rest.options, "modelRef")) {
|
|
66
|
+
const { modelRef: fromOptions, ...options } = rest.options;
|
|
67
|
+
declared ??= fromOptions;
|
|
68
|
+
rest.options = options;
|
|
69
|
+
}
|
|
70
|
+
return { agent: rest, declared };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Returns a new agent map and leaves the given one untouched: OpenCode keeps the global config's
|
|
74
|
+
* agent objects for the whole process, so mutating them would lose each modelRef on the next reload.
|
|
75
|
+
*/
|
|
76
|
+
export function applyModelRefs(configuredAgents, document, profile, availableModels) {
|
|
77
|
+
const notices = [];
|
|
78
|
+
const assignments = [];
|
|
79
|
+
const isAvailable = (model) => availableModels === undefined || availableModels.has(model);
|
|
80
|
+
const agents = {};
|
|
81
|
+
const refAgents = new Map();
|
|
82
|
+
const builtIns = builtInAgentNames;
|
|
83
|
+
const sourceAgents = isRecord(configuredAgents) ? configuredAgents : {};
|
|
84
|
+
const names = [
|
|
85
|
+
...new Set([
|
|
86
|
+
...Object.keys(sourceAgents),
|
|
87
|
+
...Object.keys(document.builtins),
|
|
88
|
+
]),
|
|
89
|
+
];
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
const original = ownValue(sourceAgents, name);
|
|
92
|
+
if (original !== undefined && !isRecord(original)) {
|
|
93
|
+
agents[name] = original;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const { agent, declared } = withoutModelRef(original ?? {});
|
|
97
|
+
const builtInRef = builtIns.includes(name) && agent.model === undefined
|
|
98
|
+
? ownValue(document.builtins, name)
|
|
99
|
+
: undefined;
|
|
100
|
+
if (declared !== undefined &&
|
|
101
|
+
(typeof declared !== "string" || declared.trim() === "")) {
|
|
102
|
+
notices.push(`Agent ${name} has a modelRef that is not a ref name; it keeps OpenCode's model.`);
|
|
103
|
+
}
|
|
104
|
+
const ref = declared === undefined
|
|
105
|
+
? builtInRef
|
|
106
|
+
: typeof declared === "string" && declared.trim() !== ""
|
|
107
|
+
? declared
|
|
108
|
+
: undefined;
|
|
109
|
+
if (original === undefined && ref === undefined) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (ref === undefined || agent.disable === true) {
|
|
113
|
+
agents[name] = agent;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
refAgents.set(ref, [...(refAgents.get(ref) ?? []), name]);
|
|
117
|
+
const resolved = resolveRef(document, profile, ref);
|
|
118
|
+
if (resolved !== undefined) {
|
|
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
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (original !== undefined ||
|
|
141
|
+
(resolved !== undefined && isAvailable(resolved.choice.model))) {
|
|
142
|
+
agents[name] = agent;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const refNames = [
|
|
146
|
+
...new Set([
|
|
147
|
+
...Object.keys(document.defaults),
|
|
148
|
+
...(profile === null
|
|
149
|
+
? []
|
|
150
|
+
: Object.keys(ownValue(document.profiles, profile) ?? {})),
|
|
151
|
+
...refAgents.keys(),
|
|
152
|
+
]),
|
|
153
|
+
].sort();
|
|
154
|
+
const refs = refNames.map((ref) => {
|
|
155
|
+
const users = refAgents.get(ref) ?? [];
|
|
156
|
+
const resolved = resolveRef(document, profile, ref);
|
|
157
|
+
if (resolved === undefined) {
|
|
158
|
+
const where = profile === null ? "defaults" : `profile "${profile}" or defaults`;
|
|
159
|
+
notices.push(`Ref "${ref}" is not defined in ${where}; ${users.join(", ")} ${users.length === 1 ? "keeps" : "keep"} OpenCode's model.`);
|
|
160
|
+
return { ref, source: "unresolved", agents: users };
|
|
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
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
ref,
|
|
169
|
+
model: resolved.choice.model,
|
|
170
|
+
...(resolved.choice.variant === undefined
|
|
171
|
+
? {}
|
|
172
|
+
: { variant: resolved.choice.variant }),
|
|
173
|
+
source: resolved.source,
|
|
174
|
+
...(unavailable ? { unavailable: true } : {}),
|
|
175
|
+
agents: users,
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
return { agents, refs, assignments, notices };
|
|
179
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const processProfileVariable = "OPENCODE_MODEL_PROFILE";
|
|
2
|
+
export declare function processProfile(environment?: NodeJS.ProcessEnv): string | undefined;
|
|
3
|
+
export declare function configurationDirectory(environment?: NodeJS.ProcessEnv): string;
|
|
4
|
+
/** OpenCode 1.19 uses xdg-basedir for its state directory on every platform, including Windows. */
|
|
5
|
+
export declare function stateDirectory(environment?: NodeJS.ProcessEnv): string;
|
|
6
|
+
/** Outside a git repository OpenCode reports the worktree as "/", so the directory stands in for it. */
|
|
7
|
+
export declare function worktreeRoot(worktree: unknown, directory: unknown): string;
|
|
8
|
+
export declare function selectionFilePath(state: string, worktree: string, platform?: NodeJS.Platform): string;
|
|
9
|
+
/** `null` saves "defaults only"; `undefined` means nothing is saved for this worktree. */
|
|
10
|
+
export type SavedProfile = string | null | undefined;
|
|
11
|
+
export interface SavedSelection {
|
|
12
|
+
profile: SavedProfile;
|
|
13
|
+
notice?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function readSavedSelection(file: string): Promise<SavedSelection>;
|
|
16
|
+
export declare function saveSelection(file: string, worktree: string, profile: string | null): Promise<void>;
|
|
17
|
+
export declare function clearSelection(file: string): Promise<void>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { isRecord } from "./models-file.js";
|
|
6
|
+
export const processProfileVariable = "OPENCODE_MODEL_PROFILE";
|
|
7
|
+
export function processProfile(environment = process.env) {
|
|
8
|
+
const value = environment[processProfileVariable]?.trim();
|
|
9
|
+
return value === undefined || value === "" ? undefined : value;
|
|
10
|
+
}
|
|
11
|
+
export function configurationDirectory(environment = process.env) {
|
|
12
|
+
const configured = environment.OPENCODE_CONFIG_DIR;
|
|
13
|
+
if (configured !== undefined && configured !== "") {
|
|
14
|
+
return path.resolve(configured);
|
|
15
|
+
}
|
|
16
|
+
const xdg = environment.XDG_CONFIG_HOME;
|
|
17
|
+
return xdg !== undefined && path.isAbsolute(xdg)
|
|
18
|
+
? path.join(xdg, "opencode")
|
|
19
|
+
: path.join(homedir(), ".config", "opencode");
|
|
20
|
+
}
|
|
21
|
+
/** OpenCode 1.19 uses xdg-basedir for its state directory on every platform, including Windows. */
|
|
22
|
+
export function stateDirectory(environment = process.env) {
|
|
23
|
+
const xdg = environment.XDG_STATE_HOME;
|
|
24
|
+
return path.join(xdg !== undefined && path.isAbsolute(xdg)
|
|
25
|
+
? xdg
|
|
26
|
+
: path.join(homedir(), ".local", "state"), "opencode");
|
|
27
|
+
}
|
|
28
|
+
/** Outside a git repository OpenCode reports the worktree as "/", so the directory stands in for it. */
|
|
29
|
+
export function worktreeRoot(worktree, directory) {
|
|
30
|
+
if (typeof worktree === "string" && worktree !== "" && worktree !== "/") {
|
|
31
|
+
return worktree;
|
|
32
|
+
}
|
|
33
|
+
return typeof directory === "string" && directory !== ""
|
|
34
|
+
? directory
|
|
35
|
+
: process.cwd();
|
|
36
|
+
}
|
|
37
|
+
/** `resolve` already drops trailing separators except at a root, so equivalent spellings share a key. */
|
|
38
|
+
function worktreeKey(worktree, platform) {
|
|
39
|
+
return platform === "win32"
|
|
40
|
+
? path.win32.resolve(worktree).toLowerCase()
|
|
41
|
+
: path.posix.resolve(worktree);
|
|
42
|
+
}
|
|
43
|
+
export function selectionFilePath(state, worktree, platform = process.platform) {
|
|
44
|
+
const key = createHash("sha256")
|
|
45
|
+
.update(worktreeKey(worktree, platform))
|
|
46
|
+
.digest("hex")
|
|
47
|
+
.slice(0, 32);
|
|
48
|
+
const platformPath = platform === "win32" ? path.win32 : path.posix;
|
|
49
|
+
return platformPath.join(state, "model-profiles", "worktrees", `${key}.json`);
|
|
50
|
+
}
|
|
51
|
+
export async function readSavedSelection(file) {
|
|
52
|
+
let text;
|
|
53
|
+
try {
|
|
54
|
+
text = await readFile(file, "utf8");
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
58
|
+
return { profile: undefined };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
profile: undefined,
|
|
62
|
+
notice: `Could not read the saved profile at ${file}; using the default selection.`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const value = JSON.parse(text);
|
|
67
|
+
if (isRecord(value) &&
|
|
68
|
+
value.version === 1 &&
|
|
69
|
+
(value.profile === null ||
|
|
70
|
+
(typeof value.profile === "string" && value.profile !== ""))) {
|
|
71
|
+
return { profile: value.profile };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// An unreadable selection is reported below like any other invalid content.
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
profile: undefined,
|
|
79
|
+
notice: `The saved profile at ${file} is invalid; using the default selection. Pick a profile with /model-config to replace it.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export async function saveSelection(file, worktree, profile) {
|
|
83
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
84
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
85
|
+
try {
|
|
86
|
+
await writeFile(temporary, `${JSON.stringify({ version: 1, worktree, profile }, null, 2)}\n`, "utf8");
|
|
87
|
+
await rename(temporary, file);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await rm(temporary, { force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function clearSelection(file) {
|
|
94
|
+
await rm(file, { force: true });
|
|
95
|
+
}
|