@baize-ai/core 0.3.15 → 0.3.16

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.
@@ -41,6 +41,10 @@ function readToml() {
41
41
  const p = path.join(homeDir, '.codex', 'config.toml');
42
42
  return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
43
43
  }
44
+ function readProjectToml() {
45
+ const p = path.join(baizeDir, '.codex', 'config.toml');
46
+ return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
47
+ }
44
48
  function readStore() {
45
49
  const p = path.join(baizeDir, '.baize', 'providers.json');
46
50
  return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : {};
@@ -52,10 +56,11 @@ describe('provider store', () => {
52
56
  expect(mp.getProviders()).toEqual({ active: 'official', providers: [] });
53
57
  });
54
58
 
55
- test('saveProvider validates name and url', async () => {
59
+ test('saveProvider validates name, url, and kind', async () => {
56
60
  expect((await mp.saveProvider({})).success).toBe(false);
57
61
  expect((await mp.saveProvider({ name: 'x', cc: { baseUrl: 'ftp://bad' } })).success).toBe(false);
58
62
  expect((await mp.saveProvider({ name: 'x', codex: { baseUrl: 'nope' } })).success).toBe(false);
63
+ expect((await mp.saveProvider({ name: 'x', codex: { kind: 'bogus-kind' } })).success).toBe(false);
59
64
  });
60
65
 
61
66
  test('saveProvider persists sanitized entry (no secrets echoed)', async () => {
@@ -70,6 +75,64 @@ describe('provider store', () => {
70
75
  expect(stored.providers[0].cc.authToken).toBe('sk-ds-token'); // persisted, just not echoed
71
76
  });
72
77
 
78
+ test('saveProvider assigns codex kind + providerKey (D51)', async () => {
79
+ const r = await mp.saveProvider(DEEPSEEK);
80
+ expect(r.provider.codex.kind).toBe('responses-compatible');
81
+ expect(r.provider.codex.providerKey).toBe('deepseek');
82
+ // Stored (not just sanitized) so the slug is stable across restarts.
83
+ const stored = readStore();
84
+ expect(stored.providers[0].codex.kind).toBe('responses-compatible');
85
+ expect(stored.providers[0].codex.providerKey).toBe('deepseek');
86
+ });
87
+
88
+ test('provider without codex.baseUrl defaults to openai-official kind', async () => {
89
+ const r = await mp.saveProvider({ name: 'Claude Only', cc: { baseUrl: 'https://a', authToken: 'sk', model: 'm' } });
90
+ expect(r.success).toBe(true);
91
+ expect(r.provider.codex.kind).toBe('openai-official');
92
+ expect(r.provider.codex.providerKey).toBe('claude-only');
93
+ });
94
+
95
+ test('codex kind can be set explicitly, chat-only included', async () => {
96
+ const r = await mp.saveProvider({ name: 'Old Gateway', codex: { baseUrl: 'https://legacy.example.com', kind: 'chat-only' } });
97
+ expect(r.success).toBe(true);
98
+ expect(r.provider.codex.kind).toBe('chat-only');
99
+ });
100
+
101
+ test('providerKey falls back to codex-<hash8> for reserved slugs (D51)', async () => {
102
+ // Raw reserved key 'openai' → codex-<hash8>; the id itself is sanitized to
103
+ // 'openai-custom' by genId, which is a safe non-reserved slug.
104
+ const r = await mp.saveProvider({ name: 'OpenAI', codex: { baseUrl: 'https://api.deepseek.com', apiKey: 'sk', model: 'm' } });
105
+ expect(r.success).toBe(true);
106
+ expect(r.provider.id).toBe('openai-custom'); // genId reservation (D51-extended)
107
+ expect(r.provider.codex.providerKey).toBe('openai-custom');
108
+ expect(r.provider.codex.providerKey).not.toBe('openai');
109
+ });
110
+
111
+ test('explicit kind survives an update; slug stays stable (D51)', async () => {
112
+ const created = await mp.saveProvider(DEEPSEEK);
113
+ const updated = await mp.saveProvider({ id: created.provider.id, name: 'DeepSeek 主', codex: { baseUrl: 'https://api.deepseek.com/v2', apiKey: 'sk2', model: 'm2' } });
114
+ expect(updated.success).toBe(true);
115
+ expect(updated.provider.codex.kind).toBe('responses-compatible'); // unchanged
116
+ expect(updated.provider.codex.providerKey).toBe('deepseek'); // slug never regenerated
117
+ });
118
+
119
+ test('lazy migration backfills kind/providerKey for legacy store entries (D51)', async () => {
120
+ // Simulate a pre-D51 providers.json: codex config but no kind/providerKey.
121
+ fs.writeFileSync(
122
+ path.join(baizeDir, '.baize', 'providers.json'),
123
+ JSON.stringify({
124
+ active: 'legacy',
125
+ providers: [{ id: 'legacy', name: 'Legacy', codex: { baseUrl: 'https://api.deepseek.com', apiKey: 'sk', model: 'm' } }],
126
+ }),
127
+ );
128
+ const list = mp.getProviders();
129
+ expect(list.providers[0].codex.kind).toBe('responses-compatible');
130
+ expect(list.providers[0].codex.providerKey).toBe('legacy');
131
+ // Backfill is persisted by the lazy migration.
132
+ expect(readStore().providers[0].codex.kind).toBe('responses-compatible');
133
+ expect(readStore().providers[0].codex.providerKey).toBe('legacy');
134
+ });
135
+
73
136
  test('deleteProvider falls back to official when active', async () => {
74
137
  await mp.saveProvider(DEEPSEEK);
75
138
  const store = readStore();
@@ -82,7 +145,7 @@ describe('provider store', () => {
82
145
  });
83
146
 
84
147
  describe('activateProvider — external (cc + codex)', () => {
85
- test('applies env/settings/auth/toml, stashes official key, restarts', async () => {
148
+ test('applies env/settings/auth + provider block, stashes official key, restarts', async () => {
86
149
  // Pre-configure an official API key via D5-style storage
87
150
  fs.writeFileSync(path.join(baizeDir, '.env'), 'ANTHROPIC_API_KEY=sk-ant-official-key\n');
88
151
  fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
@@ -104,7 +167,7 @@ describe('activateProvider — external (cc + codex)', () => {
104
167
  expect(env).toMatch(/^ANTHROPIC_BASE_URL=https:\/\/api\.deepseek\.com\/anthropic$/m);
105
168
  expect(env).toMatch(/^ANTHROPIC_MODEL=deepseek-chat$/m);
106
169
  expect(env).toMatch(/^ANTHROPIC_SMALL_FAST_MODEL=deepseek-chat$/m);
107
- expect(env).toMatch(/^OPENAI_BASE_URL=https:\/\/api\.deepseek\.com$/m);
170
+ expect(env).toMatch(/^OPENAI_BASE_URL=https:\/\/api\.deepseek\.com$/m); // legacy health-probe surface, kept
108
171
  expect(env).not.toMatch(/ANTHROPIC_API_KEY/);
109
172
 
110
173
  // settings.json env: same
@@ -115,12 +178,29 @@ describe('activateProvider — external (cc + codex)', () => {
115
178
  // stash preserved for revert
116
179
  expect(readStore().stash.apiKey).toBe('sk-ant-official-key');
117
180
 
118
- // codex auth.json + config.toml
181
+ // codex auth.json + provider block config (D51)
119
182
  const auth = JSON.parse(fs.readFileSync(path.join(homeDir, '.codex', 'auth.json'), 'utf8'));
120
183
  expect(auth.OPENAI_API_KEY).toBe('sk-ds-key');
121
184
  const toml = readToml();
122
- expect(toml).toMatch(/^model = "deepseek-chat"$/m);
123
- expect(toml).toMatch(/^openai_base_url = "https:\/\/api\.deepseek\.com"$/m);
185
+ expect(toml).toMatch(/^model_provider = "deepseek"$/m);
186
+ expect(toml).toMatch(/^\[model_providers\.deepseek\]$/m);
187
+ expect(toml).toMatch(/^name = "deepseek"$/m);
188
+ expect(toml).toMatch(/^base_url = "https:\/\/api\.deepseek\.com"$/m);
189
+ expect(toml).toMatch(/^wire_api = "responses"$/m);
190
+ expect(toml).toMatch(/^experimental_bearer_token = "sk-ds-key"$/m);
191
+ // the legacy override must never coexist with the provider block
192
+ expect(toml).not.toMatch(/openai_base_url/);
193
+
194
+ // project-level: model ensured, openai_base_url cleaned, no provider block
195
+ const projectToml = readProjectToml();
196
+ expect(projectToml).toMatch(/^model = "deepseek-chat"$/m);
197
+ expect(projectToml).not.toMatch(/openai_base_url/);
198
+ expect(projectToml).not.toMatch(/model_provider/);
199
+ expect(projectToml).not.toMatch(/\[model_providers\./);
200
+
201
+ // token-bearing global config is user-only
202
+ const mode = fs.statSync(path.join(homeDir, '.codex', 'config.toml')).mode & 0o777;
203
+ expect(mode).toBe(0o600);
124
204
  });
125
205
 
126
206
  test('probe failure surfaces a warning but still applies', async () => {
@@ -139,6 +219,22 @@ describe('activateProvider — external (cc + codex)', () => {
139
219
  });
140
220
  });
141
221
 
222
+ describe('activateProvider — chat-only interception (D51)', () => {
223
+ test('activating a chat-only provider is rejected before anything is written', async () => {
224
+ const saved = await mp.saveProvider({
225
+ name: '旧网关',
226
+ codex: { baseUrl: 'https://legacy.example.com', apiKey: 'sk-legacy', model: 'gpt-3.5', kind: 'chat-only' },
227
+ });
228
+ const before = readToml();
229
+ const r = await mp.activateProvider(saved.provider.id, { probe: async () => true, restart: noopRestart });
230
+ expect(r.success).toBe(false);
231
+ expect(r.error).toMatch(/仅支持 Chat 接口/);
232
+ expect(r.error).toMatch(/Claude 运行时/);
233
+ expect(readToml()).toBe(before); // nothing written
234
+ expect(readEnv()).not.toMatch(/OPENAI_BASE_URL=https:\/\/legacy\.example\.com/);
235
+ });
236
+ });
237
+
142
238
  describe('activateProvider — official restores', () => {
143
239
  test('official clears external vars and restores stashed key', async () => {
144
240
  fs.writeFileSync(path.join(baizeDir, '.env'), 'ANTHROPIC_API_KEY=sk-ant-official-key\n');
@@ -160,58 +256,57 @@ describe('activateProvider — official restores', () => {
160
256
  expect(readStore().stash).toBeUndefined(); // stash cleared from file
161
257
  });
162
258
 
163
- test('official-openai clears codex base_url and resets model', async () => {
259
+ test('official-openai drops the provider block and resets model to gpt-5.5 (D51)', async () => {
164
260
  await mp.saveProvider(DEEPSEEK);
165
261
  await mp.activateProvider('deepseek', { probe: async () => true, restart: noopRestart });
166
262
 
167
263
  const r = await mp.activateProvider('official-openai', { restart: noopRestart });
168
264
  expect(r.success).toBe(true);
265
+ expect(r.codexProviderKey).toBeNull();
169
266
  const env = readEnv();
170
- expect(env).not.toMatch(/OPENAI_BASE_URL/);
171
267
  const toml = readToml();
268
+ expect(env).not.toMatch(/OPENAI_BASE_URL/);
172
269
  expect(toml).toMatch(/^model = "gpt-5.5"$/m);
270
+ expect(toml).not.toMatch(/^model_provider/m);
271
+ expect(toml).not.toMatch(/\[model_providers\.deepseek\]/);
173
272
  expect(toml).not.toMatch(/openai_base_url/);
174
- });
175
- });
176
-
177
- describe('editCodexConfigToml (TOML scope aware)', () => {
178
- test('top-level keys land BEFORE the first section header; section content byte-preserved', () => {
179
- fs.mkdirSync(path.join(homeDir, '.codex'), { recursive: true });
180
- fs.writeFileSync(path.join(homeDir, '.codex', 'config.toml'), '# comment\n\n[model_providers.my]\nbase_url = "https://x"\nmodel = "other"\n');
181
- mp.editCodexConfigToml({ model: 'deepseek-chat', openaiBaseUrl: 'https://api.deepseek.com' });
182
- const toml = readToml();
183
- const sectionIdx = toml.indexOf('[model_providers.my]');
184
- // top-level keys must appear in the prefix BEFORE the section header
185
- const topLevelIdx = toml.indexOf('model = "deepseek-chat"');
186
- const baseUrlIdx = toml.indexOf('openai_base_url = "https://api.deepseek.com"');
187
- expect(topLevelIdx).toBeGreaterThanOrEqual(0);
188
- expect(baseUrlIdx).toBeGreaterThanOrEqual(0);
189
- expect(topLevelIdx).toBeLessThan(sectionIdx);
190
- expect(baseUrlIdx).toBeLessThan(sectionIdx);
191
- // section content untouched
192
- expect(toml).toMatch(/^\[model_providers\.my\]$/m);
193
- expect(toml).toMatch(/^base_url = "https:\/\/x"$/m);
194
- expect(toml).toMatch(/^model = "other"$/m);
273
+ const projectToml = readProjectToml();
274
+ expect(projectToml).not.toMatch(/openai_base_url/);
195
275
  });
196
276
 
197
- test('replaces existing top-level keys without duplicating', () => {
277
+ test('official-openai removes baize-written tables but preserves hand-edited ones (D51 review P2)', async () => {
278
+ // Contract (review P2): a baize-written block always has name === key &&
279
+ // wire_api === 'responses'. Those are removed on restore. Hand-edited
280
+ // user providers (any other shape) survive untouched.
198
281
  fs.mkdirSync(path.join(homeDir, '.codex'), { recursive: true });
199
- fs.writeFileSync(path.join(homeDir, '.codex', 'config.toml'), 'model = "gpt-5.5"\n');
200
- mp.editCodexConfigToml({ model: 'deepseek-chat', openaiBaseUrl: 'https://api.deepseek.com' });
282
+ fs.writeFileSync(
283
+ path.join(homeDir, '.codex', 'config.toml'),
284
+ 'model_provider = "deepseek"\n\n[model_providers.deepseek]\nname = "deepseek"\nbase_url = "https://api.deepseek.com"\nwire_api = "responses"\n\n[model_providers.myrelay]\nname = "My Relay"\nbase_url = "https://r.example"\n',
285
+ );
286
+ const r = await mp.activateProvider('official-openai', { restart: noopRestart });
287
+ expect(r.success).toBe(true);
201
288
  const toml = readToml();
202
- expect(toml.match(/^model = "deepseek-chat"$/gm)).toHaveLength(1);
203
- expect(toml.match(/^openai_base_url = "https:\/\/api\.deepseek\.com"$/gm)).toHaveLength(1);
289
+ expect(toml).toMatch(/^model = "gpt-5.5"$/m);
290
+ expect(toml).not.toMatch(/^model_provider/m);
291
+ expect(toml).not.toMatch(/\[model_providers\.deepseek\]/, 'baize-written block removed');
292
+ expect(toml).toMatch(/\[model_providers\.myrelay\]/, 'hand-edited provider survives');
293
+ expect(toml).toMatch(/name = "My Relay"/);
204
294
  });
205
295
 
206
- test('removes openai_base_url on official restore, keeping sections', () => {
296
+ test('activating an openai-official-kind provider keeps the built-in OpenAI provider (D51)', async () => {
297
+ fs.mkdirSync(path.join(baizeDir, '.baize'), { recursive: true });
298
+ fs.writeFileSync(path.join(baizeDir, '.baize', 'config.json'), JSON.stringify({ runtime: 'codex' }));
207
299
  fs.mkdirSync(path.join(homeDir, '.codex'), { recursive: true });
208
- fs.writeFileSync(path.join(homeDir, '.codex', 'config.toml'), 'openai_base_url = "https://api.deepseek.com"\n\n[model_providers.my]\nmodel = "deepseek-chat"\n');
209
- mp.editCodexConfigToml({ model: 'gpt-5.5', openaiBaseUrl: null });
300
+ // fixture: baize-written shape (name === key && wire_api === responses) so restore removes it
301
+ fs.writeFileSync(path.join(homeDir, '.codex', 'config.toml'), 'model_provider = "deepseek"\n\n[model_providers.deepseek]\nname = "deepseek"\nbase_url = "https://api.deepseek.com"\nwire_api = "responses"\n');
302
+ await mp.saveProvider({ name: '官方 OpenAI', codex: { apiKey: 'sk-official', model: 'gpt-5.5' } }); // no baseUrl → openai-official
303
+ const r = await mp.activateProvider('openai-custom', { probe: async () => true, restart: noopRestart });
304
+ expect(r.success).toBe(true);
305
+ expect(r.codexProviderKey).toBeNull();
210
306
  const toml = readToml();
211
- expect(toml).not.toMatch(/^openai_base_url/m);
307
+ expect(toml).not.toMatch(/^model_provider/m); // built-in provider back in charge
308
+ expect(toml).not.toMatch(/\[model_providers\.deepseek\]/);
212
309
  expect(toml).toMatch(/^model = "gpt-5.5"$/m);
213
- expect(toml).toMatch(/^\[model_providers\.my\]$/m);
214
- expect(toml).toMatch(/^model = "deepseek-chat"$/m); // section model untouched
215
310
  });
216
311
  });
217
312
 
@@ -268,3 +363,18 @@ describe('activateProvider — side-coverage validation (D13)', () => {
268
363
  expect(r.success).toBe(true);
269
364
  });
270
365
  });
366
+
367
+ describe('D51 migration — legacy activation cleans stale overrides', () => {
368
+ test('activating a provider removes a pre-existing openai_base_url override', async () => {
369
+ fs.mkdirSync(path.join(homeDir, '.codex'), { recursive: true });
370
+ fs.writeFileSync(path.join(homeDir, '.codex', 'config.toml'), 'openai_base_url = "https://old.example.com/v1"\nmodel = "old"\n');
371
+ fs.mkdirSync(path.join(baizeDir, '.baize'), { recursive: true });
372
+ fs.writeFileSync(path.join(baizeDir, '.baize', 'config.json'), JSON.stringify({ runtime: 'codex' }));
373
+ await mp.saveProvider({ name: 'codex-only', codex: { baseUrl: 'https://api.deepseek.com', apiKey: 'sk', model: 'deepseek-chat' } });
374
+ const r = await mp.activateProvider('codex-only', { probe: async () => true, restart: noopRestart });
375
+ expect(r.success).toBe(true);
376
+ const toml = readToml();
377
+ expect(toml).not.toMatch(/openai_base_url/);
378
+ expect(toml).toMatch(/^model_provider = "codex-only"$/m);
379
+ });
380
+ });
@@ -744,7 +744,7 @@ describe('web-console a2a authz routes (D22 单元 C)', () => {
744
744
  expect(await res.json()).toEqual({ success: true, mode: 'open', allow: [], block: [] });
745
745
  });
746
746
 
747
- test('PUT authz validates mode enum and list entries', async () => {
747
+ test('PUT authz validates mode enum; allow/block ignored and forced empty (D52 governance)', async () => {
748
748
  ctx = await startServer({ extraEnv: a2aEnv() });
749
749
  const put = async (body) => {
750
750
  const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`, {
@@ -754,17 +754,15 @@ describe('web-console a2a authz routes (D22 单元 C)', () => {
754
754
  });
755
755
  return { status: res.status, body: await res.json() };
756
756
  };
757
- expect((await put({ mode: 'bogus' })).status).toBe(400);
758
- expect((await put({ mode: 'open', allow: 'x' })).status).toBe(400); // allow not an array
759
- expect((await put({ mode: 'open', block: [''], allow: [] })).status).toBe(400); // empty string element
760
- expect((await put({ mode: 'open', block: [42], allow: [] })).status).toBe(400); // non-string element
761
- expect((await put({ mode: 'open', block: ['a', 'a'], allow: [] })).status).toBe(400); // duplicate
762
- const ok = await put({ mode: 'allowlist', allow: ['peer_1', 'peer_2'], block: [] });
757
+ expect((await put({ mode: 'bogus' })).status).toBe(400); // invalid mode rejected
758
+ expect((await put({})).status).toBe(400); // missing mode rejected
759
+ // D52: lists are admin-managed — any allow/block payload is ignored []
760
+ const ok = await put({ mode: 'allowlist', allow: ['peer_1', 'peer_2'], block: ['x'] });
763
761
  expect(ok.status).toBe(200);
764
- expect(ok.body).toEqual({ success: true, mode: 'allowlist', allow: ['peer_1', 'peer_2'], block: [] });
762
+ expect(ok.body).toEqual({ success: true, mode: 'allowlist', allow: [], block: [] });
765
763
  });
766
764
 
767
- test('PUT authz writes config.json preserving other fields and trims entries', async () => {
765
+ test('PUT authz writes config.json preserving other fields; lists forced empty', async () => {
768
766
  ctx = await startServer({ extraEnv: a2aEnv() });
769
767
  const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`, {
770
768
  method: 'PUT',
@@ -772,16 +770,16 @@ describe('web-console a2a authz routes (D22 单元 C)', () => {
772
770
  body: JSON.stringify({ mode: 'open', allow: [], block: [' peer_9 ', 'peer_10'] }),
773
771
  });
774
772
  expect(res.status).toBe(200);
775
- expect(await res.json()).toEqual({ success: true, mode: 'open', allow: [], block: ['peer_9', 'peer_10'] });
773
+ expect(await res.json()).toEqual({ success: true, mode: 'open', allow: [], block: [] });
776
774
  const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
777
- expect(cfg.authz).toEqual({ mode: 'open', allow: [], block: ['peer_9', 'peer_10'] });
775
+ expect(cfg.authz).toEqual({ mode: 'open', allow: [], block: [] });
778
776
  expect(cfg.enabled).toBe(true);
779
777
  expect(cfg.agentId).toBe('agent_test');
780
778
  expect(cfg.cert).toEqual({ keyPath: '', certPath: '' });
781
779
  expect(cfg.name).toBeUndefined();
782
780
  });
783
781
 
784
- test('PUT authz creates config.json when missing', async () => {
782
+ test('PUT authz creates config.json when missing (mode only)', async () => {
785
783
  fs.rmSync(configFile());
786
784
  ctx = await startServer({ extraEnv: a2aEnv() });
787
785
  const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`, {
@@ -791,7 +789,7 @@ describe('web-console a2a authz routes (D22 单元 C)', () => {
791
789
  });
792
790
  expect(res.status).toBe(200);
793
791
  const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
794
- expect(cfg.authz).toEqual({ mode: 'allowlist', allow: ['peer_1'], block: [] });
792
+ expect(cfg.authz).toEqual({ mode: 'allowlist', allow: [], block: [] });
795
793
  });
796
794
 
797
795
  test('authz routes require session when a password is set', async () => {