@amenophis1er/foreman 0.1.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.
Files changed (65) hide show
  1. package/DESIGN.md +408 -0
  2. package/LICENSE +15 -0
  3. package/README.md +133 -0
  4. package/bin/foreman.mjs +58 -0
  5. package/package.json +68 -0
  6. package/scripts/prepare.mjs +48 -0
  7. package/skills/director/SKILL.md +65 -0
  8. package/src/anthropic-models.ts +54 -0
  9. package/src/ask.test.ts +88 -0
  10. package/src/ask.ts +95 -0
  11. package/src/attachments.test.ts +33 -0
  12. package/src/attachments.ts +60 -0
  13. package/src/cli.test.ts +27 -0
  14. package/src/cli.ts +297 -0
  15. package/src/codex.test.ts +328 -0
  16. package/src/codex.ts +196 -0
  17. package/src/cost-basis.test.ts +76 -0
  18. package/src/deck.test.ts +402 -0
  19. package/src/deck.ts +892 -0
  20. package/src/fork.test.ts +31 -0
  21. package/src/gateway/ledger.cjs +326 -0
  22. package/src/gateway/ledger.test.ts +255 -0
  23. package/src/gateway/llm-gateway.cjs +1411 -0
  24. package/src/gateway/llm-gateway.test.ts +478 -0
  25. package/src/gateway.test.ts +226 -0
  26. package/src/gateway.ts +309 -0
  27. package/src/instance.ts +124 -0
  28. package/src/models.test.ts +147 -0
  29. package/src/models.ts +158 -0
  30. package/src/notify/commands.test.ts +28 -0
  31. package/src/notify/commands.ts +73 -0
  32. package/src/notify/telegram.ts +259 -0
  33. package/src/notify.test.ts +343 -0
  34. package/src/notify.ts +495 -0
  35. package/src/ollama.test.ts +49 -0
  36. package/src/ollama.ts +49 -0
  37. package/src/openai-prices.test.ts +58 -0
  38. package/src/openai-prices.ts +106 -0
  39. package/src/orchestrator.test.ts +1147 -0
  40. package/src/orchestrator.ts +2325 -0
  41. package/src/planner.test.ts +60 -0
  42. package/src/planner.ts +505 -0
  43. package/src/policy.test.ts +411 -0
  44. package/src/policy.ts +599 -0
  45. package/src/preflight.ts +348 -0
  46. package/src/prices.test.ts +69 -0
  47. package/src/prices.ts +90 -0
  48. package/src/provider.test.ts +366 -0
  49. package/src/provider.ts +502 -0
  50. package/src/secrets.test.ts +143 -0
  51. package/src/secrets.ts +66 -0
  52. package/src/server.ts +1992 -0
  53. package/src/services.test.ts +53 -0
  54. package/src/services.ts +102 -0
  55. package/src/sse-events.test.ts +83 -0
  56. package/src/store.test.ts +119 -0
  57. package/src/store.ts +346 -0
  58. package/src/tailscale.test.ts +32 -0
  59. package/src/tailscale.ts +79 -0
  60. package/src/title.ts +138 -0
  61. package/src/types.ts +442 -0
  62. package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
  63. package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
  64. package/ui/dist/favicon.svg +8 -0
  65. package/ui/dist/index.html +14 -0
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Provider model tests.
3
+ *
4
+ * The bulk of these guard one thing: that no provider configuration can leave
5
+ * an ambient Anthropic credential reachable while requests are redirected at
6
+ * the gateway. That combination does not fail loudly — it succeeds, and sends
7
+ * a real subscription token to somebody else's API. So it gets tested from
8
+ * every direction rather than reasoned about once.
9
+ */
10
+ import test from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import http from 'node:http';
15
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
16
+ import {
17
+ ANTHROPIC_NATIVE_BASE_URL, GATEWAY_INVARIANT, normalizeOpenAiBaseUrl,
18
+ ownedConfigDir, providerEnv, providerFromLegacy, providerOf, providerProblem, resolveProvider, roleCost, withRoleModel,
19
+ } from './provider.js';
20
+ import type { ProviderRef } from './types.js';
21
+
22
+ /** Stands in for a running gateway; the supervisor's own port is dynamic. */
23
+ const GW = 'http://127.0.0.1:54321';
24
+
25
+ const ROOT = '/tmp/foreman-test-root';
26
+
27
+ /** Runs `fn` with extra environment variables set, then restores them. */
28
+ async function withEnv(vars: Record<string, string | undefined>, fn: () => Promise<void>): Promise<void> {
29
+ const saved = new Map(Object.keys(vars).map((k) => [k, process.env[k]]));
30
+ for (const [k, v] of Object.entries(vars)) {
31
+ if (v === undefined) delete process.env[k];
32
+ else process.env[k] = v;
33
+ }
34
+ try {
35
+ await fn();
36
+ } finally {
37
+ for (const [k, v] of saved) {
38
+ if (v === undefined) delete process.env[k];
39
+ else process.env[k] = v;
40
+ }
41
+ }
42
+ }
43
+
44
+ /** Every credential a Claude Code install could pick up from the environment. */
45
+ const AMBIENT = {
46
+ ANTHROPIC_API_KEY: 'sk-ant-a-real-user-key',
47
+ ANTHROPIC_AUTH_TOKEN: 'a-real-auth-token',
48
+ CLAUDE_CODE_OAUTH_TOKEN: 'sk-ant-oat01-a-real-subscription-token',
49
+ };
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // The leak guard
53
+ // ---------------------------------------------------------------------------
54
+
55
+ test('gateway wires never forward an ambient credential', async () => {
56
+ const refs: ProviderRef[] = [
57
+ { kind: 'openai-compatible', id: 'ollama', baseUrl: 'http://127.0.0.1:11434', label: 'Ollama' },
58
+ { kind: 'openai-compatible', id: 'or', baseUrl: 'https://openrouter.ai/api', apiKeyEnv: 'TEST_OR_KEY' },
59
+ { kind: 'codex', id: 'codex' },
60
+ ];
61
+
62
+ await withEnv({ ...AMBIENT, TEST_OR_KEY: 'sk-or-test' }, async () => {
63
+ for (const ref of refs) {
64
+ const resolved = await resolveProvider(ref, ROOT);
65
+ // Codex has no login on a test machine; give it one so env-building runs.
66
+ const p = { ...resolved, apiKey: resolved.apiKey ?? 'codex-token', problem: undefined };
67
+ const { env } = providerEnv(p, GW);
68
+ assert.ok(env, `${ref.kind} must build an env`);
69
+
70
+ const leaked = Object.values(AMBIENT);
71
+ for (const [k, v] of Object.entries(env)) {
72
+ assert.ok(
73
+ !leaked.includes(v as string),
74
+ `${ref.kind} leaked an ambient credential through ${k}`,
75
+ );
76
+ }
77
+ assert.equal(env.ANTHROPIC_BASE_URL, GW, `${ref.kind}: ${GATEWAY_INVARIANT}`);
78
+ assert.ok(env.ANTHROPIC_API_KEY, `${ref.kind}: ${GATEWAY_INVARIANT}`);
79
+ assert.equal(env.CLAUDE_CODE_OAUTH_TOKEN, undefined, `${ref.kind} left the OAuth token set`);
80
+ assert.equal(env.ANTHROPIC_AUTH_TOKEN, undefined, `${ref.kind} left the auth token set`);
81
+ }
82
+ });
83
+ });
84
+
85
+ test('a gateway wire with no credential throws rather than falling back', async () => {
86
+ const p = await resolveProvider(
87
+ { kind: 'openai-compatible', id: 'x', baseUrl: 'https://example.test', apiKeyEnv: 'TEST_ABSENT_KEY' },
88
+ ROOT,
89
+ );
90
+ await withEnv({ TEST_ABSENT_KEY: undefined, ...AMBIENT }, async () => {
91
+ assert.throws(() => providerEnv(p, GW), /gateway wire must set an explicit/);
92
+ });
93
+ });
94
+
95
+ test('gateway providers never read a user Claude Code install', async () => {
96
+ const p = await resolveProvider(
97
+ { kind: 'openai-compatible', id: 'ollama', baseUrl: 'http://127.0.0.1:11434' }, ROOT);
98
+ assert.equal(p.configDir, ownedConfigDir(ROOT, 'ollama'));
99
+ assert.ok(p.configDir.startsWith(ROOT), 'must live under the Foreman data root');
100
+ const { env } = providerEnv(p, GW);
101
+ assert.equal(env?.CLAUDE_CONFIG_DIR, p.configDir);
102
+ });
103
+
104
+ test('an endpoint needing no key still satisfies the invariant', async () => {
105
+ // A local Ollama has no credential, but leaving the key unset would let a
106
+ // machine-wide Keychain login answer for it instead.
107
+ const p = await resolveProvider(
108
+ { kind: 'openai-compatible', id: 'ollama', baseUrl: 'http://127.0.0.1:11434' }, ROOT);
109
+ await withEnv(AMBIENT, async () => {
110
+ const { env } = providerEnv(p, GW);
111
+ assert.ok(env?.ANTHROPIC_API_KEY);
112
+ assert.ok(!Object.values(AMBIENT).includes(env!.ANTHROPIC_API_KEY as string));
113
+ });
114
+ });
115
+
116
+ test('gateway wires pin every model alias, or workers 400', async () => {
117
+ const p = await resolveProvider(
118
+ { kind: 'openai-compatible', id: 'or', baseUrl: 'https://openrouter.ai/api', model: 'gpt-5' }, ROOT);
119
+ const { env } = providerEnv(p, GW);
120
+ assert.equal(env?.ANTHROPIC_DEFAULT_HAIKU_MODEL, 'gpt-5');
121
+ assert.equal(env?.ANTHROPIC_DEFAULT_SONNET_MODEL, 'gpt-5');
122
+ assert.equal(env?.ANTHROPIC_DEFAULT_OPUS_MODEL, 'gpt-5');
123
+ assert.equal(env?.LLM_GATEWAY_DEFAULT_MODEL, 'gpt-5');
124
+ });
125
+
126
+ test('a gateway wire with no gateway running refuses to build an env', async () => {
127
+ // The supervisor allocates the port, so an env built without one would point
128
+ // the agent at nothing — or, worse, at whatever else answers on a guess.
129
+ const p = await resolveProvider(
130
+ { kind: 'openai-compatible', id: 'ollama', baseUrl: 'http://127.0.0.1:11434' }, ROOT);
131
+ assert.throws(() => providerEnv(p), /gateway wire must set an explicit/);
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // The native wire keeps its existing behaviour
136
+ // ---------------------------------------------------------------------------
137
+
138
+ test('claude-code inherits ambient credentials, and never sees a gateway', async () => {
139
+ const p = await resolveProvider({ kind: 'claude-code', configDir: '/tmp/cc' }, ROOT);
140
+ await withEnv(AMBIENT, async () => {
141
+ const { env } = providerEnv(p);
142
+ assert.equal(env?.CLAUDE_CONFIG_DIR, '/tmp/cc');
143
+ assert.equal(env?.ANTHROPIC_API_KEY, AMBIENT.ANTHROPIC_API_KEY, 'inheritance is the point');
144
+ assert.equal(env?.ANTHROPIC_BASE_URL, undefined, 'must not be redirected');
145
+ assert.equal(env?.LLM_GATEWAY_MODE, undefined);
146
+ });
147
+ });
148
+
149
+ test('ownLogin strips the inherited key so the stored login pays', async () => {
150
+ const p = await resolveProvider({ kind: 'claude-code', configDir: '/tmp/cc', ownLogin: true }, ROOT);
151
+ await withEnv(AMBIENT, async () => {
152
+ const { env } = providerEnv(p);
153
+ assert.equal(env?.ANTHROPIC_API_KEY, undefined);
154
+ assert.equal(env?.ANTHROPIC_AUTH_TOKEN, undefined);
155
+ assert.equal(env?.CLAUDE_CODE_OAUTH_TOKEN, undefined);
156
+ });
157
+ });
158
+
159
+ test('an Anthropic API key displaces a machine-wide subscription', async () => {
160
+ await withEnv({ ...AMBIENT, TEST_ANT_KEY: 'sk-ant-foreman-owned' }, async () => {
161
+ const p = await resolveProvider(
162
+ { kind: 'anthropic-api', id: 'work', apiKeyEnv: 'TEST_ANT_KEY' }, ROOT);
163
+ const { env } = providerEnv(p);
164
+ assert.equal(env?.ANTHROPIC_API_KEY, 'sk-ant-foreman-owned');
165
+ assert.equal(env?.CLAUDE_CODE_OAUTH_TOKEN, undefined, 'a subscription must not win instead');
166
+ assert.equal(env?.ANTHROPIC_BASE_URL, ANTHROPIC_NATIVE_BASE_URL);
167
+ assert.equal(env?.CLAUDE_CONFIG_DIR, ownedConfigDir(ROOT, 'work'));
168
+ });
169
+ });
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Migration, resolution, reporting
173
+ // ---------------------------------------------------------------------------
174
+
175
+ test('records written before providers resolve exactly as before', () => {
176
+ assert.deepEqual(providerOf({}), { kind: 'claude-code', configDir: undefined, executable: undefined, ownLogin: false });
177
+ assert.deepEqual(
178
+ providerOf({ claudeInstance: { configDir: '/a', executable: '/b', billing: 'own-login' } }),
179
+ { kind: 'claude-code', configDir: '/a', executable: '/b', ownLogin: true },
180
+ );
181
+ assert.equal(providerFromLegacy({ billing: 'inherit' }).kind, 'claude-code');
182
+ // An explicit provider wins over a legacy pin left beside it.
183
+ assert.equal(
184
+ providerOf({ provider: { kind: 'codex', id: 'c' }, claudeInstance: { configDir: '/a' } }).kind,
185
+ 'codex',
186
+ );
187
+ });
188
+
189
+ test('a missing credential is reported, not thrown', async () => {
190
+ await withEnv({ TEST_GONE: undefined }, async () => {
191
+ const p = await resolveProvider({ kind: 'anthropic-api', id: 'x', apiKeyEnv: 'TEST_GONE' }, ROOT);
192
+ assert.match(providerProblem(p) ?? '', /TEST_GONE/);
193
+ });
194
+ });
195
+
196
+ test('codex reads the login the CLI stored, and says so when there is none', async () => {
197
+ const home = await mkdtemp(path.join(os.tmpdir(), 'foreman-codex-'));
198
+ const missing = await resolveProvider({ kind: 'codex', id: 'c', codexHome: home }, ROOT);
199
+ assert.match(missing.problem ?? '', /codex login/);
200
+
201
+ await mkdir(home, { recursive: true });
202
+ await writeFile(path.join(home, 'auth.json'), JSON.stringify({
203
+ auth_mode: 'chatgpt', OPENAI_API_KEY: null,
204
+ tokens: { access_token: 'codex-access-token', account_id: 'acct-1' },
205
+ }));
206
+ const found = await resolveProvider({ kind: 'codex', id: 'c', codexHome: home }, ROOT);
207
+ assert.equal(found.problem, undefined);
208
+ assert.equal(found.apiKey, 'codex-access-token');
209
+ assert.equal(found.wire, 'gateway-codex');
210
+ assert.ok(!found.label.includes('codex-access-token'), 'a label must never carry a secret');
211
+ });
212
+
213
+ test('base URLs are normalised the way people paste them', () => {
214
+ for (const input of ['https://api.openai.com/v1', 'https://api.openai.com/', 'api.openai.com']) {
215
+ assert.equal(normalizeOpenAiBaseUrl(input), 'https://api.openai.com');
216
+ }
217
+ assert.equal(normalizeOpenAiBaseUrl('http://127.0.0.1:11434'), 'http://127.0.0.1:11434',
218
+ 'a local http endpoint must not be upgraded to https');
219
+ });
220
+
221
+ test('a scheme-less local host gets http, a public one gets https', () => {
222
+ // Typing the host and port is how a person adds a local daemon, and
223
+ // defaulting that to https guarantees a handshake failure on the first call.
224
+ for (const local of ['127.0.0.1:11434', 'localhost:11434', 'box:11434', 'nas.local:11434',
225
+ '192.168.1.9:11434', '10.0.0.4:11434', '172.20.1.1:11434']) {
226
+ assert.equal(normalizeOpenAiBaseUrl(local).slice(0, 5), 'http:', `${local} should not be https`);
227
+ }
228
+ for (const remote of ['api.openai.com', 'openrouter.ai/api', 'ollama.com']) {
229
+ assert.equal(normalizeOpenAiBaseUrl(remote).slice(0, 6), 'https:', `${remote} should be https`);
230
+ }
231
+ // An explicit scheme always wins, in both directions.
232
+ assert.equal(normalizeOpenAiBaseUrl('https://box:11434'), 'https://box:11434');
233
+ });
234
+
235
+ test('a resolvable gateway provider reports no problem', async () => {
236
+ const p = await resolveProvider(
237
+ { kind: 'openai-compatible', id: 'ollama', baseUrl: 'http://127.0.0.1:11434' }, ROOT);
238
+ assert.equal(providerProblem(p), null);
239
+ });
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // What each provider's spend actually is
243
+ // ---------------------------------------------------------------------------
244
+
245
+ test('each provider kind resolves to the cost basis that is true of it', async (t) => {
246
+ const root = await mkdtemp(path.join(os.tmpdir(), 'foreman-basis-'));
247
+ t.after(() => rm(root, { recursive: true, force: true }));
248
+
249
+ const basis = async (ref: ProviderRef) => (await resolveProvider(ref, root)).costBasis;
250
+
251
+ // Anthropic prices its own tokens, so the SDK figure is the real one.
252
+ assert.equal(await basis({ kind: 'claude-code' }), 'priced');
253
+ assert.equal(await basis({
254
+ kind: 'anthropic-api', id: 'a', apiKeyEnv: 'NOPE',
255
+ }), 'priced');
256
+
257
+ // A ChatGPT plan is drawn down rather than billed per token — real, finite,
258
+ // and not something Foreman can put a number on.
259
+ assert.equal(await basis({ kind: 'codex', id: 'c', codexHome: path.join(root, 'codex') }), 'unpriced');
260
+
261
+ // The operator's own hardware, whether on this machine or their LAN.
262
+ assert.equal(await basis({
263
+ kind: 'openai-compatible', id: 'o', baseUrl: 'http://127.0.0.1:11434',
264
+ }), 'free');
265
+ assert.equal(await basis({
266
+ kind: 'openai-compatible', id: 'o', baseUrl: 'http://192.168.1.9:11434',
267
+ }), 'free');
268
+
269
+ // Somebody's paid service. Unpriced, never free — guessing free about a
270
+ // billed endpoint is the error that costs money.
271
+ assert.equal(await basis({
272
+ kind: 'openai-compatible', id: 'o', baseUrl: 'https://openrouter.ai/api',
273
+ }), 'unpriced');
274
+ assert.equal(await basis({
275
+ kind: 'openai-compatible', id: 'o', baseUrl: 'https://ollama.com',
276
+ }), 'unpriced');
277
+ });
278
+
279
+ test('the deprecated metered boolean never disagrees with the basis', async (t) => {
280
+ // Both are written by one helper precisely so they cannot drift; if that
281
+ // ever stops being true, a run's enforcement and its display disagree.
282
+ const root = await mkdtemp(path.join(os.tmpdir(), 'foreman-basis-drift-'));
283
+ t.after(() => rm(root, { recursive: true, force: true }));
284
+
285
+ const refs: ProviderRef[] = [
286
+ { kind: 'claude-code' },
287
+ { kind: 'anthropic-api', id: 'a', apiKeyEnv: 'NOPE' },
288
+ { kind: 'codex', id: 'c', codexHome: path.join(root, 'codex') },
289
+ { kind: 'openai-compatible', id: 'o', baseUrl: 'http://127.0.0.1:11434' },
290
+ { kind: 'openai-compatible', id: 'p', baseUrl: 'https://openrouter.ai/api' },
291
+ ];
292
+ for (const ref of refs) {
293
+ const p = await resolveProvider(ref, root);
294
+ assert.equal(p.metered, p.costBasis === 'priced', `${ref.kind} drifted`);
295
+ }
296
+ });
297
+
298
+ test('a cloud model behind a local daemon is not free', async (t) => {
299
+ // The configuration Foreman is most often used in, and the one the endpoint
300
+ // alone gets wrong: `glm-…:cloud` reaches 127.0.0.1, but runs on paid
301
+ // servers the daemon signs for with the operator's own Ollama account.
302
+ const root = await mkdtemp(path.join(os.tmpdir(), 'foreman-refine-'));
303
+ const daemon = http.createServer((req, res) => {
304
+ if (req.url !== '/api/tags') { res.writeHead(404); res.end(); return; }
305
+ res.writeHead(200, { 'content-type': 'application/json' });
306
+ res.end(JSON.stringify({ models: [
307
+ { name: 'qwen3:8b', details: { parameter_size: '8B' } },
308
+ { name: 'glm-5.3-flash:cloud', remote_model: true, remote_host: 'https://ollama.com' },
309
+ ] }));
310
+ });
311
+ await new Promise<void>((r) => daemon.listen(0, '127.0.0.1', r));
312
+ const port = (daemon.address() as { port: number }).port;
313
+ t.after(async () => {
314
+ await new Promise<void>((r) => daemon.close(() => r()));
315
+ await rm(root, { recursive: true, force: true });
316
+ });
317
+
318
+ const p = await resolveProvider(
319
+ { kind: 'openai-compatible', id: 'o', baseUrl: `http://127.0.0.1:${port}` }, root);
320
+ assert.equal(p.costBasis, 'free', 'the endpoint alone says free');
321
+
322
+ assert.equal((await roleCost(p, 'qwen3:8b')).basis, 'free');
323
+ assert.equal((await roleCost(p, 'glm-5.3-flash:cloud')).basis, 'unpriced',
324
+ 'a model the daemon merely proxies is somebody else’s bill');
325
+ });
326
+
327
+ test('refining never downgrades a basis, and survives an endpoint that will not answer', async (t) => {
328
+ const root = await mkdtemp(path.join(os.tmpdir(), 'foreman-refine-2-'));
329
+ t.after(() => rm(root, { recursive: true, force: true }));
330
+
331
+ // A priced or unpriced endpoint does not become free because of its model,
332
+ // so refining must not even ask.
333
+ const priced = await resolveProvider({ kind: 'claude-code' }, root);
334
+ assert.equal((await roleCost(priced, 'anything')).basis, 'priced');
335
+ const paid = await resolveProvider(
336
+ { kind: 'openai-compatible', id: 'o', baseUrl: 'https://openrouter.ai/api' }, root);
337
+ assert.equal((await roleCost(paid, 'anything')).basis, 'unpriced');
338
+
339
+ // Nothing listening: discovery returns null rather than throwing, and the
340
+ // provider's own answer stands. Port 1 is reserved and never bound.
341
+ const dead = await resolveProvider(
342
+ { kind: 'openai-compatible', id: 'o', baseUrl: 'http://127.0.0.1:1' }, root);
343
+ assert.equal((await roleCost(dead, 'whatever')).basis, 'free');
344
+ });
345
+
346
+ test('a role provider carries the role’s model, so every alias resolves on its gateway', async (t) => {
347
+ // The run title asked for the haiku alias on a kimi worker's gateway and
348
+ // went upstream as claude-haiku-4-5 — four 404s, no title — because the
349
+ // per-role Ollama provider had no model of its own to pin the aliases to.
350
+ const root = await mkdtemp(path.join(os.tmpdir(), 'foreman-rolemodel-'));
351
+ t.after(() => rm(root, { recursive: true, force: true }));
352
+ const bare = await resolveProvider(
353
+ { kind: 'openai-compatible', id: 'ollama-local', baseUrl: 'http://127.0.0.1:11434' }, root);
354
+ assert.equal(bare.model, undefined);
355
+
356
+ const pinned = withRoleModel(bare, 'kimi-k3:cloud');
357
+ const { env } = providerEnv(pinned, 'http://127.0.0.1:9');
358
+ assert.equal(env?.ANTHROPIC_DEFAULT_HAIKU_MODEL, 'kimi-k3:cloud');
359
+ assert.equal(env?.ANTHROPIC_DEFAULT_SONNET_MODEL, 'kimi-k3:cloud');
360
+ assert.equal(env?.ANTHROPIC_DEFAULT_OPUS_MODEL, 'kimi-k3:cloud');
361
+
362
+ // A provider that already names a model keeps it; "inherit" changes nothing.
363
+ assert.equal(withRoleModel({ ...bare, model: 'qwen3:8b' }, 'kimi-k3:cloud').model, 'qwen3:8b');
364
+ assert.equal(withRoleModel(bare, '').model, undefined);
365
+ assert.equal(withRoleModel(bare, undefined), bare, 'no change returns the same object');
366
+ });