@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,434 @@
1
+ /**
2
+ * Generic pi-ai-backed implementation of the ChatCode CLI LLM seam.
3
+ *
4
+ * Each resolution produces one **immutable** snapshot — the profiles plus a
5
+ * `Models` collection holding the `Provider` each route built — and an
6
+ * operation captures a whole snapshot before its first `await`. A
7
+ * configuration change builds a *new* collection rather than mutating the one
8
+ * in use, because `Models.streamSimple()` is lazy: it resolves the provider
9
+ * when the stream is first consumed, which is after the credential await, so a
10
+ * mutated collection would let a request that started under one configuration
11
+ * finish under another — or fail with a provider that no longer exists. This is
12
+ * what makes the seam's per-step call freeze (`llm.prepareCall()`) hold all the
13
+ * way down: switching models mid-reply takes effect on the next step, never
14
+ * inside the one in flight.
15
+ *
16
+ * A route naming a credential reference still resolves it through the harness
17
+ * seam and passes it as the request's `apiKey` option, which pi-ai treats as
18
+ * the highest-priority auth override — that is what keeps the fail-loud
19
+ * reference semantics. Everything that override does not cover reaches pi-ai
20
+ * through the collection's own auth: the credential store holds the records a
21
+ * login wrote and a refresh rotates, and the auth context answers the ambient
22
+ * questions a provider asks while resolving. Both are stable across snapshots,
23
+ * so a configuration change rebuilds the collection without forgetting who is
24
+ * signed in.
25
+ *
26
+ * @module dsh-llm-pi-ai/adapter
27
+ */
28
+
29
+ import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
30
+ import type {
31
+ Api,
32
+ AuthContext,
33
+ CredentialStore,
34
+ Model,
35
+ Models,
36
+ ModelThinkingLevel,
37
+ MutableModels,
38
+ SimpleStreamOptions,
39
+ ThinkingLevel,
40
+ } from '@earendil-works/pi-ai'
41
+ import {
42
+ attributionHeaders,
43
+ contentHasImage,
44
+ LlmAdapter,
45
+ LlmError,
46
+ ReasoningEffortId,
47
+ } from '@deepseek-ai/dsh-llm'
48
+ import type {
49
+ GenerateOptions,
50
+ ImageAttachmentAccess,
51
+ LlmModelInfo,
52
+ LlmProviderInfo,
53
+ LlmResolvedModelInfo,
54
+ PreparedAdapterCall,
55
+ ReasoningEffortId as ReasoningEffortIdType,
56
+ ResolvedRetryPolicy,
57
+ StreamChunk,
58
+ } from '@deepseek-ai/dsh-llm'
59
+ import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
60
+ import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
61
+ import type { ResolvedPiAiProviderProfile } from './config.ts'
62
+ import { toPiContext } from './context.ts'
63
+ import { toStreamChunks } from './stream.ts'
64
+
65
+ /** One resolution's frozen view: the profiles and the collection built from them. */
66
+ interface PiAiSnapshot {
67
+ /** The resolved profiles this collection was built from, used as its identity. */
68
+ profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>
69
+ /** Providers for exactly those profiles; never mutated once published. */
70
+ models: Models
71
+ }
72
+
73
+ /** Credentials and headers resolved privately for one request, never advertised as model metadata. */
74
+ export interface PiAiRequestAuth {
75
+ /** Request API key; omission leaves authentication to provider auth or the supplied headers. */
76
+ apiKey?: string
77
+ /** Authentication headers overriding profile headers; host attribution remains reserved. */
78
+ headers?: Record<string, string>
79
+ }
80
+
81
+ /** Constructor options for {@link PiAiAdapter}: the resolution hooks the plugin owns. */
82
+ export interface PiAiAdapterOptions {
83
+ /** Current validated profiles by provider route; called once per operation. */
84
+ profiles: () => ReadonlyMap<string, ResolvedPiAiProviderProfile>
85
+ /**
86
+ * Resolve credentials for one already-resolved profile; called once per
87
+ * stream call and frozen for that call. An empty result defers to the route's own
88
+ * pi-ai auth, which for an installed catalog route is its provider-native
89
+ * ambient discovery; the plugin allows that only for a profile naming no
90
+ * credential at all, because a named reference that misses throws `LlmError`
91
+ * `MISSING_CREDENTIAL` rather than falling back.
92
+ */
93
+ resolveAuth: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise<PiAiRequestAuth>
94
+ /**
95
+ * How every collection this adapter builds resolves auth the request-level
96
+ * `apiKey` override does not cover. Required rather than optional: a
97
+ * collection built without them gets pi-ai's in-memory default store, which
98
+ * is empty at every boot and discarded on every configuration change, so a
99
+ * route whose only method is a login would report itself unconfigured on
100
+ * every request no matter how often the human signed in.
101
+ */
102
+ auth: PiAiAuthInjection
103
+ /** Resolve the optional durable attachment service at request time. */
104
+ resolveAttachments?: () => AttachmentStore | undefined
105
+ /** Bridge one attachment reference into the current model-tool execution world. */
106
+ resolveImageAccess?: (attachments: AttachmentStore, ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined
107
+ /**
108
+ * Observe one assistant history message degrading to provider-neutral
109
+ * conversion because its stored replay state is unusable by this build.
110
+ */
111
+ onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
112
+ }
113
+
114
+ /** The two auth injectables a pi-ai collection is built with. */
115
+ export interface PiAiAuthInjection {
116
+ /** Durable storage for credentials pi-ai itself writes: logins, and the refreshes it runs under its own lock. */
117
+ credentials: CredentialStore
118
+ /** Ambient lookups a provider performs while resolving its own auth. */
119
+ authContext: AuthContext
120
+ }
121
+
122
+ /** Copy profile stream knobs into pi-ai's common option vocabulary. */
123
+ function profileOptions(
124
+ profile: ResolvedPiAiProviderProfile,
125
+ reasoning: ModelThinkingLevel | undefined,
126
+ apiKey: string | undefined,
127
+ ): SimpleStreamOptions {
128
+ const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning
129
+ return {
130
+ ...apiKey === undefined ? {} : { apiKey },
131
+ ...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning },
132
+ ...profile.reasoningSplit === undefined ? {} : { samplingParams: { reasoning_split: profile.reasoningSplit } },
133
+ ...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets },
134
+ ...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention },
135
+ ...profile.transport === undefined ? {} : { transport: profile.transport },
136
+ ...profile.timeoutMs === undefined ? {} : { timeoutMs: profile.timeoutMs },
137
+ ...profile.websocketConnectTimeoutMs === undefined ? {} : { websocketConnectTimeoutMs: profile.websocketConnectTimeoutMs },
138
+ // The agent recovery layer owns visible attempts; one adapter call is one SDK attempt.
139
+ maxRetries: 0,
140
+ }
141
+ }
142
+
143
+ /**
144
+ * The profile default this exact model can actually take, for DESCRIBING it.
145
+ * A configured level the model does not support yields none rather than
146
+ * throwing: `resolveModel` builds the model catalog, and a catalog that fails
147
+ * takes its whole provider out of every picker — so one mis-set profile field
148
+ * would hide every model on the route, including the ones that support the
149
+ * level. The request path still refuses, which is where a bad configuration
150
+ * belongs: describing what a model can do must not fail because a deployment
151
+ * asked it for something it cannot.
152
+ * @param model - the resolved model descriptor.
153
+ * @param effort - the profile's configured level, if any.
154
+ * @returns the level when this model supports it, otherwise undefined.
155
+ */
156
+ function describableReasoningLevel(
157
+ model: Model<Api>,
158
+ effort: ReasoningEffortIdType | ModelThinkingLevel | undefined,
159
+ ): ModelThinkingLevel | undefined {
160
+ if (effort === undefined) return undefined
161
+ return getSupportedThinkingLevels(model).some(level => level === effort)
162
+ ? effort as ModelThinkingLevel
163
+ : undefined
164
+ }
165
+
166
+ /** Validate an explicit ChatCode CLI profile effort without invoking pi-ai's clamp. */
167
+ function resolveReasoningLevel(
168
+ model: Model<Api>,
169
+ effort: ReasoningEffortIdType | ModelThinkingLevel | undefined,
170
+ ): ModelThinkingLevel | undefined {
171
+ if (effort === undefined) return undefined
172
+ const supported = getSupportedThinkingLevels(model)
173
+ if (supported.some(level => level === effort)) return effort as ModelThinkingLevel
174
+ throw new LlmError(
175
+ `pi-ai provider "${model.provider}" model "${model.id}" does not support reasoning effort "${effort}"`,
176
+ 'UNSUPPORTED_REASONING_EFFORT',
177
+ )
178
+ }
179
+
180
+ /**
181
+ * Selectable reasoning efforts for one model, or nothing at all.
182
+ *
183
+ * A model that carries no reasoning metadata — every hand-declared one, and
184
+ * every catalog model pi-ai marks as non-reasoning — is reported by pi-ai as
185
+ * supporting the single level `off`. Passing that through would offer a control
186
+ * that cannot do what it says: `off` is translated to *omitting* the reasoning
187
+ * option, which for such a model is byte-for-byte the same request as naming no
188
+ * effort — so a provider whose own default is to think would keep thinking with
189
+ * `off` selected. Omitting `reasoning` entirely is the seam's way of saying the
190
+ * capability is unavailable, which leaves the surface offering only the
191
+ * provider's default.
192
+ * @param model - the resolved model descriptor.
193
+ * @param defaultLevel - the profile's configured effort, already validated.
194
+ * @returns the `reasoning` field, or an empty object when none can be offered.
195
+ */
196
+ function reasoningInfo(
197
+ model: Model<Api>,
198
+ defaultLevel: ModelThinkingLevel | undefined,
199
+ ): Pick<LlmResolvedModelInfo, 'reasoning'> | Record<string, never> {
200
+ if (!model.reasoning) return {}
201
+ const levels = getSupportedThinkingLevels(model)
202
+ return {
203
+ reasoning: {
204
+ efforts: levels.map(level => ({
205
+ id: ReasoningEffortId(level),
206
+ name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`,
207
+ })),
208
+ ...defaultLevel === undefined ? {} : { defaultEffort: ReasoningEffortId(defaultLevel) },
209
+ },
210
+ }
211
+ }
212
+
213
+ /** Merge deployment headers while removing case-insensitive attribution collisions. */
214
+ function requestHeaders(
215
+ headers: Readonly<Record<string, string>> | undefined,
216
+ auth: Readonly<Record<string, string>> | undefined,
217
+ ): Record<string, string> {
218
+ const attribution = attributionHeaders()
219
+ const reserved = new Set([...Object.keys(attribution), ...Object.keys(auth ?? {})].map(name => name.toLowerCase()))
220
+ return {
221
+ ...Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => !reserved.has(name.toLowerCase()))),
222
+ ...Object.fromEntries(Object.entries(auth ?? {}).filter(([name]) =>
223
+ !Object.keys(attribution).some(reservedName => reservedName.toLowerCase() === name.toLowerCase()))),
224
+ ...attribution,
225
+ }
226
+ }
227
+
228
+ /**
229
+ * pi-ai-backed multi-provider adapter. Each operation reads the current
230
+ * profiles, so a configuration change reaches the next request without a
231
+ * restart; model descriptors come from the collection those profiles built.
232
+ */
233
+ export class PiAiAdapter extends LlmAdapter {
234
+ private snapshot: PiAiSnapshot | undefined
235
+
236
+ constructor(private readonly config: PiAiAdapterOptions) {
237
+ super()
238
+ }
239
+
240
+ /**
241
+ * The snapshot for the current profiles. Resolution memoizes its result, so
242
+ * an unchanged configuration is recognized by identity; a changed one gets a
243
+ * brand-new collection, leaving any snapshot an operation already captured
244
+ * untouched for as long as that operation holds it.
245
+ */
246
+ private current(): PiAiSnapshot {
247
+ const profiles = this.config.profiles()
248
+ if (this.snapshot?.profiles === profiles) return this.snapshot
249
+ const models: MutableModels = createModels(this.config.auth)
250
+ for (const profile of profiles.values()) models.setProvider(profile.piProvider)
251
+ this.snapshot = { profiles, models }
252
+ return this.snapshot
253
+ }
254
+
255
+ /** The profile for one route within one snapshot, or the not-owned failure. */
256
+ private profileOf(snapshot: PiAiSnapshot, provider: string): ResolvedPiAiProviderProfile {
257
+ const profile = snapshot.profiles.get(provider)
258
+ if (profile === undefined) {
259
+ throw new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')
260
+ }
261
+ return profile
262
+ }
263
+
264
+ /** The configured descriptor for one exact route/model pair within one snapshot. */
265
+ private modelOf(snapshot: PiAiSnapshot, provider: string, model: string): Model<Api> {
266
+ this.profileOf(snapshot, provider)
267
+ const resolved = snapshot.models.getModel(provider, model)
268
+ if (resolved === undefined) {
269
+ throw new LlmError(`pi-ai provider "${provider}" has no configured model "${model}"`, 'UNKNOWN_MODEL')
270
+ }
271
+ return resolved
272
+ }
273
+
274
+ override providerInfo(provider: string): LlmProviderInfo {
275
+ // The configured name, not the route key: `displayName` exists so a
276
+ // deployment can label a route, and a label only the configuration surface
277
+ // reads would leave every selector showing the raw key.
278
+ return { id: provider, name: this.current().profiles.get(provider)?.displayName ?? provider }
279
+ }
280
+
281
+ override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined {
282
+ return this.current().profiles.get(provider)?.retryPolicy
283
+ }
284
+
285
+ override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
286
+ return Promise.resolve().then(() => {
287
+ const snapshot = this.current()
288
+ this.profileOf(snapshot, provider)
289
+ return snapshot.models.getModels(provider).map(model => ({
290
+ provider,
291
+ id: model.id,
292
+ name: model.name,
293
+ inputModalities: [...model.input],
294
+ }))
295
+ })
296
+ }
297
+
298
+ override resolveModel(
299
+ provider: string,
300
+ model: string,
301
+ _signal?: AbortSignal,
302
+ ): Promise<LlmResolvedModelInfo> {
303
+ return Promise.resolve().then(() => {
304
+ const snapshot = this.current()
305
+ return this.modelInfo(snapshot, provider, model)
306
+ })
307
+ }
308
+
309
+ private modelInfo(snapshot: PiAiSnapshot, provider: string, model: string): LlmResolvedModelInfo {
310
+ const profile = this.profileOf(snapshot, provider)
311
+ const resolvedModel = this.modelOf(snapshot, provider, model)
312
+ const defaultLevel = describableReasoningLevel(resolvedModel, profile.reasoning)
313
+ // Only a cap the deployment configured is a request default; the
314
+ // catalog's `maxTokens` sizes the model and stops there.
315
+ const configuredMaxTokens = profile.configuredMaxTokens.get(model)
316
+ return {
317
+ provider,
318
+ id: model,
319
+ name: resolvedModel.name,
320
+ inputModalities: [...resolvedModel.input],
321
+ context: { contextWindow: resolvedModel.contextWindow },
322
+ ...configuredMaxTokens === undefined ? {} : { defaultMaxTokens: configuredMaxTokens },
323
+ ...reasoningInfo(resolvedModel, defaultLevel),
324
+ }
325
+ }
326
+
327
+ override prepareCall(provider: string, model: string, _signal?: AbortSignal): Promise<PreparedAdapterCall> {
328
+ const snapshot = this.current()
329
+ return Promise.resolve({
330
+ model: this.modelInfo(snapshot, provider, model),
331
+ stream: options => this.streamWithSnapshot(options, snapshot),
332
+ })
333
+ }
334
+
335
+ stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
336
+ return this.streamWithSnapshot(options, this.current())
337
+ }
338
+
339
+ private async * streamWithSnapshot(
340
+ options: GenerateOptions,
341
+ snapshot: PiAiSnapshot,
342
+ ): AsyncIterable<StreamChunk> {
343
+ if (options.stop !== undefined) {
344
+ throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION')
345
+ }
346
+ // One capture per stream call, taken before any await: the profile, the
347
+ // model descriptor, and the collection all come from the same immutable
348
+ // snapshot, and the credential freezes with them. A configuration change
349
+ // mid-request builds a separate snapshot, so this request finishes under
350
+ // the one it started with and the next call picks up the new one.
351
+ const profile = this.profileOf(snapshot, options.provider)
352
+ const model = this.modelOf(snapshot, options.provider, options.model)
353
+ const reasoning = resolveReasoningLevel(
354
+ model,
355
+ options.reasoningEffort ?? profile.reasoning,
356
+ )
357
+ const auth = await this.config.resolveAuth(options.provider, profile)
358
+
359
+ const consumer = new AbortController()
360
+ const upstream = options.signal === undefined
361
+ ? consumer.signal
362
+ : AbortSignal.any([options.signal, consumer.signal])
363
+ const streamIdleTimeoutMs = profile.streamIdleTimeoutMs
364
+ using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT')
365
+
366
+ try {
367
+ const containsImage = options.messages.some(message => contentHasImage(message.content))
368
+ if (containsImage && !model.input.includes('image')) {
369
+ throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT')
370
+ }
371
+ const attachments = containsImage ? this.config.resolveAttachments?.() : undefined
372
+ if (containsImage && attachments === undefined) {
373
+ throw new LlmError('pi-ai image input requires the durable attachment service', 'UNSUPPORTED_CONTENT')
374
+ }
375
+ const onReplayDegrade = (reason: string): void => {
376
+ this.config.onReplayDegrade?.({ provider: options.provider, model: options.model, reason })
377
+ }
378
+ const context = attachments === undefined
379
+ ? toPiContext(options, undefined, onReplayDegrade)
380
+ : await toPiContext({ ...options, signal: watchdog.signal }, {
381
+ attachments,
382
+ resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref),
383
+ maxRequestImageBytes: profile.maxRequestImageBytes,
384
+ requestImagePolicy: {
385
+ maxPixels: profile.requestImagePixelBudget,
386
+ maxBytes: profile.requestImageMaxBytes,
387
+ },
388
+ }, onReplayDegrade)
389
+ const events = snapshot.models.streamSimple(model, context, {
390
+ ...profileOptions(profile, reasoning, auth.apiKey),
391
+ ...options.temperature === undefined ? {} : { temperature: options.temperature },
392
+ ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },
393
+ ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
394
+ signal: watchdog.signal,
395
+ // Profile headers are deployment-owned; attribution names are
396
+ // Host-owned and therefore win collisions.
397
+ headers: requestHeaders(profile.headers, auth.headers),
398
+ })
399
+ const iterator = toStreamChunks(events, model.contextWindow, options.signal)[Symbol.asyncIterator]()
400
+ let exhausted = false
401
+ try {
402
+ while (true) {
403
+ const result = await watchdog.next(iterator)
404
+ const timeout = timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT')
405
+ if (timeout !== undefined) throw timeout
406
+ if (result.done) {
407
+ exhausted = true
408
+ return
409
+ }
410
+ yield result.value
411
+ }
412
+ } finally {
413
+ if (!exhausted) {
414
+ consumer.abort('pi-ai stream consumer stopped')
415
+ try {
416
+ await iterator.return(undefined)
417
+ } catch (_abortedSdkTeardown) {
418
+ // The stable signal already owns SDK termination; return-time abort cannot add an outcome.
419
+ }
420
+ }
421
+ }
422
+ } catch (error: unknown) {
423
+ if (timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT') !== undefined) {
424
+ throw new LlmError(`pi-ai stream idle timeout after ${streamIdleTimeoutMs}ms`, 'TIMEOUT', { cause: error })
425
+ }
426
+ if (options.signal?.aborted) {
427
+ throw new LlmError('pi-ai request aborted by caller', 'ABORTED', { cause: error })
428
+ }
429
+ throw error
430
+ } finally {
431
+ consumer.abort('pi-ai stream consumer stopped')
432
+ }
433
+ }
434
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The three adapters between pi-ai's auth model and the harness credential
3
+ * plane. Every pi-ai-specific concept stays on this side of them: the harness
4
+ * seams they consume — `ctx.credentials` records and `ctx.authorization` flows —
5
+ * name nothing from this library, so another adapter family can arrive with a
6
+ * different auth model and share the same two seams.
7
+ *
8
+ * @module dsh-llm-pi-ai/auth
9
+ */
10
+
11
+ import { homedir } from 'node:os'
12
+ import { access } from 'node:fs/promises'
13
+ import { resolve as resolvePath } from 'node:path'
14
+ import type { AuthContext, Credential, CredentialInfo, CredentialStore } from '@earendil-works/pi-ai'
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import { defaultProviderAuthContext, InMemoryCredentialStore } from '@earendil-works/pi-ai'
17
+ import type { PiAiAuthInjection } from './adapter.ts'
18
+ import {
19
+ credentialKey, credentialKeyId, credentialKeyScope, credentialRef, isCredentialKeySegment, isCredentialRefName,
20
+ } from '@deepseek-ai/dsh-credentials'
21
+ import type { CredentialKey, CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials'
22
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
23
+ import { LlmError } from '@deepseek-ai/dsh-llm'
24
+
25
+ /**
26
+ * The record scope every credential this adapter family stores is written
27
+ * under. It is the plugin's registered name, which is what tells a later
28
+ * reader — a configuration UI, or a second adapter family serving the same
29
+ * provider name — that this plugin owns the format inside the record.
30
+ */
31
+ export const RECORD_SCOPE = 'llm-pi-ai'
32
+
33
+ /**
34
+ * The record address for one pi-ai provider id.
35
+ * @param providerId - pi-ai's own provider id, which is also the harness route key.
36
+ * @returns the scoped credential key this adapter family reads and writes.
37
+ */
38
+ export function recordKeyFor(providerId: string): CredentialKey {
39
+ return credentialKey(RECORD_SCOPE, providerId)
40
+ }
41
+
42
+ /**
43
+ * The JSON image of one grant payload: plain objects lose their
44
+ * explicitly-undefined members and array entries JSON cannot hold become
45
+ * null, exactly as `JSON.stringify` would render them. pi-ai credentials
46
+ * idiomatically carry optional members as explicit `undefined` (a github.com
47
+ * Copilot grant holds `enterpriseUrl: undefined`), which the credential
48
+ * store's strict validator refuses as unrepresentable. Everything else —
49
+ * non-finite numbers and foreign prototypes included — passes through
50
+ * untouched, so a genuinely unstorable value still fails loud at the store.
51
+ * @param value - the value to render.
52
+ * @returns the value's JSON image.
53
+ */
54
+ function jsonImage(value: unknown): unknown {
55
+ if (Array.isArray(value)) return value.map(entry => entry === undefined ? null : jsonImage(entry))
56
+ if (typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype) {
57
+ const image: Record<string, unknown> = {}
58
+ for (const [key, member] of Object.entries(value)) {
59
+ if (member !== undefined) image[key] = jsonImage(member)
60
+ }
61
+ return image
62
+ }
63
+ return value
64
+ }
65
+
66
+ /**
67
+ * Translate a stored record into the credential pi-ai expects.
68
+ *
69
+ * An `api-key` record is structural on both sides, so it is rebuilt field by
70
+ * field. A `grant` payload is pi-ai's own OAuth credential, stored verbatim:
71
+ * the seam treats it as opaque JSON precisely so a library that owns a token
72
+ * format keeps owning it, refresh fields and all.
73
+ * @param record - the stored record, or undefined when nothing is stored.
74
+ * @returns the pi-ai credential, or undefined for an absent record.
75
+ */
76
+ function toPiCredential(record: CredentialRecord | undefined): Credential | undefined {
77
+ if (record === undefined) return undefined
78
+ if (record.kind === 'api-key') {
79
+ return {
80
+ type: 'api_key',
81
+ ...record.key === undefined ? {} : { key: record.key },
82
+ ...record.env === undefined ? {} : { env: { ...record.env } },
83
+ }
84
+ }
85
+ return record.payload as Credential
86
+ }
87
+
88
+ /**
89
+ * Translate a pi-ai credential into the record to store.
90
+ * @param credential - what a login or refresh produced.
91
+ * @returns the record to commit, in the union the credential seam stores.
92
+ */
93
+ function toRecord(credential: Credential): CredentialRecord {
94
+ if (credential.type === 'api_key') {
95
+ return {
96
+ kind: 'api-key',
97
+ ...credential.key === undefined ? {} : { key: credential.key },
98
+ ...credential.env === undefined ? {} : { env: { ...credential.env } },
99
+ }
100
+ }
101
+ return { kind: 'grant', payload: jsonImage(credential) }
102
+ }
103
+
104
+ /**
105
+ * The credential service, or the failure that names what is missing. Reads
106
+ * answer "nothing stored" without a service, because a composition with no
107
+ * credential plane genuinely holds no credential; writes refuse, because a
108
+ * login whose grant silently evaporated would report success and then fail
109
+ * every request.
110
+ * @param ctx - the plugin context.
111
+ * @returns the live service.
112
+ * @throws {LlmError} code `NO_CREDENTIAL_STORE` when none is mounted.
113
+ */
114
+ function writableStore(ctx: Context): CredentialProvider {
115
+ const credentials = ctx.get('credentials')
116
+ if (credentials === undefined) {
117
+ throw new LlmError(
118
+ 'llm-pi-ai: this composition mounts no credentials service, so there is nowhere to store the'
119
+ + ' credential a sign-in produces; mount one (dsh-credentials-local) to sign in',
120
+ 'NO_CREDENTIAL_STORE',
121
+ )
122
+ }
123
+ return credentials
124
+ }
125
+
126
+ /**
127
+ * A pi-ai `CredentialStore` over the harness credential records.
128
+ *
129
+ * pi-ai runs OAuth refresh *inside* `modify()`, so this store's exclusion has
130
+ * to cover a network round trip rather than a file rename — which is why the
131
+ * record write path takes a wait limit of its own rather than the short one a
132
+ * local write would need.
133
+ *
134
+ * pi-ai asks this store about every provider in the collection, hand-declared
135
+ * routes included, and a route key is an arbitrary settings dict key while a
136
+ * record id is not. An id outside the record grammar can never have stored a
137
+ * record, so reads answer "nothing stored" and a delete has nothing to remove;
138
+ * only `modify` refuses it, because a write that cannot land must not report
139
+ * that it did.
140
+ * @param ctx - the plugin context carrying the optional `ctx.credentials`.
141
+ * @returns the store to hand `createModels()`.
142
+ */
143
+ export function credentialStoreFrom(ctx: Context): CredentialStore {
144
+ return {
145
+ async read(providerId) {
146
+ const credentials = ctx.get('credentials')
147
+ if (credentials === undefined) return undefined
148
+ if (!isCredentialKeySegment(providerId)) return undefined
149
+ return toPiCredential(await credentials.readRecord(recordKeyFor(providerId)))
150
+ },
151
+ async list(): Promise<readonly CredentialInfo[]> {
152
+ const stored = await ctx.get('credentials')?.listRecords() ?? []
153
+ const mine: CredentialInfo[] = []
154
+ for (const entry of stored) {
155
+ // Records another plugin owns are not this collection's to report:
156
+ // their payloads are written in a format pi-ai never agreed to.
157
+ if (credentialKeyScope(entry.key) !== RECORD_SCOPE) continue
158
+ mine.push({
159
+ providerId: credentialKeyId(entry.key),
160
+ type: entry.kind === 'api-key' ? 'api_key' : 'oauth',
161
+ })
162
+ }
163
+ return mine
164
+ },
165
+ async modify(providerId, mutate) {
166
+ if (!isCredentialKeySegment(providerId)) {
167
+ throw new LlmError(
168
+ `llm-pi-ai: provider id "${providerId}" cannot address a stored credential record (a record id is a`
169
+ + ' lowercase hyphenated identifier); authenticate this route through apiKeyEnv instead of a stored'
170
+ + ' credential',
171
+ 'UNSTORABLE_PROVIDER_ID',
172
+ )
173
+ }
174
+ const stored = await writableStore(ctx).modifyRecord(recordKeyFor(providerId), async (current) => {
175
+ const next = await mutate(toPiCredential(current))
176
+ return next === undefined ? undefined : toRecord(next)
177
+ })
178
+ return toPiCredential(stored)
179
+ },
180
+ // `async` so a missing service reaches the caller as a rejection: pi-ai's
181
+ // store contract is promise-returning, and a synchronous throw would
182
+ // escape the `ModelsError` wrapper every other storage failure gets.
183
+ async delete(providerId) {
184
+ if (!isCredentialKeySegment(providerId)) return
185
+ await writableStore(ctx).deleteRecord(recordKeyFor(providerId))
186
+ },
187
+ }
188
+ }
189
+
190
+ /**
191
+ * A pi-ai `AuthContext` over the harness credential plane and the host
192
+ * filesystem.
193
+ *
194
+ * `env()` answers from the credential seam first, so a value a deployment
195
+ * stored through the harness is found by a provider's own ambient discovery —
196
+ * without this, that discovery reads only the process environment and a stored
197
+ * `AWS_ACCESS_KEY_ID` is invisible to it. `fileExists()` answers about the host
198
+ * process's own filesystem rather than the workspace `ctx.fs` seam, because the
199
+ * paths it is asked about (`~/.aws/credentials`, application-default
200
+ * credentials) are facts about where this process runs, not about the project
201
+ * under edit.
202
+ * @param ctx - the plugin context carrying the optional `ctx.credentials`.
203
+ * @returns the auth context to hand `createModels()`.
204
+ */
205
+ export function authContextFrom(ctx: Context): AuthContext {
206
+ return {
207
+ async env(name) {
208
+ // pi-ai asks about arbitrary provider-declared names; one that is not a
209
+ // POSIX identifier can never have been stored as a reference, and asking
210
+ // the seam would throw instead of answering "not set".
211
+ if (isCredentialRefName(name)) {
212
+ const credentials = ctx.get('credentials')
213
+ const hit = await credentials?.resolve(credentialRef(name))
214
+ if (hit !== undefined) return hit.value
215
+ }
216
+ return launchEnvironmentOf(ctx).get(name)?.value
217
+ },
218
+ async fileExists(path) {
219
+ const expanded = path.startsWith('~/') || path === '~'
220
+ ? resolvePath(homedir(), path.slice(1).replace(/^\//, ''))
221
+ : path
222
+ try {
223
+ await access(expanded)
224
+ return true
225
+ } catch {
226
+ // Absent, unreadable, or a broken symlink — every one of which means
227
+ // this ambient credential source cannot be used, which is the only
228
+ // distinction the caller makes.
229
+ return false
230
+ }
231
+ },
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Create private auth storage for adapters whose explicit source owns every credential.
237
+ * @returns an empty in-memory store and provider auth context, independent of ChatCode CLI login records.
238
+ */
239
+ export function isolatedPiAiAuth(): PiAiAuthInjection {
240
+ return { credentials: new InMemoryCredentialStore(), authContext: defaultProviderAuthContext() }
241
+ }