agentsw 0.6.0 → 0.6.2
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/dist/jsonc.js +111 -0
- package/dist/targets/dsh.js +2 -2
- package/dist/targets/hermes.js +14 -5
- package/dist/targets/omp.js +56 -17
- package/dist/targets/opencode.js +12 -10
- package/dist/targets/pistyle.js +61 -28
- package/dist/targets/wire.js +7 -4
- package/package.json +2 -1
package/dist/jsonc.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { applyEdits, createScanner, getNodeValue, modify, parseTree, printParseErrorCode, } from "jsonc-parser";
|
|
3
|
+
export function isJsonObject(value) {
|
|
4
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function parseJsoncObject(file, text) {
|
|
7
|
+
const source = text.startsWith("\uFEFF") ? text.slice(1) : text;
|
|
8
|
+
const errors = [];
|
|
9
|
+
const root = parseTree(source, errors, { allowTrailingComma: true });
|
|
10
|
+
const error = errors[0];
|
|
11
|
+
if (error) {
|
|
12
|
+
// Do not quote source text: config files can contain credentials.
|
|
13
|
+
throw new Error(`${file}: invalid JSONC (${printParseErrorCode(error.error)} at offset ${error.offset})`);
|
|
14
|
+
}
|
|
15
|
+
if (root?.type !== "object")
|
|
16
|
+
throw new Error(`${file}: expected a JSON object at the document root`);
|
|
17
|
+
// Parsing resolves duplicate keys last-wins, but modify edits the first occurrence.
|
|
18
|
+
// Reject that ambiguity at any depth rather than syncing a value the agent will not read.
|
|
19
|
+
const checkDuplicates = (node) => {
|
|
20
|
+
const names = node.type === "object" ? new Set() : undefined;
|
|
21
|
+
for (const child of node.children ?? []) {
|
|
22
|
+
if (names) {
|
|
23
|
+
const name = child.children[0].value;
|
|
24
|
+
if (names.has(name)) {
|
|
25
|
+
throw new Error(`${file}: invalid JSONC (duplicate object property at offset ${child.offset})`);
|
|
26
|
+
}
|
|
27
|
+
names.add(name);
|
|
28
|
+
}
|
|
29
|
+
checkDuplicates(child);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
checkDuplicates(root);
|
|
33
|
+
return getNodeValue(root);
|
|
34
|
+
}
|
|
35
|
+
/** Only missing files initialize to an empty object; unreadable or invalid files must not be replaced. */
|
|
36
|
+
export function readJsoncObject(file) {
|
|
37
|
+
let text;
|
|
38
|
+
try {
|
|
39
|
+
text = fs.readFileSync(file, "utf8");
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error.code === "ENOENT")
|
|
43
|
+
return { file, text: "{}\n", value: {} };
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
return { file, text, value: parseJsoncObject(file, text) };
|
|
47
|
+
}
|
|
48
|
+
function comments(text) {
|
|
49
|
+
const result = [];
|
|
50
|
+
const scanner = createScanner(text);
|
|
51
|
+
for (let token = scanner.scan(); token !== 17 /* SyntaxKind.EOF */; token = scanner.scan()) {
|
|
52
|
+
if (token === 12 /* SyntaxKind.LineCommentTrivia */ || token === 13 /* SyntaxKind.BlockCommentTrivia */) {
|
|
53
|
+
result.push(text.slice(scanner.getTokenOffset(), scanner.getTokenOffset() + scanner.getTokenLength()));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
/** modify() may include adjacent comments in deletion ranges; retain them as detached comments. */
|
|
59
|
+
function retainComments(text, edit, eol) {
|
|
60
|
+
const remaining = new Map();
|
|
61
|
+
for (const comment of comments(edit.content))
|
|
62
|
+
remaining.set(comment, (remaining.get(comment) ?? 0) + 1);
|
|
63
|
+
const removed = [];
|
|
64
|
+
for (const comment of comments(text.slice(edit.offset, edit.offset + edit.length))) {
|
|
65
|
+
const count = remaining.get(comment) ?? 0;
|
|
66
|
+
if (count)
|
|
67
|
+
remaining.set(comment, count - 1);
|
|
68
|
+
else
|
|
69
|
+
removed.push(comment);
|
|
70
|
+
}
|
|
71
|
+
return removed.length ? { ...edit, content: eol + removed.join(eol) + eol + edit.content } : edit;
|
|
72
|
+
}
|
|
73
|
+
/** Change only differing values, leaving unrelated fields, comments, and string literals intact. */
|
|
74
|
+
export function editJsoncObject(document, value) {
|
|
75
|
+
const bom = document.text.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
76
|
+
let text = bom ? document.text.slice(1) : document.text;
|
|
77
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
78
|
+
const update = (location, previous, next) => {
|
|
79
|
+
if (previous === next)
|
|
80
|
+
return;
|
|
81
|
+
if (isJsonObject(previous) && isJsonObject(next)) {
|
|
82
|
+
const keys = Object.keys(next);
|
|
83
|
+
if (keys.length || !Object.keys(previous).length) {
|
|
84
|
+
// Add before deleting, so a trailing-comma object never temporarily loses its last property.
|
|
85
|
+
for (const key of keys)
|
|
86
|
+
update([...location, key], previous[key], next[key]);
|
|
87
|
+
for (const key of Object.keys(previous)) {
|
|
88
|
+
if (!Object.hasOwn(next, key))
|
|
89
|
+
update([...location, key], previous[key], undefined);
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Replacing an emptying object also removes its trailing comma (modify's last-property
|
|
94
|
+
// deletion otherwise leaves `{,}`). retainComments keeps comments from the removed subtree.
|
|
95
|
+
}
|
|
96
|
+
// Replace shrinking arrays whole: modify's last-element deletion can truncate a compact
|
|
97
|
+
// value incorrectly. Equal-length/growing arrays still get incremental, comment-safe edits.
|
|
98
|
+
if (Array.isArray(previous) && Array.isArray(next) && next.length >= previous.length) {
|
|
99
|
+
for (let index = 0; index < next.length; index++) {
|
|
100
|
+
update([...location, index], previous[index], next[index]);
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const edits = modify(text, location, next, { formattingOptions: { insertSpaces: true, tabSize: 2, eol } });
|
|
105
|
+
text = applyEdits(text, edits.map((edit) => retainComments(text, edit, eol)));
|
|
106
|
+
};
|
|
107
|
+
update([], document.value, value);
|
|
108
|
+
// Never let a library edit boundary or comment relocation write malformed output.
|
|
109
|
+
parseJsoncObject(document.file, text);
|
|
110
|
+
return bom + text;
|
|
111
|
+
}
|
package/dist/targets/dsh.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { backupFile, expandHome, home, localAppDataDir, readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
5
|
-
import { apiValue, classifyApi, mergeModels,
|
|
5
|
+
import { apiValue, classifyApi, mergeModels, sdkBaseUrl } from "./wire.js";
|
|
6
6
|
/** `$DSH_HOME`, or `~/.dsh` (packages/util/home-paths `resolveDshHome`). */
|
|
7
7
|
function dshHome() {
|
|
8
8
|
const env = process.env.DSH_HOME?.trim();
|
|
@@ -91,7 +91,7 @@ export const dsh = {
|
|
|
91
91
|
displayName: provider.name,
|
|
92
92
|
apiKeyEnv: ref, // credential reference, never the secret
|
|
93
93
|
api: apiValue(provider.protocol, provider.openaiApi, prev?.api),
|
|
94
|
-
baseURL:
|
|
94
|
+
baseURL: sdkBaseUrl(provider.protocol, provider.baseUrl),
|
|
95
95
|
models: mergeModels(prev?.models, provider.models.map(modelEntry), OWNED_MODEL_KEYS),
|
|
96
96
|
};
|
|
97
97
|
if (YAML.isMap(doc.getIn(at))) {
|
package/dist/targets/hermes.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { backupFile, home, localAppDataDir, readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
5
|
-
import {
|
|
5
|
+
import { sdkBaseUrl } from "./wire.js";
|
|
6
6
|
function hermesHome() {
|
|
7
7
|
const env = process.env.HERMES_HOME?.trim();
|
|
8
8
|
if (env)
|
|
@@ -47,9 +47,13 @@ export const hermes = {
|
|
|
47
47
|
}
|
|
48
48
|
const entry = {
|
|
49
49
|
name: provider.name,
|
|
50
|
-
api:
|
|
50
|
+
api: sdkBaseUrl(provider.protocol, provider.baseUrl),
|
|
51
51
|
key_env: keyVar,
|
|
52
|
-
transport: provider.protocol === "anthropic"
|
|
52
|
+
transport: provider.protocol === "anthropic"
|
|
53
|
+
? "anthropic_messages"
|
|
54
|
+
: provider.openaiApi === "responses"
|
|
55
|
+
? "codex_responses"
|
|
56
|
+
: "chat_completions",
|
|
53
57
|
default_model: provider.defaultModel,
|
|
54
58
|
models,
|
|
55
59
|
};
|
|
@@ -158,9 +162,14 @@ export const hermes = {
|
|
|
158
162
|
return Object.entries(parsed.providers).flatMap(([id, entry]) => {
|
|
159
163
|
if (!entry || typeof entry.api !== "string" || !entry.api)
|
|
160
164
|
return [];
|
|
161
|
-
const protocol = entry.transport === "anthropic_messages"
|
|
165
|
+
const protocol = entry.transport === "anthropic_messages"
|
|
166
|
+
? "anthropic"
|
|
167
|
+
: ["chat_completions", "codex_responses"].includes(entry.transport ?? "")
|
|
168
|
+
? "openai"
|
|
169
|
+
: undefined;
|
|
162
170
|
if (!protocol)
|
|
163
171
|
return [];
|
|
172
|
+
const openaiApi = protocol === "openai" ? (entry.transport === "codex_responses" ? "responses" : "completions") : undefined;
|
|
164
173
|
const keyEnv = typeof entry.key_env === "string" && entry.key_env ? entry.key_env : undefined;
|
|
165
174
|
const apiKey = keyEnv ? readEnv(keyEnv) : undefined;
|
|
166
175
|
const models = Object.keys(entry.models ?? {});
|
|
@@ -168,7 +177,7 @@ export const hermes = {
|
|
|
168
177
|
const defaultModel = entry.default_model ?? active;
|
|
169
178
|
if (defaultModel && !models.includes(defaultModel))
|
|
170
179
|
models.unshift(defaultModel);
|
|
171
|
-
return [{ id, name: entry.name ?? id, protocol, baseUrl: entry.api, apiKey, keyEnv, models, defaultModel, source: self }];
|
|
180
|
+
return [{ id, name: entry.name ?? id, protocol, openaiApi, baseUrl: entry.api, apiKey, keyEnv, models, defaultModel, source: self }];
|
|
172
181
|
});
|
|
173
182
|
},
|
|
174
183
|
};
|
package/dist/targets/omp.js
CHANGED
|
@@ -3,13 +3,57 @@ import path from "node:path";
|
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { backupFile, home, readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
5
5
|
import { looksLikeEnvName } from "../slug.js";
|
|
6
|
-
import {
|
|
7
|
-
import { apiValue, classifyApi, entryApi, mergeModels, stripConflictingOverrides } from "./wire.js";
|
|
6
|
+
import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
|
|
8
7
|
/** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
|
|
9
8
|
const OWNED_MODEL_KEYS = ["id", "name", "reasoning", "input", "contextWindow", "maxTokens", "cost"];
|
|
10
9
|
const agentDir = path.join(home, ".omp", "agent");
|
|
11
10
|
const modelsYml = path.join(agentDir, "models.yml");
|
|
12
11
|
const modelsYaml = path.join(agentDir, "models.yaml");
|
|
12
|
+
/** Resolve references before edits can remove their anchors or mutate shared providers. */
|
|
13
|
+
function parseModelsDocument(file, text) {
|
|
14
|
+
try {
|
|
15
|
+
const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
|
|
16
|
+
if (doc.errors.length)
|
|
17
|
+
throw doc.errors[0];
|
|
18
|
+
if (doc.contents == null)
|
|
19
|
+
doc.contents = doc.createNode({});
|
|
20
|
+
// Validate references with the full document context and the default alias limit.
|
|
21
|
+
// Cycles are valid YAML, but not a usable model configuration; reject before expansion.
|
|
22
|
+
JSON.stringify(doc.toJS());
|
|
23
|
+
const expanded = new Map();
|
|
24
|
+
YAML.visit(doc, {
|
|
25
|
+
Alias(_key, alias) {
|
|
26
|
+
const node = doc.createNode(alias.toJS(doc), { aliasDuplicateObjects: false });
|
|
27
|
+
node.comment = alias.comment;
|
|
28
|
+
node.commentBefore = alias.commentBefore;
|
|
29
|
+
node.spaceBefore = alias.spaceBefore;
|
|
30
|
+
expanded.set(alias, node);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
// Resolve all references before replacing any nodes (anchors may be reused).
|
|
34
|
+
YAML.visit(doc, { Alias: (_key, alias) => expanded.get(alias) });
|
|
35
|
+
if (!YAML.isMap(doc.contents))
|
|
36
|
+
throw new Error("expected a configuration mapping");
|
|
37
|
+
if (doc.hasIn(["providers"]) && !YAML.isMap(doc.getIn(["providers"]))) {
|
|
38
|
+
throw new Error("expected providers to be a mapping");
|
|
39
|
+
}
|
|
40
|
+
return doc;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw new Error(`${file} has invalid YAML: ${error instanceof Error ? error.message : String(error)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Validate the emitted document before any backup or write. */
|
|
47
|
+
function serializeModelsDocument(file, doc) {
|
|
48
|
+
try {
|
|
49
|
+
const text = doc.toString();
|
|
50
|
+
YAML.parse(text);
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new Error(`${file} has invalid YAML: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
13
57
|
/**
|
|
14
58
|
* Oh My Pi: providers live in ~/.omp/agent/models.yml (models.yaml fallback).
|
|
15
59
|
* YAML Document round-trip preserves existing comments and unrelated providers.
|
|
@@ -25,15 +69,11 @@ export const omp = {
|
|
|
25
69
|
// Respect an existing models.yaml if models.yml is absent (omp precedence: yml then yaml).
|
|
26
70
|
const file = !fs.existsSync(modelsYml) && fs.existsSync(modelsYaml) ? modelsYaml : modelsYml;
|
|
27
71
|
const text = readTextIfExists(file);
|
|
28
|
-
const doc =
|
|
29
|
-
|
|
30
|
-
throw new Error(`${file} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
|
|
31
|
-
}
|
|
32
|
-
if (doc.contents == null)
|
|
33
|
-
doc.contents = doc.createNode({});
|
|
34
|
-
const prev = doc.getIn(["providers", provider.id])?.toJSON?.();
|
|
72
|
+
const doc = parseModelsDocument(file, text);
|
|
73
|
+
const prev = doc.getIn(["providers", provider.id])?.toJS(doc);
|
|
35
74
|
const anthropic = provider.protocol === "anthropic";
|
|
36
75
|
const api = apiValue(provider.protocol, provider.openaiApi, entryApi(prev ?? {}));
|
|
76
|
+
const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
|
|
37
77
|
const models = mergeModels(prev?.models, provider.models.map((m) => ({
|
|
38
78
|
id: m.id,
|
|
39
79
|
...(m.name ? { name: m.name } : {}),
|
|
@@ -52,11 +92,11 @@ export const omp = {
|
|
|
52
92
|
}
|
|
53
93
|
: {}),
|
|
54
94
|
})), OWNED_MODEL_KEYS);
|
|
55
|
-
const conflicts = stripConflictingOverrides(models, api,
|
|
95
|
+
const conflicts = stripConflictingOverrides(models, api, baseUrl);
|
|
56
96
|
if (conflicts.length)
|
|
57
97
|
notes.push(`dropped model overrides pointing elsewhere: ${conflicts.join(", ")}`);
|
|
58
98
|
const entry = {
|
|
59
|
-
baseUrl
|
|
99
|
+
baseUrl,
|
|
60
100
|
apiKey: provider.apiKey, // omp treats value as env-var name first, then literal
|
|
61
101
|
api,
|
|
62
102
|
models,
|
|
@@ -79,10 +119,11 @@ export const omp = {
|
|
|
79
119
|
else {
|
|
80
120
|
doc.setIn(at, doc.createNode(entry));
|
|
81
121
|
}
|
|
122
|
+
const output = serializeModelsDocument(file, doc);
|
|
82
123
|
const backup = backupFile(file);
|
|
83
124
|
if (backup)
|
|
84
125
|
notes.push(`backup: ${backup}`);
|
|
85
|
-
writeFileAtomic(file,
|
|
126
|
+
writeFileAtomic(file, output);
|
|
86
127
|
notes.push(`select in omp with: omp --model ${provider.id}/${provider.defaultModel}`);
|
|
87
128
|
return { app: this.id, changed: [file], notes };
|
|
88
129
|
},
|
|
@@ -90,19 +131,17 @@ export const omp = {
|
|
|
90
131
|
const file = fs.existsSync(modelsYml) ? modelsYml : fs.existsSync(modelsYaml) ? modelsYaml : undefined;
|
|
91
132
|
if (!file)
|
|
92
133
|
return { app: this.id, changed: [], notes: [], skipped: "no models.yml" };
|
|
93
|
-
const doc =
|
|
94
|
-
if (doc.errors.length) {
|
|
95
|
-
throw new Error(`${file} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
|
|
96
|
-
}
|
|
134
|
+
const doc = parseModelsDocument(file, readTextIfExists(file));
|
|
97
135
|
if (!doc.hasIn(["providers", provider.id])) {
|
|
98
136
|
return { app: this.id, changed: [], notes: [], skipped: `no providers.${provider.id} entry` };
|
|
99
137
|
}
|
|
100
138
|
doc.deleteIn(["providers", provider.id]);
|
|
101
139
|
const notes = [];
|
|
140
|
+
const output = serializeModelsDocument(file, doc);
|
|
102
141
|
const backup = backupFile(file);
|
|
103
142
|
if (backup)
|
|
104
143
|
notes.push(`backup: ${backup}`);
|
|
105
|
-
writeFileAtomic(file,
|
|
144
|
+
writeFileAtomic(file, output);
|
|
106
145
|
return { app: this.id, changed: [file], notes };
|
|
107
146
|
},
|
|
108
147
|
current() {
|
package/dist/targets/opencode.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { appDataDir, backupFile, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
3
|
+
import { appDataDir, backupFile, expandHome, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
|
|
4
|
+
const customDir = process.env.OPENCODE_CONFIG_DIR?.trim();
|
|
5
|
+
const configFile = customDir
|
|
6
|
+
? path.join(expandHome(customDir), "opencode.json")
|
|
7
|
+
: path.join(appDataDir("opencode"), "opencode.json");
|
|
7
8
|
/**
|
|
8
9
|
* opencode: custom providers in ~/.config/opencode/opencode.json "provider" map,
|
|
9
10
|
* backed by @ai-sdk/openai-compatible or @ai-sdk/anthropic npm loaders.
|
|
@@ -48,11 +49,11 @@ export const opencode = {
|
|
|
48
49
|
},
|
|
49
50
|
}
|
|
50
51
|
: {}),
|
|
51
|
-
...(m.contextWindow
|
|
52
|
+
...(m.contextWindow && m.maxOutput
|
|
52
53
|
? {
|
|
53
54
|
limit: {
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
context: m.contextWindow,
|
|
56
|
+
output: m.maxOutput,
|
|
56
57
|
},
|
|
57
58
|
}
|
|
58
59
|
: {}),
|
|
@@ -60,11 +61,11 @@ export const opencode = {
|
|
|
60
61
|
}
|
|
61
62
|
providerMap[provider.id] = {
|
|
62
63
|
...prev,
|
|
63
|
-
npm: anthropic ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible",
|
|
64
|
+
npm: anthropic ? "@ai-sdk/anthropic" : provider.openaiApi === "responses" ? "@ai-sdk/openai" : "@ai-sdk/openai-compatible",
|
|
64
65
|
name: provider.name,
|
|
65
66
|
options: {
|
|
66
67
|
...prev.options,
|
|
67
|
-
baseURL:
|
|
68
|
+
baseURL: provider.baseUrl.replace(/\/+$/, ""),
|
|
68
69
|
apiKey: provider.apiKey,
|
|
69
70
|
},
|
|
70
71
|
models,
|
|
@@ -120,6 +121,7 @@ export const opencode = {
|
|
|
120
121
|
if (!entry.npm)
|
|
121
122
|
return []; // built-in provider without a custom npm loader
|
|
122
123
|
const protocol = entry.npm.includes("anthropic") ? "anthropic" : "openai";
|
|
124
|
+
const openaiApi = protocol === "openai" ? (entry.npm === "@ai-sdk/openai" ? "responses" : "completions") : undefined;
|
|
123
125
|
let apiKey = entry.options?.apiKey;
|
|
124
126
|
let keyEnv;
|
|
125
127
|
const ref = apiKey?.match(/^\{env:([A-Za-z0-9_]+)\}$/);
|
|
@@ -131,7 +133,7 @@ export const opencode = {
|
|
|
131
133
|
const defaultModel = activeProvider === id ? activeModel : undefined;
|
|
132
134
|
if (defaultModel && !models.includes(defaultModel))
|
|
133
135
|
models.unshift(defaultModel);
|
|
134
|
-
return [{ id, name: entry.name ?? id, protocol, baseUrl, apiKey, keyEnv, models, defaultModel, source: self }];
|
|
136
|
+
return [{ id, name: entry.name ?? id, protocol, openaiApi, baseUrl, apiKey, keyEnv, models, defaultModel, source: self }];
|
|
135
137
|
});
|
|
136
138
|
},
|
|
137
139
|
};
|
package/dist/targets/pistyle.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { backupFile, home,
|
|
3
|
+
import { backupFile, home, writeFileAtomic } from "../fsutil.js";
|
|
4
|
+
import { editJsoncObject, isJsonObject, readJsoncObject } from "../jsonc.js";
|
|
4
5
|
import { looksLikeEnvName } from "../slug.js";
|
|
5
|
-
import {
|
|
6
|
-
import { apiValue, classifyApi, entryApi, mergeModels, stripConflictingOverrides } from "./wire.js";
|
|
6
|
+
import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
|
|
7
7
|
/** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
|
|
8
8
|
const OWNED_MODEL_KEYS = [
|
|
9
9
|
"id",
|
|
@@ -15,6 +15,28 @@ const OWNED_MODEL_KEYS = [
|
|
|
15
15
|
"maxTokens",
|
|
16
16
|
"cost",
|
|
17
17
|
];
|
|
18
|
+
function readModels(file) {
|
|
19
|
+
const document = readJsoncObject(file);
|
|
20
|
+
const providers = document.value.providers;
|
|
21
|
+
if (providers !== undefined) {
|
|
22
|
+
if (!isJsonObject(providers))
|
|
23
|
+
throw new Error(`${file}: expected providers to be a JSON object`);
|
|
24
|
+
for (const entry of Object.values(providers)) {
|
|
25
|
+
if (!isJsonObject(entry))
|
|
26
|
+
throw new Error(`${file}: expected every provider entry to be a JSON object`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { ...document, value: document.value };
|
|
30
|
+
}
|
|
31
|
+
function readSettings(file) {
|
|
32
|
+
const document = readJsoncObject(file);
|
|
33
|
+
for (const key of ["defaultProvider", "defaultModel"]) {
|
|
34
|
+
if (document.value[key] !== undefined && typeof document.value[key] !== "string") {
|
|
35
|
+
throw new Error(`${file}: expected ${key} to be a string`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { ...document, value: document.value };
|
|
39
|
+
}
|
|
18
40
|
/**
|
|
19
41
|
* pi-family agents (pi, prime-agent) share the same config layout:
|
|
20
42
|
* <dir>/models.json — { providers: { <id>: { baseUrl, apiKey, api, models[] } } }
|
|
@@ -51,15 +73,16 @@ export function piStyleTarget(opts) {
|
|
|
51
73
|
const modelsFile = path.join(dir, "models.json");
|
|
52
74
|
const settingsFile = path.join(dir, "settings.json");
|
|
53
75
|
const notes = [];
|
|
54
|
-
|
|
76
|
+
// Validate both files before preparing any writes, including a malformed settings file.
|
|
77
|
+
const modelsDocument = readModels(modelsFile);
|
|
78
|
+
const settingsDocument = readSettings(settingsFile);
|
|
79
|
+
const modelsConfig = { ...modelsDocument.value };
|
|
55
80
|
const providers = { ...modelsConfig.providers };
|
|
56
81
|
// Keys agentsw does not model (headers, authHeader, oauth, ...) and per-model extras
|
|
57
82
|
// are the user's; a re-sync overwrites only the fields it owns.
|
|
58
|
-
const
|
|
59
|
-
const prev = (existing && typeof existing === "object" && !Array.isArray(existing)
|
|
60
|
-
? existing
|
|
61
|
-
: {});
|
|
83
|
+
const prev = providers[provider.id] ?? {};
|
|
62
84
|
const api = apiValue(provider.protocol, provider.openaiApi, entryApi(prev));
|
|
85
|
+
const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
|
|
63
86
|
const models = mergeModels(prev.models, provider.models.map((m) => ({
|
|
64
87
|
id: m.id,
|
|
65
88
|
...(m.name ? { name: m.name } : {}),
|
|
@@ -81,72 +104,82 @@ export function piStyleTarget(opts) {
|
|
|
81
104
|
}
|
|
82
105
|
: {}),
|
|
83
106
|
})), OWNED_MODEL_KEYS);
|
|
84
|
-
const conflicts = stripConflictingOverrides(models, api,
|
|
107
|
+
const conflicts = stripConflictingOverrides(models, api, baseUrl);
|
|
85
108
|
if (conflicts.length)
|
|
86
109
|
notes.push(`dropped model overrides pointing elsewhere: ${conflicts.join(", ")}`);
|
|
87
110
|
providers[provider.id] = {
|
|
88
111
|
...prev,
|
|
89
112
|
name: provider.name,
|
|
90
|
-
baseUrl
|
|
113
|
+
baseUrl,
|
|
91
114
|
apiKey: provider.apiKey, // literal key; pi treats "$VAR"/"!cmd" as indirection, prime also accepts bare env names
|
|
92
115
|
api,
|
|
93
116
|
models,
|
|
94
117
|
};
|
|
95
118
|
modelsConfig.providers = providers;
|
|
119
|
+
const settings = {
|
|
120
|
+
...settingsDocument.value,
|
|
121
|
+
defaultProvider: provider.id,
|
|
122
|
+
defaultModel: provider.defaultModel,
|
|
123
|
+
};
|
|
124
|
+
const modelsText = editJsoncObject(modelsDocument, modelsConfig);
|
|
125
|
+
const settingsText = editJsoncObject(settingsDocument, settings);
|
|
96
126
|
const modelsBackup = backupFile(modelsFile);
|
|
97
127
|
if (modelsBackup)
|
|
98
128
|
notes.push(`backup: ${modelsBackup}`);
|
|
99
|
-
writeFileAtomic(modelsFile,
|
|
100
|
-
const settings = readJsonIfExists(settingsFile) ?? {};
|
|
101
|
-
settings.defaultProvider = provider.id;
|
|
102
|
-
settings.defaultModel = provider.defaultModel;
|
|
129
|
+
writeFileAtomic(modelsFile, modelsText);
|
|
103
130
|
const settingsBackup = backupFile(settingsFile);
|
|
104
131
|
if (settingsBackup)
|
|
105
132
|
notes.push(`backup: ${settingsBackup}`);
|
|
106
|
-
writeFileAtomic(settingsFile,
|
|
133
|
+
writeFileAtomic(settingsFile, settingsText);
|
|
107
134
|
return { app: opts.id, changed: [modelsFile, settingsFile], notes };
|
|
108
135
|
},
|
|
109
136
|
async prune(provider) {
|
|
110
137
|
const dir = resolveDir();
|
|
111
138
|
const modelsFile = path.join(dir, "models.json");
|
|
112
139
|
const settingsFile = path.join(dir, "settings.json");
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
|
|
140
|
+
const modelsDocument = readModels(modelsFile);
|
|
141
|
+
const settingsDocument = readSettings(settingsFile);
|
|
142
|
+
const providers = { ...modelsDocument.value.providers };
|
|
143
|
+
if (!Object.hasOwn(providers, provider.id)) {
|
|
116
144
|
return { app: opts.id, changed: [], notes: [], skipped: `no providers.${provider.id} entry` };
|
|
117
145
|
}
|
|
118
146
|
delete providers[provider.id];
|
|
147
|
+
const settings = { ...settingsDocument.value };
|
|
148
|
+
const resetDefault = settings.defaultProvider === provider.id;
|
|
149
|
+
if (resetDefault) {
|
|
150
|
+
delete settings.defaultProvider;
|
|
151
|
+
delete settings.defaultModel;
|
|
152
|
+
}
|
|
153
|
+
const modelsText = editJsoncObject(modelsDocument, { ...modelsDocument.value, providers });
|
|
154
|
+
const settingsText = resetDefault ? editJsoncObject(settingsDocument, settings) : undefined;
|
|
119
155
|
const notes = [];
|
|
120
156
|
const changed = [modelsFile];
|
|
121
157
|
const modelsBackup = backupFile(modelsFile);
|
|
122
158
|
if (modelsBackup)
|
|
123
159
|
notes.push(`backup: ${modelsBackup}`);
|
|
124
|
-
writeFileAtomic(modelsFile,
|
|
125
|
-
|
|
126
|
-
if (settings?.defaultProvider === provider.id) {
|
|
127
|
-
delete settings.defaultProvider;
|
|
128
|
-
delete settings.defaultModel;
|
|
160
|
+
writeFileAtomic(modelsFile, modelsText);
|
|
161
|
+
if (settingsText !== undefined) {
|
|
129
162
|
const settingsBackup = backupFile(settingsFile);
|
|
130
163
|
if (settingsBackup)
|
|
131
164
|
notes.push(`backup: ${settingsBackup}`);
|
|
132
|
-
writeFileAtomic(settingsFile,
|
|
165
|
+
writeFileAtomic(settingsFile, settingsText);
|
|
133
166
|
changed.push(settingsFile);
|
|
134
167
|
notes.push("default model reset (was pointing at this provider)");
|
|
135
168
|
}
|
|
136
169
|
return { app: opts.id, changed, notes };
|
|
137
170
|
},
|
|
138
171
|
current() {
|
|
139
|
-
const settings =
|
|
172
|
+
const settings = readSettings(path.join(resolveDir(), "settings.json")).value;
|
|
140
173
|
if (!settings?.defaultProvider)
|
|
141
174
|
return undefined;
|
|
142
175
|
return `${settings.defaultProvider} · ${settings.defaultModel ?? "?"}`;
|
|
143
176
|
},
|
|
144
177
|
candidates() {
|
|
145
178
|
const dir = resolveDir();
|
|
146
|
-
const mc =
|
|
147
|
-
|
|
179
|
+
const mc = readModels(path.join(dir, "models.json")).value;
|
|
180
|
+
const settings = readSettings(path.join(dir, "settings.json")).value;
|
|
181
|
+
if (!mc.providers)
|
|
148
182
|
return [];
|
|
149
|
-
const settings = readJsonIfExists(path.join(dir, "settings.json"));
|
|
150
183
|
const self = opts.id;
|
|
151
184
|
return Object.entries(mc.providers).flatMap(([id, entry]) => {
|
|
152
185
|
if (!entry || typeof entry.baseUrl !== "string")
|
package/dist/targets/wire.js
CHANGED
|
@@ -108,11 +108,14 @@ export function stripConflictingOverrides(models, api, baseUrl) {
|
|
|
108
108
|
return dropped;
|
|
109
109
|
}
|
|
110
110
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* (
|
|
114
|
-
* it appends `/responses` directly, so it keeps the `/v1` segment.
|
|
111
|
+
* Return the base URL expected by SDK-backed clients. Anthropic SDK methods
|
|
112
|
+
* append `/v1/...`, while OpenAI-compatible clients append only the operation
|
|
113
|
+
* path (`/responses` or `/chat/completions`) and therefore keep `/v1`.
|
|
115
114
|
*/
|
|
115
|
+
export function sdkBaseUrl(protocol, baseUrl) {
|
|
116
|
+
return protocol === "anthropic" ? stripApiVersion(baseUrl) : baseUrl.replace(/\/+$/, "");
|
|
117
|
+
}
|
|
118
|
+
/** Strip a trailing API version segment (e.g. `/v1`, `/v2beta`). */
|
|
116
119
|
export function stripApiVersion(baseUrl) {
|
|
117
120
|
return baseUrl.replace(/\/v\d+(?:beta\d*)?\/?$/i, "");
|
|
118
121
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentsw",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "CLI provider switcher for coding agents: manage OpenAI/Anthropic-protocol providers in one place and sync them, with models.dev metadata, into Claude Code, Codex, omp, pi, prime-agent, opencode, Hermes, WorkBuddy and DeepSeek Harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"commander": "^15.0.0",
|
|
44
|
+
"jsonc-parser": "^3.3.1",
|
|
44
45
|
"picocolors": "^1.1.1",
|
|
45
46
|
"prompts": "^2.4.2",
|
|
46
47
|
"smol-toml": "^1.8.0",
|