@polderlabs/bizar 6.2.1 → 6.2.3

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,311 @@
1
+ /**
2
+ * cli/commands/setup-provider.test.mjs
3
+ *
4
+ * v6.2.3 — Unit tests for the setup-provider subcommand.
5
+ *
6
+ * v6.2.3 changed the storage backend from `~/.cline/cline.json` to
7
+ * `~/.cline/data/settings/providers.json` (the file the Cline CLI
8
+ * and kanban mode actually read). The default providerId changed
9
+ * from `9router` to `litellm` (a real entry in Cline's catalog).
10
+ *
11
+ * Exercises the pure helpers (`buildProviderBlock`, `parseFlags`,
12
+ * `OPENAI_COMPATIBLE_PROVIDERS`) and the filesystem mutations
13
+ * (`applyProviderBlock`, `removeProvider`) with a mocked CLINE_DIR.
14
+ */
15
+ import { test, describe, beforeEach, afterEach } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ const {
22
+ buildProviderBlock,
23
+ applyProviderBlock,
24
+ removeProvider,
25
+ listGatewayModels,
26
+ parseFlags,
27
+ OPENAI_COMPATIBLE_PROVIDERS,
28
+ } = await import('./setup-provider.mjs');
29
+
30
+ const ORIG_CLINE_DIR = process.env.CLINE_DIR;
31
+ const ORIG_DATA_DIR = process.env.CLINE_DATA_DIR;
32
+ const ORIG_PROVIDER_PATH = process.env.CLINE_PROVIDER_SETTINGS_PATH;
33
+ let workDir;
34
+
35
+ function freshWorkDir() {
36
+ workDir = mkdtempSync(join(tmpdir(), 'bizar-setup-provider-'));
37
+ // CLINE_DIR is the *base* dir (e.g. ~/.cline). The settings file
38
+ // lives at `${CLINE_DIR}/data/settings/providers.json`. Mocking
39
+ // CLINE_DIR is the easiest way to redirect the whole tree.
40
+ process.env.CLINE_DIR = workDir;
41
+ // Clear overrides that could change the resolution path.
42
+ delete process.env.CLINE_DATA_DIR;
43
+ delete process.env.CLINE_PROVIDER_SETTINGS_PATH;
44
+ return workDir;
45
+ }
46
+
47
+ function settingsFile() {
48
+ return join(workDir, 'data', 'settings', 'providers.json');
49
+ }
50
+
51
+ function makeFakeSettings(root, body = {}) {
52
+ const cfg = {
53
+ version: 1,
54
+ providers: {},
55
+ lastUsedProvider: 'litellm',
56
+ ...body,
57
+ };
58
+ const file = join(root, 'data', 'settings', 'providers.json');
59
+ mkdirSync(join(root, 'data', 'settings'), { recursive: true });
60
+ writeFileSync(file, JSON.stringify(cfg, null, 2));
61
+ return file;
62
+ }
63
+
64
+ beforeEach(() => {
65
+ workDir = freshWorkDir();
66
+ });
67
+
68
+ afterEach(() => {
69
+ if (workDir && existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
70
+ if (ORIG_CLINE_DIR === undefined) delete process.env.CLINE_DIR;
71
+ else process.env.CLINE_DIR = ORIG_CLINE_DIR;
72
+ if (ORIG_DATA_DIR === undefined) delete process.env.CLINE_DATA_DIR;
73
+ else process.env.CLINE_DATA_DIR = ORIG_DATA_DIR;
74
+ if (ORIG_PROVIDER_PATH === undefined) delete process.env.CLINE_PROVIDER_SETTINGS_PATH;
75
+ else process.env.CLINE_PROVIDER_SETTINGS_PATH = ORIG_PROVIDER_PATH;
76
+ });
77
+
78
+ describe('OPENAI_COMPATIBLE_PROVIDERS', () => {
79
+ test('includes litellm (the new v6.2.3 default)', () => {
80
+ assert.ok(OPENAI_COMPATIBLE_PROVIDERS.includes('litellm'),
81
+ 'litellm must be the canonical openai-compatible providerId');
82
+ });
83
+ test('does NOT include the fake "openai-compatible" or "9router" IDs', () => {
84
+ assert.equal(OPENAI_COMPATIBLE_PROVIDERS.includes('openai-compatible'), false,
85
+ 'openai-compatible is a family, not a valid providerId');
86
+ assert.equal(OPENAI_COMPATIBLE_PROVIDERS.includes('9router'), false,
87
+ '9router is not a built-in Cline providerId (would fail in kanban)');
88
+ });
89
+ });
90
+
91
+ describe('buildProviderBlock()', () => {
92
+ test('emits a Cline-settings-shaped block (version 1 + settings + updatedAt)', () => {
93
+ const block = buildProviderBlock({
94
+ name: 'litellm',
95
+ gateway: 'http://localhost:20128/v1',
96
+ apiKey: 'sk-test',
97
+ model: 'minimaxcustom/MiniMax-M3',
98
+ });
99
+ assert.ok(block.settings);
100
+ assert.equal(block.settings.provider, 'litellm');
101
+ assert.equal(block.settings.model, 'minimaxcustom/MiniMax-M3');
102
+ assert.equal(block.settings.apiKey, 'sk-test');
103
+ assert.equal(block.settings.baseUrl, 'http://localhost:20128/v1');
104
+ assert.ok(block.settings.reasoning);
105
+ assert.equal(block.settings.reasoning.enabled, false);
106
+ assert.ok(block.updatedAt);
107
+ assert.match(block.updatedAt, /^\d{4}-\d{2}-\d{2}T/);
108
+ assert.equal(block.tokenSource, 'manual');
109
+ });
110
+
111
+ test('wraps env-var-style keys in ${env:...}', () => {
112
+ const block = buildProviderBlock({
113
+ name: 'litellm',
114
+ gateway: 'http://localhost:20128/v1',
115
+ apiKey: 'NINEROUTER_KEY',
116
+ model: 'm1',
117
+ });
118
+ assert.equal(block.settings.apiKey, '${env:NINEROUTER_KEY}');
119
+ });
120
+
121
+ test('sets reasoning.enabled=true when reason=true', () => {
122
+ const block = buildProviderBlock({
123
+ name: 'litellm', gateway: 'http://x', apiKey: 'k', model: 'm', reason: true,
124
+ });
125
+ assert.equal(block.settings.reasoning.enabled, true);
126
+ });
127
+ });
128
+
129
+ describe('applyProviderBlock()', () => {
130
+ test('writes to ${CLINE_DIR}/data/settings/providers.json (NOT ~/.cline/cline.json)', () => {
131
+ const body = buildProviderBlock({
132
+ name: 'litellm', gateway: 'http://localhost:20128/v1',
133
+ apiKey: 'sk-test', model: 'minimaxcustom/MiniMax-M3',
134
+ });
135
+ const r = applyProviderBlock('litellm', body);
136
+ const expected = settingsFile();
137
+ assert.equal(r.path, expected, 'must write to the Cline settings file');
138
+ assert.ok(existsSync(expected));
139
+ });
140
+
141
+ test('bootstraps the settings file when it does not exist', () => {
142
+ // No makeFakeSettings call.
143
+ const body = buildProviderBlock({
144
+ name: 'litellm', gateway: 'http://x', apiKey: 'k', model: 'm',
145
+ });
146
+ const r = applyProviderBlock('litellm', body);
147
+ assert.ok(existsSync(r.path));
148
+ const cfg = JSON.parse(readFileSync(r.path, 'utf8'));
149
+ assert.equal(cfg.version, 1);
150
+ assert.equal(cfg.lastUsedProvider, 'litellm');
151
+ assert.ok(cfg.providers.litellm);
152
+ });
153
+
154
+ test('preserves other provider entries when adding a new one', () => {
155
+ makeFakeSettings(workDir, {
156
+ providers: { ollama: { settings: { provider: 'ollama', model: 'llama3' } } },
157
+ });
158
+ const body = buildProviderBlock({
159
+ name: 'litellm', gateway: 'http://x', apiKey: 'k', model: 'm',
160
+ });
161
+ applyProviderBlock('litellm', body);
162
+ const cfg = JSON.parse(readFileSync(settingsFile(), 'utf8'));
163
+ assert.ok(cfg.providers.ollama, 'existing provider preserved');
164
+ assert.ok(cfg.providers.litellm, 'new provider added');
165
+ });
166
+
167
+ test('overwrites an existing provider with the same name', () => {
168
+ makeFakeSettings(workDir, {
169
+ providers: { litellm: { settings: { provider: 'litellm', baseUrl: 'https://old', model: 'old' } } },
170
+ });
171
+ const body = buildProviderBlock({
172
+ name: 'litellm', gateway: 'http://new', apiKey: 'new', model: 'new',
173
+ });
174
+ applyProviderBlock('litellm', body);
175
+ const cfg = JSON.parse(readFileSync(settingsFile(), 'utf8'));
176
+ assert.equal(cfg.providers.litellm.settings.baseUrl, 'http://new');
177
+ assert.equal(cfg.providers.litellm.settings.model, 'new');
178
+ });
179
+
180
+ test('creates a backup file', () => {
181
+ makeFakeSettings(workDir, {
182
+ providers: { x: { settings: { provider: 'x' } } },
183
+ });
184
+ const body = buildProviderBlock({
185
+ name: 'litellm', gateway: 'http://x', apiKey: 'k', model: 'm',
186
+ });
187
+ const r = applyProviderBlock('litellm', body);
188
+ assert.ok(existsSync(r.backup), 'backup file created');
189
+ const backup = JSON.parse(readFileSync(r.backup, 'utf8'));
190
+ assert.ok(backup.providers.x, 'backup contains original entry');
191
+ });
192
+ });
193
+
194
+ describe('migrateLegacyOpenaiCompatible()', async () => {
195
+ const { migrateLegacyOpenaiCompatible } = await import('./setup-provider.mjs');
196
+
197
+ test('renames an openai-compatible entry to litellm (preserving baseUrl/apiKey/model)', () => {
198
+ makeFakeSettings(workDir, {
199
+ providers: {
200
+ 'openai-compatible': {
201
+ settings: {
202
+ provider: 'openai-compatible',
203
+ baseUrl: 'http://localhost:20128/v1',
204
+ apiKey: 'sk-test',
205
+ model: 'minimaxcustom/MiniMax-M3',
206
+ reasoning: { enabled: false, effort: 'medium' },
207
+ },
208
+ updatedAt: '2026-07-09T21:20:00.000Z',
209
+ tokenSource: 'manual',
210
+ },
211
+ },
212
+ lastUsedProvider: 'openai-compatible',
213
+ });
214
+ const r = migrateLegacyOpenaiCompatible('litellm');
215
+ assert.equal(r.migrated, true);
216
+ assert.equal(r.from, 'openai-compatible');
217
+ assert.equal(r.to, 'litellm');
218
+ const cfg = JSON.parse(readFileSync(settingsFile(), 'utf8'));
219
+ assert.equal(cfg.providers['openai-compatible'], undefined, 'old key removed');
220
+ assert.ok(cfg.providers.litellm, 'new key added');
221
+ assert.equal(cfg.providers.litellm.settings.baseUrl, 'http://localhost:20128/v1', 'baseUrl preserved');
222
+ assert.equal(cfg.providers.litellm.settings.apiKey, 'sk-test', 'apiKey preserved');
223
+ assert.equal(cfg.providers.litellm.settings.model, 'minimaxcustom/MiniMax-M3', 'model preserved');
224
+ assert.equal(cfg.lastUsedProvider, 'litellm', 'active provider switched');
225
+ });
226
+
227
+ test('no-op when no legacy entry exists', () => {
228
+ makeFakeSettings(workDir, {
229
+ providers: { litellm: { settings: { provider: 'litellm' } } },
230
+ });
231
+ const r = migrateLegacyOpenaiCompatible('litellm');
232
+ assert.equal(r.migrated, false);
233
+ assert.equal(r.reason, 'no legacy entry');
234
+ });
235
+
236
+ test('no-op when settings file missing', () => {
237
+ // No makeFakeSettings call
238
+ const r = migrateLegacyOpenaiCompatible('litellm');
239
+ assert.equal(r.migrated, false);
240
+ assert.equal(r.reason, 'no settings file');
241
+ });
242
+
243
+ test('preserves lastUsedProvider when it pointed at a different entry', () => {
244
+ makeFakeSettings(workDir, {
245
+ providers: {
246
+ 'openai-compatible': { settings: { provider: 'openai-compatible', baseUrl: 'http://a' } },
247
+ ollama: { settings: { provider: 'ollama' } },
248
+ },
249
+ lastUsedProvider: 'ollama',
250
+ });
251
+ migrateLegacyOpenaiCompatible('litellm');
252
+ const cfg = JSON.parse(readFileSync(settingsFile(), 'utf8'));
253
+ // Should NOT clobber the active provider if it wasn't the broken one
254
+ assert.equal(cfg.lastUsedProvider, 'ollama', 'active ollama preserved');
255
+ });
256
+ });
257
+
258
+ describe('removeProvider()', () => {
259
+ test('removes a named provider', () => {
260
+ makeFakeSettings(workDir, {
261
+ providers: {
262
+ a: { settings: { provider: 'a' } },
263
+ b: { settings: { provider: 'b' } },
264
+ },
265
+ });
266
+ const r = removeProvider('a');
267
+ assert.equal(r.removed, true);
268
+ const cfg = JSON.parse(readFileSync(settingsFile(), 'utf8'));
269
+ assert.equal(cfg.providers.a, undefined);
270
+ assert.ok(cfg.providers.b, 'untouched provider preserved');
271
+ });
272
+
273
+ test('returns removed=false when settings file is missing', () => {
274
+ const r = removeProvider('nope');
275
+ assert.equal(r.removed, false);
276
+ assert.equal(r.reason, 'no settings file');
277
+ });
278
+
279
+ test('returns removed=false for a missing provider', () => {
280
+ makeFakeSettings(workDir);
281
+ const r = removeProvider('nonexistent');
282
+ assert.equal(r.removed, false);
283
+ assert.equal(r.reason, 'not present');
284
+ });
285
+ });
286
+
287
+ describe('parseFlags()', () => {
288
+ test('parses --gateway, --key, --provider, --model with space separator', () => {
289
+ const f = parseFlags(['--gateway', 'http://x', '--key', 'sk-x', '--provider', 'litellm', '--model', 'm1']);
290
+ assert.equal(f.gateway, 'http://x');
291
+ assert.equal(f.key, 'sk-x');
292
+ assert.equal(f.provider, 'litellm');
293
+ assert.equal(f.model, 'm1');
294
+ });
295
+
296
+ test('parses equals form', () => {
297
+ const f = parseFlags(['--gateway=http://x', '--key=sk-x', '--provider=litellm', '--model=m1']);
298
+ assert.equal(f.gateway, 'http://x');
299
+ assert.equal(f.key, 'sk-x');
300
+ assert.equal(f.provider, 'litellm');
301
+ assert.equal(f.model, 'm1');
302
+ });
303
+
304
+ test('parses --list, --discover, --remove', () => {
305
+ const f = parseFlags(['--list', '--discover']);
306
+ assert.equal(f.list, true);
307
+ assert.equal(f.discover, true);
308
+ const g = parseFlags(['--remove', 'myprov']);
309
+ assert.equal(g.remove, 'myprov');
310
+ });
311
+ });
@@ -51,7 +51,7 @@ const REQUIRED_COMMANDS = [
51
51
  'audit.md', 'bizar.md', 'explain.md', 'init.md', 'learn.md',
52
52
  'plan.md', 'plow-through.md', 'pr-review.md', 'tailscale-serve.md',
53
53
  'visual-plan.md',
54
- 'team.md', 'test.md', 'validate.md',
54
+ 'team.md', 'test.md', 'validate.md', 'setup-provider.md',
55
55
  ];
56
56
 
57
57
  // v6.2.1 — Cline-native hook scripts (executable, shebang, no extension).
@@ -65,6 +65,23 @@ const REQUIRED_HOOKS = [
65
65
 
66
66
  const REQUIRED_RUNTIME_DEPS = ['zod', '@cline/sdk', '@cline/core', '@cline/shared'];
67
67
 
68
+ // v6.2.3 — Built-in Cline providerIds that are safe for any
69
+ // "OpenAI-compatible endpoint" use case. The harness warns about
70
+ // any providerId NOT in this set (since they fail in kanban mode).
71
+ const OPENAI_COMPATIBLE_BUILTIN_IDS = new Set([
72
+ 'litellm', 'cline', 'ollama', 'lmstudio', 'huggingface',
73
+ // Plus the first-class providers (also OpenAI-compatible):
74
+ 'openrouter', 'deepseek', 'xai', 'together', 'fireworks',
75
+ 'groq', 'cerebras', 'sambanova', 'nebius', 'baseten',
76
+ 'requesty', 'vercel-ai-gateway', 'v0', 'aihubmix', 'hicap',
77
+ 'nousResearch', 'huawei-cloud-maas', 'qwen', 'qwen-code',
78
+ 'doubao', 'zai', 'zai-coding-plan', 'moonshot', 'wandb',
79
+ 'xiaomi', 'kilo', 'oca', 'asksage', 'sapaicore',
80
+ // Non-OpenAI-compatible (kept so we don't false-positive):
81
+ 'anthropic', 'google', 'bedrock', 'vertex', 'gemini',
82
+ 'openai', 'openai-native', 'openai-codex',
83
+ ]);
84
+
68
85
  function check(name, fn) {
69
86
  return Promise.resolve()
70
87
  .then(fn)
@@ -266,20 +283,66 @@ const CHECKS = {
266
283
  },
267
284
 
268
285
  'provider-config': async () => {
286
+ // v6.2.2 — The installer no longer adds a provider block. The user
287
+ // must configure their own provider. This check is therefore
288
+ // ALWAYS lenient (returns a hint, never fails). It just reports
289
+ // what's in cline.json so the user can see the current state.
269
290
  const cfg = readJsonSafe(clineJsonPath());
270
- if (!cfg?.provider) throw new Error('no provider block in cline.json');
271
- const nine = cfg.provider['9router'];
272
- if (nine && nine.baseUrl) {
273
- const models = nine.models || {};
274
- const count = Object.keys(models).length;
275
- if (count === 0) throw new Error('provider.9router has no models');
276
- return `provider.9router (${count} models, baseUrl=${nine.baseUrl})`;
291
+ if (!cfg?.provider || Object.keys(cfg.provider).length === 0) {
292
+ return 'no provider configured — user must add one. `bizar install` no longer touches provider config (v6.2.2+).';
293
+ }
294
+ const names = Object.keys(cfg.provider);
295
+ const summary = names.map((n) => {
296
+ const p = cfg.provider[n];
297
+ const url = p?.baseUrl || p?.options?.baseURL || '(no baseUrl)';
298
+ const modelCount = Object.keys(p?.models || {}).length;
299
+ return `${n} (${modelCount} models, baseUrl=${url})`;
300
+ });
301
+ return `user-configured providers: ${summary.join('; ')}`;
302
+ },
303
+
304
+ 'cline-settings-provider': async () => {
305
+ // v6.2.3 — The Cline CLI and kanban mode read from
306
+ // `~/.cline/data/settings/providers.json`. The harness config
307
+ // (`cline.json`) is a separate thing. This check inspects the
308
+ // actual settings file and:
309
+ // 1. Warns if the user has a legacy `openai-compatible` providerId
310
+ // (from the v6.0.1 Cline auto-migration shim). Kanban mode
311
+ // fails on this with "Unknown or disabled provider". Run
312
+ // `bizar setup-provider` to auto-migrate.
313
+ // 2. Reports the active `lastUsedProvider` so the user can
314
+ // see which provider Cline will actually use on the next
315
+ // session.
316
+ const file = join(homedir(), '.cline', 'data', 'settings', 'providers.json');
317
+ if (!existsSync(file)) {
318
+ return 'no settings file at ~/.cline/data/settings/providers.json — run `bizar setup-provider` to add one';
319
+ }
320
+ const settings = readJsonSafe(file);
321
+ if (!settings || !settings.providers) {
322
+ return 'settings file is empty or invalid — run `bizar setup-provider`';
277
323
  }
278
- const minimax = cfg.provider.minimax;
279
- if (!minimax) {
280
- throw new Error('no provider.9router AND no provider.minimax');
324
+ const names = Object.keys(settings.providers);
325
+ if (names.length === 0) {
326
+ throw new Error('no providers configured in Cline settings — run `bizar setup-provider`');
281
327
  }
282
- return 'provider.minimax (legacy fallback)';
328
+ const issues = [];
329
+ if (names.includes('openai-compatible')) {
330
+ issues.push(`legacy 'openai-compatible' providerId present (broken in kanban mode) — run \`bizar setup-provider\` to auto-migrate to 'litellm'`);
331
+ }
332
+ // Check for any providerId that's not in Cline's built-in catalog.
333
+ for (const n of names) {
334
+ if (n === 'openai-compatible') continue; // handled above
335
+ if (!OPENAI_COMPATIBLE_BUILTIN_IDS.has(n)) {
336
+ issues.push(`'${n}' providerId is NOT in Cline's built-in catalog (likely broken in kanban mode)`);
337
+ }
338
+ }
339
+ const lastUsed = settings.lastUsedProvider || '(none)';
340
+ if (issues.length > 0) {
341
+ // Throw so the ⚠ marker shows up; the check is in LENIENT_CHECKS
342
+ // so this won't cause `bizar validate` to exit non-zero.
343
+ throw new Error(`WARN: ${issues.join('; ')}. lastUsedProvider=${lastUsed} (${names.join(', ')})`);
344
+ }
345
+ return `lastUsedProvider=${lastUsed}; providers: ${names.join(', ')}`;
283
346
  },
284
347
 
285
348
  '9router-reachable': async () => {
@@ -369,10 +432,15 @@ const CHECK_ORDER = [
369
432
  'default-agent-set',
370
433
  'instructions-loaded',
371
434
  'provider-config',
435
+ 'cline-settings-provider',
372
436
  '9router-reachable',
373
437
  ];
374
438
 
375
- const LENIENT_CHECKS = new Set(['9router-reachable']);
439
+ const LENIENT_CHECKS = new Set([
440
+ '9router-reachable',
441
+ 'provider-config', // v6.2.2+ — installer no longer touches provider config; user must configure
442
+ 'cline-settings-provider', // v6.2.3 — warns about legacy/fake providerIds; non-blocking
443
+ ]);
376
444
 
377
445
  export function showValidateHelp() {
378
446
  console.log(`
@@ -68,7 +68,7 @@ function makeFakeClineInstall(root) {
68
68
  'audit.md', 'bizar.md', 'explain.md', 'init.md', 'learn.md',
69
69
  'plan.md', 'plow-through.md', 'pr-review.md', 'tailscale-serve.md',
70
70
  'visual-plan.md',
71
- 'team.md', 'test.md', 'validate.md',
71
+ 'team.md', 'test.md', 'validate.md', 'setup-provider.md',
72
72
  ]) {
73
73
  writeFileSync(join(cmdsDir, f), '# fake command');
74
74
  }
@@ -205,28 +205,54 @@ describe('runValidate() — JSON output', () => {
205
205
  assert.equal(exitCode, 1, 'should fail when enableAgentTeams is false');
206
206
  });
207
207
 
208
- test('fails when provider config is missing', async () => {
208
+ test('passes when no provider is configured (v6.2.2+ — user must configure)', async () => {
209
+ // v6.2.2: the installer no longer touches provider config. The
210
+ // user must add their own provider. The provider-config check
211
+ // is therefore ALWAYS lenient — it just reports state.
209
212
  const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
210
213
  delete cfg.provider;
211
214
  writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
212
- const { exitCode } = await captureRun({ json: true });
213
- assert.equal(exitCode, 1);
215
+ const { exitCode, stdout } = await captureRun({ json: true });
216
+ assert.equal(exitCode, 0, 'no-provider must not fail validation (v6.2.2+ contract)');
217
+ const parsed = JSON.parse(stdout);
218
+ const p = parsed.results.find((r) => r.name === 'provider-config');
219
+ assert.ok(p, 'provider-config check should be present');
220
+ assert.equal(p.ok, true, 'provider-config always returns ok (lenient)');
221
+ assert.match(p.message, /no provider configured/);
214
222
  });
215
223
 
216
- test('passes when only 9router is configured (no minimax)', async () => {
224
+ test('reports user-configured providers when present', async () => {
225
+ // User has set up their own provider (anything goes).
217
226
  const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
218
- delete cfg.provider.minimax;
227
+ cfg.provider = {
228
+ 'my-custom-provider': {
229
+ baseUrl: 'https://my-llm.example/v1',
230
+ apiKey: 'sk-test',
231
+ models: { 'my-model-1': {}, 'my-model-2': {} },
232
+ },
233
+ };
219
234
  writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
220
- const { exitCode } = await captureRun({ json: true });
235
+ const { exitCode, stdout } = await captureRun({ json: true });
221
236
  assert.equal(exitCode, 0);
237
+ const parsed = JSON.parse(stdout);
238
+ const p = parsed.results.find((r) => r.name === 'provider-config');
239
+ assert.match(p.message, /my-custom-provider/);
240
+ assert.match(p.message, /2 models/);
222
241
  });
223
242
 
224
- test('passes when only minimax is configured (no 9router)', async () => {
243
+ test('reports both providers if user has multiple', async () => {
225
244
  const cfg = JSON.parse(readFileSync(join(workDir, 'cline.json'), 'utf8'));
226
- delete cfg.provider['9router'];
245
+ cfg.provider = {
246
+ 'a': { baseUrl: 'https://a.example/v1', models: { 'm1': {} } },
247
+ 'b': { baseUrl: 'https://b.example/v1', models: { 'm2': {}, 'm3': {} } },
248
+ };
227
249
  writeFileSync(join(workDir, 'cline.json'), JSON.stringify(cfg, null, 2));
228
- const { exitCode } = await captureRun({ json: true });
250
+ const { exitCode, stdout } = await captureRun({ json: true });
229
251
  assert.equal(exitCode, 0);
252
+ const parsed = JSON.parse(stdout);
253
+ const p = parsed.results.find((r) => r.name === 'provider-config');
254
+ assert.match(p.message, /\ba\b/);
255
+ assert.match(p.message, /\bb\b/);
230
256
  });
231
257
 
232
258
  test('default mode is lenient on 9router-unreachable', async () => {