agentsw 0.10.2 → 0.11.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.
@@ -1,22 +1,14 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { backupFile, writeFileAtomic } from "../fsutil.js";
3
+ import { backupFile, SafeConfigError, writeFileAtomic } from "../fsutil.js";
4
4
  import { piDir, piModelsFile, piSettingsFile } from "../app-paths.js";
5
5
  import { editJsoncObject, isJsonObject, readJsoncObject } from "../jsonc.js";
6
6
  import { looksLikeEnvName } from "../slug.js";
7
7
  import { transactionalTarget } from "../target-transaction.js";
8
- import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
9
- /** Per-model keys this adapter writes; one a metadata-bearing entry stops emitting is cleared, not inherited. */
10
- const OWNED_MODEL_KEYS = [
11
- "id",
12
- "name",
13
- "reasoning",
14
- "thinkingLevelMap",
15
- "input",
16
- "contextWindow",
17
- "maxTokens",
18
- "cost",
19
- ];
8
+ import { t } from "../i18n.js";
9
+ import { apiValue, applyDeepseekCompat, classifyApi, entryApi, mergeModels, OWNED_MODEL_METADATA_KEYS, ownedModelMetadata, sdkBaseUrl, stripConflictingOverrides, } from "./wire.js";
10
+ /** pi writes the shared metadata plus its own thinking-level map. */
11
+ const OWNED_MODEL_KEYS = [...OWNED_MODEL_METADATA_KEYS, "thinkingLevelMap"];
20
12
  function readModels(file) {
21
13
  const document = readJsoncObject(file);
22
14
  const providers = document.value.providers;
@@ -64,11 +56,12 @@ export function piStyleTarget(opts) {
64
56
  id: opts.id,
65
57
  name: opts.name,
66
58
  protocols: ["openai", "anthropic"],
59
+ supportsKeepDefault: true,
67
60
  get configPaths() {
68
61
  return [piModelsFile(id), piSettingsFile(id)];
69
62
  },
70
63
  detect: () => fs.existsSync(path.dirname(resolveDir())),
71
- async apply(provider) {
64
+ async apply(provider, options = {}) {
72
65
  const dir = resolveDir();
73
66
  const modelsFile = path.join(dir, "models.json");
74
67
  const settingsFile = path.join(dir, "settings.json");
@@ -76,6 +69,11 @@ export function piStyleTarget(opts) {
76
69
  // Validate both files before preparing any writes, including a malformed settings file.
77
70
  const modelsDocument = readModels(modelsFile);
78
71
  const settingsDocument = readSettings(settingsFile);
72
+ const current = settingsDocument.value;
73
+ if (options.keepDefault && current.defaultProvider === provider.id && current.defaultModel &&
74
+ !provider.models.some((model) => model.id === current.defaultModel)) {
75
+ throw new SafeConfigError(t("sync.keepDefaultMissing", { file: settingsFile, id: provider.id, model: current.defaultModel }));
76
+ }
79
77
  const modelsConfig = { ...modelsDocument.value };
80
78
  const providers = { ...modelsConfig.providers };
81
79
  // Keys agentsw does not model (headers, authHeader, oauth, ...) and per-model extras
@@ -84,26 +82,17 @@ export function piStyleTarget(opts) {
84
82
  const api = apiValue(provider.protocol, provider.openaiApi, prev.api ?? entryApi(prev));
85
83
  const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
86
84
  const models = mergeModels(prev.models, provider.models.map((m) => ({
87
- id: m.id,
88
- ...(m.name ? { name: m.name } : {}),
89
- ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
85
+ ...ownedModelMetadata(m),
90
86
  ...(m.reasoning && m.reasoningEfforts?.length
91
87
  ? { thinkingLevelMap: thinkingLevelMap(m.reasoningEfforts) }
92
88
  : {}),
93
- input: m.imageInput ? ["text", "image"] : ["text"],
94
- ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
95
- ...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
96
- ...(m.cost
97
- ? {
98
- cost: {
99
- input: m.cost.input ?? 0,
100
- output: m.cost.output ?? 0,
101
- cacheRead: m.cost.cacheRead ?? 0,
102
- cacheWrite: m.cost.cacheWrite ?? 0,
103
- },
104
- }
105
- : {}),
106
89
  })), OWNED_MODEL_KEYS);
90
+ // pi/prime key their DeepSeek wire rules by provider name, so a gateway
91
+ // entry never picks them up; the flags below are what their own deepseek
92
+ // catalog entries carry. Anthropic-wire entries replay differently.
93
+ if (provider.protocol === "openai") {
94
+ applyDeepseekCompat(models, { requiresReasoningContentOnAssistantMessages: true, thinkingFormat: "deepseek" });
95
+ }
107
96
  const conflicts = stripConflictingOverrides(models, api, baseUrl);
108
97
  if (conflicts.length)
109
98
  notes.push(`dropped model overrides pointing elsewhere: ${conflicts.join(", ")}`);
@@ -116,22 +105,25 @@ export function piStyleTarget(opts) {
116
105
  models,
117
106
  };
118
107
  modelsConfig.providers = providers;
119
- const settings = {
108
+ const modelsText = editJsoncObject(modelsDocument, modelsConfig);
109
+ const settingsText = options.keepDefault ? undefined : editJsoncObject(settingsDocument, {
120
110
  ...settingsDocument.value,
121
111
  defaultProvider: provider.id,
122
112
  defaultModel: provider.defaultModel,
123
- };
124
- const modelsText = editJsoncObject(modelsDocument, modelsConfig);
125
- const settingsText = editJsoncObject(settingsDocument, settings);
113
+ });
114
+ const changed = [modelsFile];
126
115
  const modelsBackup = backupFile(modelsFile);
127
116
  if (modelsBackup)
128
117
  notes.push(`backup: ${modelsBackup}`);
129
118
  writeFileAtomic(modelsFile, modelsText);
130
- const settingsBackup = backupFile(settingsFile);
131
- if (settingsBackup)
132
- notes.push(`backup: ${settingsBackup}`);
133
- writeFileAtomic(settingsFile, settingsText);
134
- return { app: opts.id, changed: [modelsFile, settingsFile], notes };
119
+ if (settingsText !== undefined) {
120
+ const settingsBackup = backupFile(settingsFile);
121
+ if (settingsBackup)
122
+ notes.push(`backup: ${settingsBackup}`);
123
+ writeFileAtomic(settingsFile, settingsText);
124
+ changed.push(settingsFile);
125
+ }
126
+ return { app: opts.id, changed, notes };
135
127
  },
136
128
  async prune(provider) {
137
129
  const dir = resolveDir();
@@ -99,6 +99,11 @@ derived = ["input"]) {
99
99
  const old = prev.get(m.id);
100
100
  if (!old)
101
101
  return m;
102
+ // An entry that carries nothing but its id says nothing about the model: it
103
+ // is no evidence that the config's metadata went stale, so clearing owned
104
+ // keys on its behalf would delete what a richer sync or the user put there.
105
+ if (Object.keys(m).length === 1)
106
+ return { ...m, ...old };
102
107
  const kept = { ...old };
103
108
  const known = informative.some((key) => key in m);
104
109
  if (known)
@@ -108,6 +113,66 @@ derived = ["input"]) {
108
113
  return { ...kept, ...m };
109
114
  });
110
115
  }
116
+ /** Per-model keys every pi-family adapter owns; see `ownedModelMetadata`. */
117
+ export const OWNED_MODEL_METADATA_KEYS = ["id", "name", "reasoning", "input", "contextWindow", "maxTokens", "cost"];
118
+ const DEEPSEEK_MODEL_ID = /deepseek/i;
119
+ /**
120
+ * omp/pi/prime key their DeepSeek wire rules by provider name ("deepseek",
121
+ * "opencode-go", ...), so a gateway or reseller entry matches none of them: its
122
+ * reasoning replay then breaks and DeepSeek answers 400. Match on the model id
123
+ * instead and write the adapter's own flags per model; an explicit user value wins.
124
+ */
125
+ export function applyDeepseekCompat(models, flags) {
126
+ for (const model of models) {
127
+ if (!DEEPSEEK_MODEL_ID.test(String(model.id)))
128
+ continue;
129
+ const compat = (model.compat ?? {});
130
+ model.compat = { ...flags, ...compat };
131
+ }
132
+ }
133
+ /**
134
+ * Whether a store entry describes the model at all. A bare id is ignorance,
135
+ * not a catalog statement, so adapters must not clear what the app config
136
+ * already holds on its behalf.
137
+ */
138
+ export function hasModelMetadata(m) {
139
+ return (m.name !== undefined ||
140
+ m.reasoning !== undefined ||
141
+ m.reasoningEfforts !== undefined ||
142
+ m.imageInput !== undefined ||
143
+ m.contextWindow !== undefined ||
144
+ m.maxInput !== undefined ||
145
+ m.maxOutput !== undefined ||
146
+ m.cost !== undefined);
147
+ }
148
+ /**
149
+ * The per-model metadata agentsw writes into a pi-family models config. omp and
150
+ * the pi/prime adapters share the shape; only the extras around it differ, so
151
+ * keeping one builder is what stops the two from drifting apart.
152
+ */
153
+ export function ownedModelMetadata(m) {
154
+ // `input` is a guess for a spec that says nothing; writing it would make the
155
+ // entry look authoritative to mergeModels and clear the config's own values.
156
+ const known = hasModelMetadata(m);
157
+ return {
158
+ id: m.id,
159
+ ...(m.name ? { name: m.name } : {}),
160
+ ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
161
+ ...(known ? { input: m.imageInput ? ["text", "image"] : ["text"] } : {}),
162
+ ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
163
+ ...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
164
+ ...(m.cost
165
+ ? {
166
+ cost: {
167
+ input: m.cost.input ?? 0,
168
+ output: m.cost.output ?? 0,
169
+ cacheRead: m.cost.cacheRead ?? 0,
170
+ cacheWrite: m.cost.cacheWrite ?? 0,
171
+ },
172
+ }
173
+ : {}),
174
+ };
175
+ }
111
176
  /**
112
177
  * Drop per-model `api`/`baseUrl` overrides that contradict the route being
113
178
  * written. A model-level override wins over the provider entry in omp and pi,
@@ -6,6 +6,7 @@ import { workbuddyDir } from "../app-paths.js";
6
6
  import { localProviderId } from "../provider-identity.js";
7
7
  import { providerIdFromBaseUrl, providerNameFromBaseUrl } from "../slug.js";
8
8
  import { transactionalTarget } from "../target-transaction.js";
9
+ import { hasModelMetadata } from "./wire.js";
9
10
  function configDir() {
10
11
  return workbuddyDir();
11
12
  }
@@ -101,12 +102,15 @@ export const workbuddy = transactionalTarget({
101
102
  const previous = new Map(owned.map((row) => [row.id, row]));
102
103
  const ours = provider.models.map((model) => {
103
104
  const custom = { ...previous.get(model.id) };
104
- for (const key of OWNED_MODEL_KEYS)
105
- delete custom[key];
105
+ // A bare id says nothing about the model; the row keeps what it already has.
106
+ const known = hasModelMetadata(model);
107
+ if (known)
108
+ for (const key of OWNED_MODEL_KEYS)
109
+ delete custom[key];
106
110
  return {
107
111
  ...custom,
108
112
  id: model.id,
109
- name: model.name ?? model.id,
113
+ name: model.name ?? (known ? undefined : custom.name) ?? model.id,
110
114
  vendor: provider.name,
111
115
  url,
112
116
  apiKey: provider.apiKey,
package/dist/ui.js CHANGED
@@ -51,10 +51,12 @@ export function warn(message) {
51
51
  process.stderr.write(pc.yellow(`warning: ${message}`) + "\n");
52
52
  }
53
53
  function emitJsonError(message) {
54
- process.stdout.write(JSON.stringify({ ok: false, command, version, error: { message } }) + "\n");
54
+ emitJson(undefined, message);
55
55
  }
56
- export function emitJson(data) {
57
- process.stdout.write(JSON.stringify({ ok: true, command, version, data }) + "\n");
56
+ export function emitJson(data, errorMessage) {
57
+ process.stdout.write(JSON.stringify({ ok: errorMessage === undefined, command, version, data,
58
+ ...(errorMessage === undefined ? {} : { error: { message: errorMessage } }),
59
+ }) + "\n");
58
60
  }
59
61
  /** Fatal error. In machine mode the failure becomes the single stdout document, so pipes stay parseable. */
60
62
  export function error(message) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentsw",
3
- "version": "0.10.2",
3
+ "version": "0.11.1",
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": {