@chatcode/cco-llm-chatcode-config 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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/README.zh.md +133 -0
  4. package/cordis.patch.yml +4 -0
  5. package/cordis.web.patch.yml +12 -0
  6. package/docs/chatcode-login.md +88 -0
  7. package/docs/chatcode-login.zh.md +179 -0
  8. package/docs/chatcode-models.md +29 -0
  9. package/docs/chatcode-models.zh.md +29 -0
  10. package/docs/chatcode-reporting.md +96 -0
  11. package/docs/chatcode-reporting.zh.md +96 -0
  12. package/docs/decisions/2026-08-31-chatcode-model-source.md +39 -0
  13. package/docs/decisions/2026-08-31-chatcode-model-source.zh.md +39 -0
  14. package/docs/decisions/2026-09-16-actual-model-adapter-routing.md +31 -0
  15. package/docs/decisions/2026-09-16-actual-model-adapter-routing.zh.md +31 -0
  16. package/lib/client.js +469 -0
  17. package/lib/index.d.ts +263 -0
  18. package/lib/index.d.ts.map +1 -0
  19. package/lib/index.js +4873 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/startup-gate-BaCbWaKH.js +164 -0
  22. package/lib/startup-gate-BaCbWaKH.js.map +1 -0
  23. package/lib/web-startup.d.ts +9 -0
  24. package/lib/web-startup.d.ts.map +1 -0
  25. package/lib/web-startup.js +20 -0
  26. package/lib/web-startup.js.map +1 -0
  27. package/package.json +121 -0
  28. package/vendor/README.md +7 -0
  29. package/vendor/dsh-llm-pi-ai/LICENSE +21 -0
  30. package/vendor/dsh-llm-pi-ai/README.i18n.yaml +6 -0
  31. package/vendor/dsh-llm-pi-ai/README.md +238 -0
  32. package/vendor/dsh-llm-pi-ai/README.zh.md +238 -0
  33. package/vendor/dsh-llm-pi-ai/package.json +65 -0
  34. package/vendor/dsh-llm-pi-ai/src/adapter.ts +434 -0
  35. package/vendor/dsh-llm-pi-ai/src/auth.ts +241 -0
  36. package/vendor/dsh-llm-pi-ai/src/catalog.ts +908 -0
  37. package/vendor/dsh-llm-pi-ai/src/config.ts +478 -0
  38. package/vendor/dsh-llm-pi-ai/src/context.ts +349 -0
  39. package/vendor/dsh-llm-pi-ai/src/discovery.ts +284 -0
  40. package/vendor/dsh-llm-pi-ai/src/index.ts +336 -0
  41. package/vendor/dsh-llm-pi-ai/src/invariant.ts +30 -0
  42. package/vendor/dsh-llm-pi-ai/src/login.ts +161 -0
  43. package/vendor/dsh-llm-pi-ai/src/provider.ts +192 -0
  44. package/vendor/dsh-llm-pi-ai/src/replay.ts +249 -0
  45. package/vendor/dsh-llm-pi-ai/src/stream.ts +232 -0
  46. package/vendor/dsh-llm-pi-ai/tests/adapter.e2e.ts +168 -0
  47. package/vendor/dsh-llm-pi-ai/tests/adapter.spec.ts +1034 -0
  48. package/vendor/dsh-llm-pi-ai/tests/assemble.ts +32 -0
  49. package/vendor/dsh-llm-pi-ai/tests/auth-double.ts +39 -0
  50. package/vendor/dsh-llm-pi-ai/tests/auth.spec.ts +221 -0
  51. package/vendor/dsh-llm-pi-ai/tests/catalog.spec.ts +1220 -0
  52. package/vendor/dsh-llm-pi-ai/tests/config.spec.ts +111 -0
  53. package/vendor/dsh-llm-pi-ai/tests/context.spec.ts +474 -0
  54. package/vendor/dsh-llm-pi-ai/tests/convert.spec.ts +922 -0
  55. package/vendor/dsh-llm-pi-ai/tests/discovery.spec.ts +374 -0
  56. package/vendor/dsh-llm-pi-ai/tests/dynamic-config.spec.ts +241 -0
  57. package/vendor/dsh-llm-pi-ai/tests/fixtures/qr-code.png +0 -0
  58. package/vendor/dsh-llm-pi-ai/tests/loader-composition.spec.ts +244 -0
  59. package/vendor/dsh-llm-pi-ai/tests/login.spec.ts +198 -0
  60. package/vendor/dsh-llm-pi-ai/tests/mock-server.ts +82 -0
  61. package/vendor/dsh-llm-pi-ai/tests/provider-apis.e2e.ts +266 -0
  62. package/vendor/dsh-llm-pi-ai/tests/sdk-options.spec.ts +106 -0
  63. package/vendor/dsh-llm-pi-ai/tsconfig.json +4 -0
  64. package/vendor/dsh-llm-pi-ai/tsconfig.upstream.json +51 -0
