agentsw 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+ import { backupFile, home, readTextIfExists, writeFileAtomic } from "../fsutil.js";
5
+ import { looksLikeEnvName } from "../slug.js";
6
+ import { apiValue, classifyApi, entryApi, mergeModels, stripConflictingOverrides } from "./wire.js";
7
+ /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
8
+ const OWNED_MODEL_KEYS = ["id", "name", "reasoning", "input", "contextWindow", "maxTokens", "cost"];
9
+ const agentDir = path.join(home, ".omp", "agent");
10
+ const modelsYml = path.join(agentDir, "models.yml");
11
+ const modelsYaml = path.join(agentDir, "models.yaml");
12
+ /**
13
+ * Oh My Pi: providers live in ~/.omp/agent/models.yml (models.yaml fallback).
14
+ * YAML Document round-trip preserves existing comments and unrelated providers.
15
+ */
16
+ export const omp = {
17
+ id: "omp",
18
+ name: "Oh My Pi",
19
+ protocols: ["openai", "anthropic"],
20
+ configPaths: [modelsYml],
21
+ detect: () => fs.existsSync(path.join(home, ".omp")),
22
+ async apply(provider) {
23
+ const notes = [];
24
+ // Respect an existing models.yaml if models.yml is absent (omp precedence: yml then yaml).
25
+ const file = !fs.existsSync(modelsYml) && fs.existsSync(modelsYaml) ? modelsYaml : modelsYml;
26
+ const text = readTextIfExists(file);
27
+ const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
28
+ if (doc.errors.length) {
29
+ throw new Error(`${file} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
30
+ }
31
+ if (doc.contents == null)
32
+ doc.contents = doc.createNode({});
33
+ const prev = doc.getIn(["providers", provider.id])?.toJSON?.();
34
+ const anthropic = provider.protocol === "anthropic";
35
+ const api = apiValue(provider.protocol, provider.openaiApi, entryApi(prev ?? {}));
36
+ const models = mergeModels(prev?.models, provider.models.map((m) => ({
37
+ id: m.id,
38
+ ...(m.name ? { name: m.name } : {}),
39
+ ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
40
+ input: m.imageInput ? ["text", "image"] : ["text"],
41
+ ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
42
+ ...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
43
+ ...(m.cost
44
+ ? {
45
+ cost: {
46
+ input: m.cost.input ?? 0,
47
+ output: m.cost.output ?? 0,
48
+ cacheRead: m.cost.cacheRead ?? 0,
49
+ cacheWrite: m.cost.cacheWrite ?? 0,
50
+ },
51
+ }
52
+ : {}),
53
+ })), OWNED_MODEL_KEYS);
54
+ const conflicts = stripConflictingOverrides(models, api, provider.baseUrl);
55
+ if (conflicts.length)
56
+ notes.push(`dropped model overrides pointing elsewhere: ${conflicts.join(", ")}`);
57
+ const entry = {
58
+ baseUrl: provider.baseUrl,
59
+ apiKey: provider.apiKey, // omp treats value as env-var name first, then literal
60
+ api,
61
+ models,
62
+ };
63
+ if (anthropic) {
64
+ // Most Anthropic-fronted proxies reject the strict tool field.
65
+ entry.disableStrictTools = true;
66
+ }
67
+ // Key-by-key when the entry already exists: provider-level keys agentsw does not
68
+ // model (authHeader, headers, compat, auth, discovery, modelOverrides, ...) and their
69
+ // comments belong to the user; a sync must not drop them.
70
+ const at = ["providers", provider.id];
71
+ if (YAML.isMap(doc.getIn(at))) {
72
+ for (const [key, value] of Object.entries(entry))
73
+ doc.setIn([...at, key], doc.createNode(value));
74
+ // an anthropic-only key must not outlive a switch to an openai wire
75
+ if (!anthropic)
76
+ doc.deleteIn([...at, "disableStrictTools"]);
77
+ }
78
+ else {
79
+ doc.setIn(at, doc.createNode(entry));
80
+ }
81
+ const backup = backupFile(file);
82
+ if (backup)
83
+ notes.push(`backup: ${backup}`);
84
+ writeFileAtomic(file, doc.toString());
85
+ notes.push(`select in omp with: omp --model ${provider.id}/${provider.defaultModel}`);
86
+ return { app: this.id, changed: [file], notes };
87
+ },
88
+ async prune(provider) {
89
+ const file = fs.existsSync(modelsYml) ? modelsYml : fs.existsSync(modelsYaml) ? modelsYaml : undefined;
90
+ if (!file)
91
+ return { app: this.id, changed: [], notes: [], skipped: "no models.yml" };
92
+ const doc = YAML.parseDocument(readTextIfExists(file) ?? "");
93
+ if (doc.errors.length) {
94
+ throw new Error(`${file} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
95
+ }
96
+ if (!doc.hasIn(["providers", provider.id])) {
97
+ return { app: this.id, changed: [], notes: [], skipped: `no providers.${provider.id} entry` };
98
+ }
99
+ doc.deleteIn(["providers", provider.id]);
100
+ const notes = [];
101
+ const backup = backupFile(file);
102
+ if (backup)
103
+ notes.push(`backup: ${backup}`);
104
+ writeFileAtomic(file, doc.toString());
105
+ return { app: this.id, changed: [file], notes };
106
+ },
107
+ current() {
108
+ const file = fs.existsSync(modelsYml) ? modelsYml : fs.existsSync(modelsYaml) ? modelsYaml : undefined;
109
+ if (!file)
110
+ return undefined;
111
+ try {
112
+ const parsed = YAML.parse(readTextIfExists(file) ?? "");
113
+ const ids = Object.keys(parsed?.providers ?? {});
114
+ return ids.length ? `providers: ${ids.join(", ")}` : undefined;
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ },
120
+ candidates() {
121
+ const file = fs.existsSync(modelsYml) ? modelsYml : fs.existsSync(modelsYaml) ? modelsYaml : undefined;
122
+ if (!file)
123
+ return [];
124
+ let parsed;
125
+ try {
126
+ parsed = YAML.parse(readTextIfExists(file) ?? "");
127
+ }
128
+ catch {
129
+ return [];
130
+ }
131
+ if (!parsed?.providers)
132
+ return [];
133
+ const self = this.id;
134
+ return Object.entries(parsed.providers).flatMap(([id, entry]) => {
135
+ if (!entry || typeof entry.baseUrl !== "string")
136
+ return [];
137
+ const modelEntries = Array.isArray(entry.models) ? entry.models : [];
138
+ // omp takes `api` at provider level or on every model; a mixed-protocol entry is skipped.
139
+ const wire = classifyApi(entryApi(entry));
140
+ if (!wire)
141
+ return [];
142
+ let apiKey;
143
+ let keyEnv;
144
+ if (typeof entry.apiKey === "string" && entry.apiKey) {
145
+ if (looksLikeEnvName(entry.apiKey) && process.env[entry.apiKey]) {
146
+ keyEnv = entry.apiKey;
147
+ apiKey = process.env[entry.apiKey];
148
+ }
149
+ else {
150
+ // omp resolves env-var names first, then uses the value as a literal key
151
+ apiKey = entry.apiKey;
152
+ }
153
+ }
154
+ const models = modelEntries.map((m) => (typeof m?.id === "string" ? m.id : "")).filter(Boolean);
155
+ return [
156
+ {
157
+ id,
158
+ name: typeof entry.name === "string" ? entry.name : id,
159
+ protocol: wire.protocol,
160
+ openaiApi: wire.openaiApi,
161
+ baseUrl: entry.baseUrl,
162
+ apiKey,
163
+ keyEnv,
164
+ models,
165
+ source: self,
166
+ },
167
+ ];
168
+ });
169
+ },
170
+ };
@@ -0,0 +1,135 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { backupFile, home, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
+ const configFile = path.join(home, ".config", "opencode", "opencode.json");
5
+ /**
6
+ * opencode: custom providers in ~/.config/opencode/opencode.json "provider" map,
7
+ * backed by @ai-sdk/openai-compatible or @ai-sdk/anthropic npm loaders.
8
+ */
9
+ export const opencode = {
10
+ id: "opencode",
11
+ name: "opencode",
12
+ protocols: ["openai", "anthropic"],
13
+ configPaths: [configFile],
14
+ detect: () => fs.existsSync(path.dirname(configFile)),
15
+ async apply(provider) {
16
+ const notes = [];
17
+ const config = readJsonIfExists(configFile) ?? {
18
+ $schema: "https://opencode.ai/config.json",
19
+ };
20
+ const anthropic = provider.protocol === "anthropic";
21
+ const providerMap = { ...config.provider };
22
+ // Keys agentsw does not model (custom options, per-model extras) are the user's,
23
+ // but a key it owns and no longer emits is cleared rather than inherited from last sync.
24
+ const existing = providerMap[provider.id];
25
+ const prev = (existing && typeof existing === "object" && !Array.isArray(existing)
26
+ ? existing
27
+ : {});
28
+ const prevModels = (prev.models ?? {});
29
+ const models = {};
30
+ for (const m of provider.models) {
31
+ const old = { ...prevModels[m.id] };
32
+ for (const key of ["name", "reasoning", "attachment", "cost", "limit"])
33
+ delete old[key];
34
+ models[m.id] = {
35
+ ...old,
36
+ ...(m.name ? { name: m.name } : {}),
37
+ ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
38
+ ...(m.imageInput ? { attachment: true } : {}),
39
+ ...(m.cost
40
+ ? {
41
+ cost: {
42
+ input: m.cost.input ?? 0,
43
+ output: m.cost.output ?? 0,
44
+ ...(m.cost.cacheRead !== undefined ? { cache_read: m.cost.cacheRead } : {}),
45
+ ...(m.cost.cacheWrite !== undefined ? { cache_write: m.cost.cacheWrite } : {}),
46
+ },
47
+ }
48
+ : {}),
49
+ ...(m.contextWindow || m.maxOutput
50
+ ? {
51
+ limit: {
52
+ ...(m.contextWindow ? { context: m.contextWindow } : {}),
53
+ ...(m.maxOutput ? { output: m.maxOutput } : {}),
54
+ },
55
+ }
56
+ : {}),
57
+ };
58
+ }
59
+ providerMap[provider.id] = {
60
+ ...prev,
61
+ npm: anthropic ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible",
62
+ name: provider.name,
63
+ options: {
64
+ ...prev.options,
65
+ baseURL: provider.baseUrl,
66
+ apiKey: provider.apiKey,
67
+ },
68
+ models,
69
+ };
70
+ config.provider = providerMap;
71
+ config.model = `${provider.id}/${provider.defaultModel}`;
72
+ if (provider.smallModel)
73
+ config.small_model = `${provider.id}/${provider.smallModel}`;
74
+ const backup = backupFile(configFile);
75
+ if (backup)
76
+ notes.push(`backup: ${backup}`);
77
+ writeFileAtomic(configFile, JSON.stringify(config, null, 2) + "\n");
78
+ return { app: this.id, changed: [configFile], notes };
79
+ },
80
+ async prune(provider) {
81
+ const config = readJsonIfExists(configFile);
82
+ const providerMap = config?.provider;
83
+ if (!config || !providerMap?.[provider.id]) {
84
+ return { app: this.id, changed: [], notes: [], skipped: `no provider.${provider.id} entry` };
85
+ }
86
+ delete providerMap[provider.id];
87
+ if (Object.keys(providerMap).length === 0)
88
+ delete config.provider;
89
+ const notes = [];
90
+ if (typeof config.model === "string" && config.model.startsWith(`${provider.id}/`)) {
91
+ delete config.model;
92
+ notes.push("default model reset (was pointing at this provider)");
93
+ }
94
+ if (typeof config.small_model === "string" && config.small_model.startsWith(`${provider.id}/`)) {
95
+ delete config.small_model;
96
+ }
97
+ const backup = backupFile(configFile);
98
+ if (backup)
99
+ notes.push(`backup: ${backup}`);
100
+ writeFileAtomic(configFile, JSON.stringify(config, null, 2) + "\n");
101
+ return { app: this.id, changed: [configFile], notes };
102
+ },
103
+ current() {
104
+ const config = readJsonIfExists(configFile);
105
+ return config?.model;
106
+ },
107
+ candidates() {
108
+ const config = readJsonIfExists(configFile);
109
+ if (!config?.provider)
110
+ return [];
111
+ const activeProvider = config.model?.includes("/") ? config.model.split("/")[0] : undefined;
112
+ const activeModel = activeProvider && config.model && config.model.includes("/") ? config.model.split("/").slice(1).join("/") : undefined;
113
+ const self = this.id;
114
+ return Object.entries(config.provider).flatMap(([id, entry]) => {
115
+ const baseUrl = entry?.options?.baseURL;
116
+ if (!entry || !baseUrl || baseUrl.startsWith("{"))
117
+ return [];
118
+ if (!entry.npm)
119
+ return []; // built-in provider without a custom npm loader
120
+ const protocol = entry.npm.includes("anthropic") ? "anthropic" : "openai";
121
+ let apiKey = entry.options?.apiKey;
122
+ let keyEnv;
123
+ const ref = apiKey?.match(/^\{env:([A-Za-z0-9_]+)\}$/);
124
+ if (ref?.[1]) {
125
+ keyEnv = ref[1];
126
+ apiKey = process.env[ref[1]];
127
+ }
128
+ const models = Object.keys(entry.models ?? {});
129
+ const defaultModel = activeProvider === id ? activeModel : undefined;
130
+ if (defaultModel && !models.includes(defaultModel))
131
+ models.unshift(defaultModel);
132
+ return [{ id, name: entry.name ?? id, protocol, baseUrl, apiKey, keyEnv, models, defaultModel, source: self }];
133
+ });
134
+ },
135
+ };
@@ -0,0 +1,208 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { backupFile, home, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
+ import { looksLikeEnvName } from "../slug.js";
5
+ import { apiValue, classifyApi, entryApi, mergeModels, stripConflictingOverrides } from "./wire.js";
6
+ /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
7
+ const OWNED_MODEL_KEYS = [
8
+ "id",
9
+ "name",
10
+ "reasoning",
11
+ "thinkingLevelMap",
12
+ "input",
13
+ "contextWindow",
14
+ "maxTokens",
15
+ "cost",
16
+ ];
17
+ /**
18
+ * pi-family agents (pi, prime-agent) share the same config layout:
19
+ * <dir>/models.json — { providers: { <id>: { baseUrl, apiKey, api, models[] } } }
20
+ * <dir>/settings.json — { defaultProvider, defaultModel }
21
+ * prime-agent is a pi fork with configDir ".prime/agent" instead of ".pi/agent".
22
+ */
23
+ /**
24
+ * Map models.dev reasoning-effort values onto pi thinking levels.
25
+ * pi grammar: value string = provider value, null = level unsupported.
26
+ */
27
+ function thinkingLevelMap(efforts) {
28
+ const map = {};
29
+ map.off = efforts.includes("none") ? "none" : null;
30
+ for (const level of ["minimal", "low", "medium", "high", "xhigh", "max"]) {
31
+ map[level] = efforts.includes(level) ? level : null;
32
+ }
33
+ return map;
34
+ }
35
+ export function piStyleTarget(opts) {
36
+ const resolveDir = () => {
37
+ const env = process.env[opts.dirEnvVar];
38
+ if (env)
39
+ return env.startsWith("~") ? path.join(home, env.slice(1)) : env;
40
+ return path.join(home, opts.configDirName);
41
+ };
42
+ return {
43
+ id: opts.id,
44
+ name: opts.name,
45
+ protocols: ["openai", "anthropic"],
46
+ configPaths: [path.join(resolveDir(), "models.json"), path.join(resolveDir(), "settings.json")],
47
+ detect: () => fs.existsSync(path.dirname(resolveDir())),
48
+ async apply(provider) {
49
+ const dir = resolveDir();
50
+ const modelsFile = path.join(dir, "models.json");
51
+ const settingsFile = path.join(dir, "settings.json");
52
+ const notes = [];
53
+ const modelsConfig = readJsonIfExists(modelsFile) ?? {};
54
+ const providers = { ...modelsConfig.providers };
55
+ // Keys agentsw does not model (headers, authHeader, oauth, ...) and per-model extras
56
+ // are the user's; a re-sync overwrites only the fields it owns.
57
+ const existing = providers[provider.id];
58
+ const prev = (existing && typeof existing === "object" && !Array.isArray(existing)
59
+ ? existing
60
+ : {});
61
+ const api = apiValue(provider.protocol, provider.openaiApi, entryApi(prev));
62
+ const models = mergeModels(prev.models, provider.models.map((m) => ({
63
+ id: m.id,
64
+ ...(m.name ? { name: m.name } : {}),
65
+ ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
66
+ ...(m.reasoning && m.reasoningEfforts?.length
67
+ ? { thinkingLevelMap: thinkingLevelMap(m.reasoningEfforts) }
68
+ : {}),
69
+ input: m.imageInput ? ["text", "image"] : ["text"],
70
+ ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
71
+ ...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
72
+ ...(m.cost
73
+ ? {
74
+ cost: {
75
+ input: m.cost.input ?? 0,
76
+ output: m.cost.output ?? 0,
77
+ cacheRead: m.cost.cacheRead ?? 0,
78
+ cacheWrite: m.cost.cacheWrite ?? 0,
79
+ },
80
+ }
81
+ : {}),
82
+ })), OWNED_MODEL_KEYS);
83
+ const conflicts = stripConflictingOverrides(models, api, provider.baseUrl);
84
+ if (conflicts.length)
85
+ notes.push(`dropped model overrides pointing elsewhere: ${conflicts.join(", ")}`);
86
+ providers[provider.id] = {
87
+ ...prev,
88
+ name: provider.name,
89
+ baseUrl: provider.baseUrl,
90
+ apiKey: provider.apiKey, // literal key; pi treats "$VAR"/"!cmd" as indirection, prime also accepts bare env names
91
+ api,
92
+ models,
93
+ };
94
+ modelsConfig.providers = providers;
95
+ const modelsBackup = backupFile(modelsFile);
96
+ if (modelsBackup)
97
+ notes.push(`backup: ${modelsBackup}`);
98
+ writeFileAtomic(modelsFile, JSON.stringify(modelsConfig, null, 2) + "\n");
99
+ const settings = readJsonIfExists(settingsFile) ?? {};
100
+ settings.defaultProvider = provider.id;
101
+ settings.defaultModel = provider.defaultModel;
102
+ const settingsBackup = backupFile(settingsFile);
103
+ if (settingsBackup)
104
+ notes.push(`backup: ${settingsBackup}`);
105
+ writeFileAtomic(settingsFile, JSON.stringify(settings, null, 2) + "\n");
106
+ return { app: opts.id, changed: [modelsFile, settingsFile], notes };
107
+ },
108
+ async prune(provider) {
109
+ const dir = resolveDir();
110
+ const modelsFile = path.join(dir, "models.json");
111
+ const settingsFile = path.join(dir, "settings.json");
112
+ const modelsConfig = readJsonIfExists(modelsFile);
113
+ const providers = modelsConfig?.providers;
114
+ if (!modelsConfig || !providers?.[provider.id]) {
115
+ return { app: opts.id, changed: [], notes: [], skipped: `no providers.${provider.id} entry` };
116
+ }
117
+ delete providers[provider.id];
118
+ const notes = [];
119
+ const changed = [modelsFile];
120
+ const modelsBackup = backupFile(modelsFile);
121
+ if (modelsBackup)
122
+ notes.push(`backup: ${modelsBackup}`);
123
+ writeFileAtomic(modelsFile, JSON.stringify(modelsConfig, null, 2) + "\n");
124
+ const settings = readJsonIfExists(settingsFile);
125
+ if (settings?.defaultProvider === provider.id) {
126
+ delete settings.defaultProvider;
127
+ delete settings.defaultModel;
128
+ const settingsBackup = backupFile(settingsFile);
129
+ if (settingsBackup)
130
+ notes.push(`backup: ${settingsBackup}`);
131
+ writeFileAtomic(settingsFile, JSON.stringify(settings, null, 2) + "\n");
132
+ changed.push(settingsFile);
133
+ notes.push("default model reset (was pointing at this provider)");
134
+ }
135
+ return { app: opts.id, changed, notes };
136
+ },
137
+ current() {
138
+ const settings = readJsonIfExists(path.join(resolveDir(), "settings.json"));
139
+ if (!settings?.defaultProvider)
140
+ return undefined;
141
+ return `${settings.defaultProvider} · ${settings.defaultModel ?? "?"}`;
142
+ },
143
+ candidates() {
144
+ const dir = resolveDir();
145
+ const mc = readJsonIfExists(path.join(dir, "models.json"));
146
+ if (!mc?.providers)
147
+ return [];
148
+ const settings = readJsonIfExists(path.join(dir, "settings.json"));
149
+ const self = opts.id;
150
+ return Object.entries(mc.providers).flatMap(([id, entry]) => {
151
+ if (!entry || typeof entry.baseUrl !== "string")
152
+ return [];
153
+ const modelEntries = Array.isArray(entry.models) ? entry.models : [];
154
+ // pi takes `api` at provider level or on every model; a mixed-protocol entry is skipped.
155
+ const wire = classifyApi(entryApi(entry));
156
+ if (!wire)
157
+ return [];
158
+ let apiKey;
159
+ let keyEnv;
160
+ const raw = typeof entry.apiKey === "string" && entry.apiKey ? entry.apiKey : undefined;
161
+ if (raw) {
162
+ if (raw.startsWith("$") && raw.length > 1) {
163
+ keyEnv = raw.slice(1);
164
+ apiKey = process.env[keyEnv];
165
+ }
166
+ else if (opts.id === "prime" && looksLikeEnvName(raw)) {
167
+ // prime resolves bare env names; falls back to the literal string
168
+ keyEnv = raw;
169
+ apiKey = process.env[raw] ?? raw;
170
+ }
171
+ else {
172
+ apiKey = raw;
173
+ }
174
+ }
175
+ const models = modelEntries.map((m) => (typeof m?.id === "string" ? m.id : "")).filter(Boolean);
176
+ const defaultModel = settings?.defaultProvider === id && settings?.defaultModel ? settings.defaultModel : undefined;
177
+ if (defaultModel && !models.includes(defaultModel))
178
+ models.unshift(defaultModel);
179
+ return [
180
+ {
181
+ id,
182
+ name: typeof entry.name === "string" ? entry.name : id,
183
+ protocol: wire.protocol,
184
+ openaiApi: wire.openaiApi,
185
+ baseUrl: entry.baseUrl,
186
+ apiKey,
187
+ keyEnv,
188
+ models,
189
+ defaultModel,
190
+ source: self,
191
+ },
192
+ ];
193
+ });
194
+ },
195
+ };
196
+ }
197
+ export const pi = piStyleTarget({
198
+ id: "pi",
199
+ name: "pi",
200
+ configDirName: ".pi/agent",
201
+ dirEnvVar: "PI_CODING_AGENT_DIR",
202
+ });
203
+ export const prime = piStyleTarget({
204
+ id: "prime",
205
+ name: "prime-agent",
206
+ configDirName: ".prime/agent",
207
+ dirEnvVar: "PRIME_AGENT_CODING_AGENT_DIR",
208
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ /**
2
+ * pi-family `api` values (pi, prime-agent, omp share the enum) that speak the
3
+ * OpenAI Responses wire: `openai-responses` plus the Azure/Codex variants.
4
+ */
5
+ const RESPONSES_APIS = {
6
+ "openai-responses": true,
7
+ "azure-openai-responses": true,
8
+ "openai-codex-responses": true,
9
+ };
10
+ /**
11
+ * Classify a pi-family `api` value. Returns undefined for wires agentsw
12
+ * cannot drive (bedrock, google, ...) so importers skip those providers.
13
+ */
14
+ export function classifyApi(api) {
15
+ if (typeof api !== "string")
16
+ return undefined;
17
+ if (api === "anthropic-messages")
18
+ return { protocol: "anthropic" };
19
+ if (api === "openai-completions")
20
+ return { protocol: "openai", openaiApi: "completions" };
21
+ if (RESPONSES_APIS[api])
22
+ return { protocol: "openai", openaiApi: "responses" };
23
+ return undefined;
24
+ }
25
+ /**
26
+ * `api` value to write for a provider. An existing openai-family value is kept
27
+ * whenever it matches the flavor being written, so a sync never rewrites
28
+ * `azure-openai-responses` into plain `openai-responses` — or, when the store
29
+ * carries no flavor at all, downgrades a working `/v1/responses` endpoint to
30
+ * chat completions.
31
+ */
32
+ export function apiValue(protocol, openaiApi, existing) {
33
+ if (protocol === "anthropic")
34
+ return "anthropic-messages";
35
+ const prev = classifyApi(existing);
36
+ const flavor = openaiApi ?? prev?.openaiApi ?? "completions";
37
+ if (prev?.protocol === "openai" && prev.openaiApi === flavor)
38
+ return existing;
39
+ return flavor === "responses" ? "openai-responses" : "openai-completions";
40
+ }
41
+ /**
42
+ * The `api` a pi-family entry declares: provider-level, or its models' own when
43
+ * they agree. A mixed-protocol entry has no single answer — one agentsw
44
+ * provider cannot represent it — so importers skip it rather than guess.
45
+ */
46
+ export function entryApi(entry) {
47
+ if (typeof entry.api === "string")
48
+ return entry.api;
49
+ if (!Array.isArray(entry.models))
50
+ return undefined;
51
+ let single;
52
+ for (const model of entry.models) {
53
+ const api = model?.api;
54
+ if (typeof api !== "string")
55
+ continue;
56
+ if (single === undefined)
57
+ single = api;
58
+ else if (single !== api)
59
+ return undefined;
60
+ }
61
+ return single;
62
+ }
63
+ /**
64
+ * Merge the model entries agentsw owns over the ones already in an app
65
+ * config: per-model keys the adapter does not model (`compat`, per-model wire
66
+ * overrides, ...) survive a re-sync, while an `owned` key the new entry no
67
+ * longer carries is cleared instead of lingering as last sync's value — a
68
+ * stale `thinkingLevelMap` beside `reasoning: false` is a state no fresh write
69
+ * produces.
70
+ */
71
+ export function mergeModels(previous, written, owned) {
72
+ const prev = new Map();
73
+ if (Array.isArray(previous)) {
74
+ for (const m of previous) {
75
+ if (m && typeof m.id === "string")
76
+ prev.set(m.id, m);
77
+ }
78
+ }
79
+ return written.map((m) => {
80
+ const old = prev.get(m.id);
81
+ if (!old)
82
+ return m;
83
+ const kept = { ...old };
84
+ for (const key of owned)
85
+ if (!(key in m))
86
+ delete kept[key];
87
+ return { ...kept, ...m };
88
+ });
89
+ }
90
+ /**
91
+ * Drop per-model `api`/`baseUrl` overrides that contradict the route being
92
+ * written. A model-level override wins over the provider entry in omp and pi,
93
+ * so a preserved one would silently keep sending requests to the endpoint or
94
+ * wire the switch just replaced. Returns the dropped `<model>.<key>` labels.
95
+ */
96
+ export function stripConflictingOverrides(models, api, baseUrl) {
97
+ const dropped = [];
98
+ for (const model of models) {
99
+ if (typeof model.api === "string" && model.api !== api) {
100
+ dropped.push(`${String(model.id)}.api`);
101
+ delete model.api;
102
+ }
103
+ if (typeof model.baseUrl === "string" && model.baseUrl !== baseUrl) {
104
+ dropped.push(`${String(model.id)}.baseUrl`);
105
+ delete model.baseUrl;
106
+ }
107
+ }
108
+ return dropped;
109
+ }