@@ -0,0 +1,374 @@
1
+ import { createServer } from 'node:http'
2
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { Context } from '@deepseek-ai/cordis'
5
+ import LlmRuntime, { userAgent } from '@deepseek-ai/dsh-llm'
6
+ import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
7
+ import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
8
+ import { discoverModels } from '../src/discovery.ts'
9
+
10
+ const servers: Server[] = []
11
+ /** Credential variables a test set, cleared so the next one starts unset. */
12
+ const touchedEnv: string[] = []
13
+
14
+ afterEach(async () => {
15
+ // A no-op when the test never stubbed `fetch`; only 'probe key format'
16
+ // below installs one.
17
+ vi.unstubAllGlobals()
18
+ for (const name of touchedEnv.splice(0)) Reflect.deleteProperty(process.env, name)
19
+ await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
20
+ })
21
+
22
+ interface ListingServer {
23
+ url: string
24
+ paths: string[]
25
+ headers: IncomingMessage['headers'][]
26
+ }
27
+
28
+ /**
29
+ * A stand-in provider that answers one scripted `GET /models`. `chunks` writes
30
+ * without a declared length, which is how a real streamed reply arrives.
31
+ */
32
+ async function listingServer(behavior: {
33
+ status?: number
34
+ body?: string
35
+ chunks?: string[]
36
+ holdOpenMs?: number
37
+ }): Promise<ListingServer> {
38
+ const paths: string[] = []
39
+ const headers: IncomingMessage['headers'][] = []
40
+ const server = createServer((request: IncomingMessage, response: ServerResponse) => {
41
+ paths.push(request.url ?? '')
42
+ headers.push(request.headers)
43
+ if (behavior.chunks !== undefined) {
44
+ // No declared length: the ceiling has to hold on what is read.
45
+ response.writeHead(behavior.status ?? 200, { 'content-type': 'application/json' })
46
+ for (const chunk of behavior.chunks) response.write(chunk)
47
+ if (behavior.holdOpenMs === undefined) { response.end(); return }
48
+ // Left open so a caller's cancellation lands while the body is still
49
+ // being read rather than after it completed.
50
+ setTimeout(() => { response.end() }, behavior.holdOpenMs)
51
+ return
52
+ }
53
+ const body = behavior.body ?? '{}'
54
+ response.writeHead(behavior.status ?? 200, {
55
+ 'content-type': 'application/json',
56
+ 'content-length': String(Buffer.byteLength(body)),
57
+ })
58
+ response.end(body)
59
+ })
60
+ servers.push(server)
61
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
62
+ const address = server.address()
63
+ if (address === null || typeof address === 'string') throw new Error('no port')
64
+ return { url: `http://127.0.0.1:${address.port}`, paths, headers }
65
+ }
66
+
67
+ /** A bare dormant mount: discovery is offered whether or not a route exists. */
68
+ async function harness(): Promise<Context> {
69
+ const ctx = new Context()
70
+ await ctx.plugin(LlmRuntime)
71
+ await ctx.plugin(LlmPiAi, {})
72
+ return ctx
73
+ }
74
+
75
+ describe('catalog-route model discovery', () => {
76
+ it('answers from the installed registry, with capacities and no network call', async () => {
77
+ const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'from-the-endpoint' }] }) })
78
+ const ctx = await harness()
79
+
80
+ const models = await ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek', baseURL: server.url })
81
+
82
+ // pi-ai's own registry is the authority for its own providers, and it
83
+ // carries what a listing endpoint would not disclose.
84
+ expect(models.map(model => model.id).sort())
85
+ .toEqual(getBuiltinModels('deepseek').map(model => model.id).sort())
86
+ expect(models.every(model => (model.contextWindow ?? 0) > 0 && (model.maxTokens ?? 0) > 0)).toBe(true)
87
+ expect(server.paths).toEqual([])
88
+ })
89
+
90
+ it('needs no endpoint for a route the catalog describes', async () => {
91
+ const ctx = await harness()
92
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek' })).resolves.not.toHaveLength(0)
93
+ })
94
+
95
+ it('says where a route the catalog does not describe must get its models', async () => {
96
+ const ctx = await harness()
97
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway' }))
98
+ .rejects.toThrow(/ships no catalog for provider "acme-gateway".*set a baseURL/s)
99
+ // A form that cleared the field says the same thing as one that never had it.
100
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: '' }))
101
+ .rejects.toThrow(/set a baseURL/)
102
+ // The seam refuses a request naming neither, so the module's own guard for
103
+ // that shape is only reachable by calling it directly.
104
+ await expect(discoverModels({})).rejects.toThrow(/set a baseURL/)
105
+ })
106
+ })
107
+
108
+ describe('draft-provider model discovery', () => {
109
+ it('reads an OpenAI-compatible listing and keeps the capacities it discloses', async () => {
110
+ const server = await listingServer({
111
+ body: JSON.stringify({
112
+ data: [
113
+ { id: 'acme-large', display_name: 'Acme Large', context_length: 65_536, max_output_tokens: 4096 },
114
+ { id: 'acme-small' },
115
+ ],
116
+ }),
117
+ })
118
+ const ctx = await harness()
119
+
120
+ const models = await ctx.llm.discoverModels('llm-pi-ai', { baseURL: `${server.url}/v1`, apiKey: 'probe-key' })
121
+
122
+ expect(models).toEqual([
123
+ { id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
124
+ { id: 'acme-small' },
125
+ ])
126
+ expect(server.paths).toEqual(['/v1/models'])
127
+ expect(server.headers[0]?.authorization).toBe('Bearer probe-key')
128
+ expect(server.headers[0]?.['user-agent']).toBe(userAgent())
129
+ })
130
+
131
+ it('keeps a deployment path instead of resolving it away', async () => {
132
+ const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) })
133
+ const ctx = await harness()
134
+
135
+ await ctx.llm.discoverModels('llm-pi-ai', { baseURL: `${server.url}/openai/v1/` })
136
+
137
+ expect(server.paths).toEqual(['/openai/v1/models'])
138
+ })
139
+
140
+ it('offers no credential when the draft names none', async () => {
141
+ const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) })
142
+ const ctx = await harness()
143
+
144
+ await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url })
145
+
146
+ expect(server.headers[0]?.authorization).toBeUndefined()
147
+ })
148
+
149
+ it('authenticates a configured route the draft cannot supply a key for', async () => {
150
+ // What the Models page actually sends after a key is saved: the form holds
151
+ // the redacted descriptor, so the draft names the route and the endpoint
152
+ // and no credential at all. Interrogating unauthenticated would answer 401
153
+ // and read as a wrong key.
154
+ const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) })
155
+ const ctx = new Context()
156
+ await ctx.plugin(LlmRuntime)
157
+ process.env['ACME_GATEWAY_KEY'] = 'stored-key'
158
+ touchedEnv.push('ACME_GATEWAY_KEY')
159
+ await ctx.plugin(LlmPiAi, {
160
+ providers: {
161
+ 'acme-gateway': {
162
+ apiKeyEnv: 'ACME_GATEWAY_KEY',
163
+ api: 'openai-completions',
164
+ baseURL: server.url,
165
+ models: [{ id: 'acme-large' }],
166
+ },
167
+ },
168
+ })
169
+
170
+ await ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: server.url })
171
+ // A key typed into the form is the one being tested — possibly the
172
+ // replacement for the stored one — so it wins.
173
+ await ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: server.url, apiKey: 'typed' })
174
+ // A route no profile declares yet is the create case: nothing is stored.
175
+ await ctx.llm.discoverModels('llm-pi-ai', { provider: 'not-declared-yet', baseURL: server.url })
176
+
177
+ expect(server.headers.map(headers => headers.authorization))
178
+ .toEqual(['Bearer stored-key', 'Bearer typed', undefined])
179
+ })
180
+
181
+ it('leaves a catalog route\'s credential unresolved, having never reached the network', async () => {
182
+ // The catalog answers before any endpoint is asked, so a route whose
183
+ // profile names a credential that is not set must still answer rather than
184
+ // failing over a key the interrogation never needed.
185
+ const ctx = new Context()
186
+ await ctx.plugin(LlmRuntime)
187
+ Reflect.deleteProperty(process.env, 'ABSENT_FOR_DISCOVERY')
188
+ await ctx.plugin(LlmPiAi, { providers: { deepseek: { apiKeyEnv: 'ABSENT_FOR_DISCOVERY' } } })
189
+
190
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek' })).resolves.not.toHaveLength(0)
191
+ })
192
+
193
+ it('drops unusable rows rather than failing the whole listing', async () => {
194
+ const server = await listingServer({
195
+ body: JSON.stringify({
196
+ data: [
197
+ { id: 'good' },
198
+ { id: '' },
199
+ { name: 'no id at all' },
200
+ null,
201
+ { id: 'good' },
202
+ { id: 'zero-capacity', context_length: 0, max_tokens: -1 },
203
+ ],
204
+ }),
205
+ })
206
+ const ctx = await harness()
207
+
208
+ expect(await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url }))
209
+ .toEqual([{ id: 'good' }, { id: 'zero-capacity' }])
210
+ })
211
+
212
+ it('points at the credential for a rejected one, and only then', async () => {
213
+ const ctx = await harness()
214
+
215
+ for (const status of [401, 403]) {
216
+ const refused = await listingServer({ status, body: '{"error":"nope"}' })
217
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: refused.url, apiKey: 'wrong' }))
218
+ .rejects.toThrow(new RegExp(`answered ${status}; check the API key`))
219
+ }
220
+
221
+ // A server fault is not a credential problem, so it must not send the user
222
+ // off to re-check a key that is fine.
223
+ const broken = await listingServer({ status: 500, body: '{"error":"boom"}' })
224
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url, apiKey: 'fine' }))
225
+ .rejects.toThrow(/answered 500$/)
226
+ })
227
+
228
+ it('reports a reply that is not a model listing', async () => {
229
+ const server = await listingServer({ body: '{"models":[]}' })
230
+ const ctx = await harness()
231
+
232
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url }))
233
+ .rejects.toThrow(/no "data" array; enter this provider's models by hand/)
234
+
235
+ const broken = await listingServer({ body: 'not json at all' })
236
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url }))
237
+ .rejects.toThrow(/did not answer with JSON/)
238
+ })
239
+
240
+ it('refuses an oversized reply, whether its length is declared or streamed', async () => {
241
+ const ctx = await harness()
242
+ // Just over the four-megabyte ceiling, as one padded model row.
243
+ const oversized = `{"data":[{"id":"m","pad":"${'x'.repeat(4 * 1024 * 1024)}"}]}`
244
+
245
+ const declared = await listingServer({ body: oversized })
246
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: declared.url }))
247
+ .rejects.toThrow(/answered with more than 4194304 bytes/)
248
+
249
+ // A streamed reply declares no length, so the ceiling has to hold on the
250
+ // body the harness actually read.
251
+ const streamed = await listingServer({ chunks: ['{"data":[{"id":"m","pad":"', 'x'.repeat(4 * 1024 * 1024), '"}]}'] })
252
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: streamed.url }))
253
+ .rejects.toThrow(/answered with more than 4194304 bytes/)
254
+ })
255
+
256
+ it('reports an unreachable endpoint instead of an empty catalog', async () => {
257
+ const ctx = await harness()
258
+ // Port 9 is the discard service: nothing accepts a connection there.
259
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'http://127.0.0.1:9/v1' }))
260
+ .rejects.toMatchObject({ code: 'DISCOVERY_FAILED' })
261
+ })
262
+
263
+ it.each(['anthropic-messages', 'azure-openai-responses', 'openai-codex-responses', 'google-generative-ai'])(
264
+ 'says it cannot interrogate %s rather than guessing a shape',
265
+ async (api) => {
266
+ // Azure authenticates with an `api-key` header and an `api-version`
267
+ // query despite its OpenAI lineage, and Codex uses OAuth; guessing at
268
+ // either would report an auth failure as a provider with no models.
269
+ const ctx = await harness()
270
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'https://gateway.example/v1', api }))
271
+ .rejects.toMatchObject({ code: 'DISCOVERY_UNSUPPORTED' })
272
+ },
273
+ )
274
+
275
+ it('reports cancellation during the body read as an abort, not a raw reason', async () => {
276
+ const ctx = await harness()
277
+ const controller = new AbortController()
278
+ const bodyRead = Promise.withResolvers<undefined>()
279
+ vi.stubGlobal('fetch', async (_url: string | URL, init?: RequestInit) => {
280
+ const signal = init?.signal
281
+ if (signal === undefined || signal === null) throw new Error('expected a discovery signal')
282
+ return new Response(new ReadableStream<Uint8Array>({
283
+ pull(stream) {
284
+ bodyRead.resolve(undefined)
285
+ return new Promise<void>((resolve) => {
286
+ signal.addEventListener('abort', () => {
287
+ stream.error(signal.reason)
288
+ resolve()
289
+ }, { once: true })
290
+ })
291
+ },
292
+ }))
293
+ })
294
+ const probe = ctx.llm.discoverModels('llm-pi-ai', {
295
+ baseURL: 'https://slow.example/v1',
296
+ }, controller.signal)
297
+ await bodyRead.promise
298
+ controller.abort('test cancellation')
299
+
300
+ await expect(probe).rejects.toMatchObject({ code: 'ABORTED' })
301
+ })
302
+
303
+ it('honors caller cancellation', async () => {
304
+ const ctx = await harness()
305
+ const aborted = AbortSignal.abort('test cancellation')
306
+ await expect(ctx.llm.discoverModels('llm-pi-ai', {
307
+ baseURL: 'http://127.0.0.1:9/v1',
308
+ }, aborted)).rejects.toMatchObject({ code: 'ABORTED' })
309
+ })
310
+
311
+ it('is offered for the namespace, and refuses one it does not serve', async () => {
312
+ const ctx = await harness()
313
+
314
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' })).resolves.not.toHaveLength(0)
315
+ await expect(ctx.llm.discoverModels('llm-deepseek', { baseURL: 'https://api.deepseek.com' }))
316
+ .rejects.toMatchObject({ code: 'NO_DISCOVERY' })
317
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: '' }))
318
+ .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
319
+ })
320
+
321
+ it('withdraws the offer when the plugin unloads', async () => {
322
+ const ctx = new Context()
323
+ await ctx.plugin(LlmRuntime)
324
+ const fiber = await ctx.plugin(LlmPiAi, {})
325
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' })).resolves.not.toHaveLength(0)
326
+
327
+ await fiber.dispose()
328
+
329
+ await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' }))
330
+ .rejects.toMatchObject({ code: 'NO_DISCOVERY' })
331
+ })
332
+ })
333
+
334
+ describe('probe key format', () => {
335
+ it('reports an illegal probe key as a credential fault, not an unreachable endpoint', async () => {
336
+ await expect(discoverModels({
337
+ baseURL: 'https://acme.test',
338
+ api: 'openai-completions',
339
+ apiKey: 'sk-\u{1F600}',
340
+ })).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' })
341
+ })
342
+
343
+ it('reports a blank probe key as a credential fault too', async () => {
344
+ // The Models page omits `apiKey` entirely for a cleared field rather than
345
+ // sending '', so this pins the contract for every other caller: a supplied
346
+ // key is judged, and only an absent one probes unauthenticated. '' means
347
+ // "I have a key" and is answered as the empty key it is.
348
+ await expect(discoverModels({
349
+ baseURL: 'https://acme.test',
350
+ api: 'openai-completions',
351
+ apiKey: '',
352
+ })).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' })
353
+ })
354
+
355
+ it('leaves a probe with no key unauthenticated', async () => {
356
+ // The file's other cases capture headers through a real local HTTP server
357
+ // (`listingServer`); this one has no route or stored key to resolve, so
358
+ // the smallest real double is a `fetch` stub, scoped to this test and
359
+ // unstubbed by the shared `afterEach` above.
360
+ const requests: RequestInit[] = []
361
+ vi.stubGlobal('fetch', async (_url: string | URL, init?: RequestInit) => {
362
+ requests.push(init ?? {})
363
+ return new Response(JSON.stringify({ data: [] }), {
364
+ status: 200,
365
+ headers: { 'content-type': 'application/json' },
366
+ })
367
+ })
368
+
369
+ await discoverModels({ baseURL: 'https://acme.test', api: 'openai-completions' })
370
+
371
+ const headers = new Headers(requests[0]?.headers)
372
+ expect(headers.has('authorization')).toBe(false)
373
+ })
374
+ })
@@ -0,0 +1,241 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { Context } from '@deepseek-ai/cordis'
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import LlmRuntime, { LlmAdapter } from '@deepseek-ai/dsh-llm'
7
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
8
+ import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local'
9
+ import { FileSettingsProvider } from '@deepseek-ai/dsh-settings-file'
10
+ import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
11
+ import AuthorizationService from '@deepseek-ai/dsh-authorization'
12
+ import { assemble } from './assemble.ts'
13
+ import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
14
+
15
+ const NS = 'llm-pi-ai'
16
+
17
+ /** Minimal foreign adapter: only needs to own a route the pi-ai plugin then wants. */
18
+ class StubAdapter extends LlmAdapter {
19
+
20
+ override async * stream(): AsyncIterable<never> {
21
+ throw new Error('stub adapter must never stream')
22
+ }
23
+ }
24
+
25
+ const cleanups: Array<() => Promise<void>> = []
26
+
27
+ afterEach(async () => {
28
+ while (cleanups.length > 0) await cleanups.pop()!()
29
+ await closeMockServers()
30
+ vi.unstubAllEnvs()
31
+ })
32
+
33
+ async function home(): Promise<string> {
34
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-dynamic-'))
35
+ cleanups.push(() => rm(dir, { recursive: true, force: true }))
36
+ return dir
37
+ }
38
+
39
+ async function boot(
40
+ dir: string,
41
+ config: LlmPiAi.Config,
42
+ options: { authorization?: boolean } = {},
43
+ ): Promise<Context> {
44
+ const ctx = new Context()
45
+ cleanups.push(async () => {
46
+ await ctx.fiber.dispose()
47
+ })
48
+ await ctx.plugin(LlmRuntime)
49
+ await ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false })
50
+ await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
51
+ if (options.authorization === true) await ctx.plugin(AuthorizationService)
52
+ await ctx.plugin(LlmPiAi, config)
53
+ return ctx
54
+ }
55
+
56
+ describe('login flows in a real composition', () => {
57
+ it('offers a sign-in for a provider no route names, once the seam is mounted', async () => {
58
+ const ctx = await boot(await home(), {}, { authorization: true })
59
+
60
+ // Zero routes configured: signing in is what makes a route worth adding,
61
+ // so the offer cannot wait for a profile to name the provider.
62
+ const codex = ctx.authorization.describe(LlmPiAi.recordKeyFor('openai-codex'))
63
+ expect(codex?.methods.map(method => method.id)).toEqual(['oauth'])
64
+ })
65
+
66
+ it('mounts without the seam, and simply offers no sign-in', async () => {
67
+ const ctx = await boot(await home(), {})
68
+
69
+ // A headless or ACP composition has no surface to sign in from; everything
70
+ // else this plugin does still works.
71
+ expect(ctx.get('authorization')).toBeUndefined()
72
+ expect(ctx.llm.listConfigurableProviders().length).toBeGreaterThan(0)
73
+ })
74
+ })
75
+
76
+ describe('request-level dynamic profiles', () => {
77
+ it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
78
+ vi.stubEnv('PI_DYNAMIC_KEY', '')
79
+ const dir = await home()
80
+ await writeFile(
81
+ join(dir, '.credentials.yaml'),
82
+ 'version: 1\nrefs:\n PI_DYNAMIC_KEY: pk-from-settings\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n',
83
+ { mode: 0o600 },
84
+ )
85
+ const server = await mockServer([{ events: textEvents }])
86
+ // The exact product posture: `- id: llm-pi-ai` with no config at all.
87
+ const ctx = await boot(dir, {})
88
+
89
+ expect(ctx.llm.listProviders()).toEqual([])
90
+ // Dormant ≠ invisible: every installed catalog provider is configurable
91
+ // before any route exists, each addressed inside the providers dict.
92
+ const directory = ctx.llm.listConfigurableProviders()
93
+ expect(directory.length).toBeGreaterThan(30)
94
+ expect(directory).toContainEqual({
95
+ provider: 'openai',
96
+ displayName: 'openai',
97
+ settingsNs: 'llm-pi-ai',
98
+ settingsPath: ['providers', 'openai'],
99
+ declared: false,
100
+ })
101
+ await ctx.settings.update(NS, {
102
+ providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
103
+ })
104
+ expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
105
+ await expect(ctx.llm.listModels('deepseek')).resolves.not.toHaveLength(0)
106
+
107
+ const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
108
+ expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
109
+ expect(server.headers[0]?.authorization).toBe('Bearer pk-from-settings')
110
+
111
+ // Emptying the user layer returns the adapter to its dormant state.
112
+ await ctx.settings.replace(NS, {})
113
+ expect(ctx.llm.listProviders()).toEqual([])
114
+ })
115
+
116
+ it('adds a provider route from settings and drops it when the user layer resets', async () => {
117
+ const dir = await home()
118
+ await writeFile(
119
+ join(dir, '.credentials.yaml'),
120
+ 'version: 1\nrefs:\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n',
121
+ { mode: 0o600 },
122
+ )
123
+ const server = await mockServer([{ events: textEvents }])
124
+ const ctx = await boot(dir, {
125
+ providers: { openai: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: 'http://127.0.0.1:1/v1' } },
126
+ })
127
+
128
+ expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
129
+ await ctx.settings.update(NS, {
130
+ providers: { deepseek: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: server.url } },
131
+ })
132
+ expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek'])
133
+
134
+ const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
135
+ expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
136
+ expect(server.headers[0]?.authorization).toBe('Bearer live-key')
137
+
138
+ // Reset the user layer: the settings-born route unregisters, the
139
+ // composition route stays.
140
+ await ctx.settings.replace(NS, {})
141
+ expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
142
+ const removed = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
143
+ expect(removed.finish).toMatchObject({ kind: 'error', failure: { code: 'NO_ADAPTER' } })
144
+ })
145
+
146
+ it('rotates the per-request credential referenced by apiKeyEnv', async () => {
147
+ vi.stubEnv('PI_DYNAMIC_KEY', '')
148
+ const dir = await home()
149
+ await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 })
150
+ const server = await mockServer([{ events: textEvents }, { events: textEvents }])
151
+ const ctx = await boot(dir, {
152
+ providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
153
+ })
154
+
155
+ await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
156
+ expect(server.headers[0]?.authorization).toBe('Bearer pk-one')
157
+
158
+ await ctx.credentials.set(credentialRef('PI_DYNAMIC_KEY'), 'pk-two')
159
+ await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
160
+ expect(server.headers[1]?.authorization).toBe('Bearer pk-two')
161
+ })
162
+
163
+ it('re-registers routes in place when a captured retry policy changes', async () => {
164
+ const dir = await home()
165
+ const ctx = await boot(dir, { providers: { openai: {} } })
166
+
167
+ await ctx.settings.update(NS, {
168
+ providers: {
169
+ openai: {
170
+ retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } },
171
+ },
172
+ },
173
+ })
174
+ expect(ctx.llm.providerRetryPolicy('openai')).toEqual({
175
+ mode: 'always',
176
+ initialDelayMs: 25,
177
+ maxDelayMs: 100,
178
+ jitterRatio: 0.2,
179
+ })
180
+ expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
181
+ })
182
+
183
+ it('refuses a settings write this adapter could not serve, leaving its routes alone', async () => {
184
+ const dir = await home()
185
+ const ctx = await boot(dir, { providers: { openai: {} } })
186
+
187
+ // Shape-valid but unserviceable: a route the catalog does not ship and
188
+ // that lists no models of its own. The section schema resolves the whole
189
+ // profile set, so this is refused where it is written rather than stored
190
+ // and then quietly disabling every route in the namespace.
191
+ await expect(ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } }))
192
+ .rejects.toThrow(/resolves no models/)
193
+ expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
194
+ })
195
+
196
+ it('keeps serving its routes when a settings-born route collides with another adapter', async () => {
197
+ const dir = await home()
198
+ await writeFile(
199
+ join(dir, '.credentials.yaml'),
200
+ 'version: 1\nrefs:\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n',
201
+ { mode: 0o600 },
202
+ )
203
+ const server = await mockServer([{ events: textEvents }, { events: textEvents }])
204
+ const ctx = await boot(dir, { providers: { openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` } } })
205
+ // Another adapter owns `anthropic`; the registry must refuse to hand it over.
206
+ ctx.llm.registerAdapter(['anthropic'], new StubAdapter())
207
+
208
+ await ctx.settings.update(NS, {
209
+ providers: {
210
+ openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` },
211
+ anthropic: { apiKeyEnv: 'PI_OTHER_KEY' },
212
+ },
213
+ })
214
+
215
+ // The conflicting swap was refused whole: the previous route set still
216
+ // owns openai (an eager dispose would have dropped it), and anthropic
217
+ // still belongs to its original adapter.
218
+ expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai'])
219
+ const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
220
+ expect(result.finish.kind).toBe('error')
221
+ expect(server.paths).toEqual(['/v1/responses'])
222
+
223
+ // Reverting to the working configuration re-applies, even though its
224
+ // facts equal the ones the registry already holds.
225
+ await ctx.settings.replace(NS, {})
226
+ expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai'])
227
+ await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
228
+ expect(server.paths).toEqual(['/v1/responses', '/v1/responses'])
229
+ })
230
+
231
+ it('ignores a settings document that merely reorders its provider keys', async () => {
232
+ const dir = await home()
233
+ const ctx = await boot(dir, { providers: { openai: {}, anthropic: {} } })
234
+ const before = ctx.llm.listProviders().map(provider => provider.id)
235
+
236
+ // Same routes, different YAML key order: nothing about the registration
237
+ // changed, so no swap should happen at all.
238
+ await ctx.settings.update(NS, { providers: { anthropic: {}, openai: {} } })
239
+ expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(before)
240
+ })
241
+ })