@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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/README.zh.md +133 -0
- package/cordis.patch.yml +4 -0
- package/cordis.web.patch.yml +12 -0
- package/docs/chatcode-login.md +88 -0
- package/docs/chatcode-login.zh.md +179 -0
- package/docs/chatcode-models.md +29 -0
- package/docs/chatcode-models.zh.md +29 -0
- package/docs/chatcode-reporting.md +96 -0
- package/docs/chatcode-reporting.zh.md +96 -0
- package/docs/decisions/2026-08-31-chatcode-model-source.md +39 -0
- package/docs/decisions/2026-08-31-chatcode-model-source.zh.md +39 -0
- package/docs/decisions/2026-09-16-actual-model-adapter-routing.md +31 -0
- package/docs/decisions/2026-09-16-actual-model-adapter-routing.zh.md +31 -0
- package/lib/client.js +469 -0
- package/lib/index.d.ts +263 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +4873 -0
- package/lib/index.js.map +1 -0
- package/lib/startup-gate-BaCbWaKH.js +164 -0
- package/lib/startup-gate-BaCbWaKH.js.map +1 -0
- package/lib/web-startup.d.ts +9 -0
- package/lib/web-startup.d.ts.map +1 -0
- package/lib/web-startup.js +20 -0
- package/lib/web-startup.js.map +1 -0
- package/package.json +121 -0
- package/vendor/README.md +7 -0
- package/vendor/dsh-llm-pi-ai/LICENSE +21 -0
- package/vendor/dsh-llm-pi-ai/README.i18n.yaml +6 -0
- package/vendor/dsh-llm-pi-ai/README.md +238 -0
- package/vendor/dsh-llm-pi-ai/README.zh.md +238 -0
- package/vendor/dsh-llm-pi-ai/package.json +65 -0
- package/vendor/dsh-llm-pi-ai/src/adapter.ts +434 -0
- package/vendor/dsh-llm-pi-ai/src/auth.ts +241 -0
- package/vendor/dsh-llm-pi-ai/src/catalog.ts +908 -0
- package/vendor/dsh-llm-pi-ai/src/config.ts +478 -0
- package/vendor/dsh-llm-pi-ai/src/context.ts +349 -0
- package/vendor/dsh-llm-pi-ai/src/discovery.ts +284 -0
- package/vendor/dsh-llm-pi-ai/src/index.ts +336 -0
- package/vendor/dsh-llm-pi-ai/src/invariant.ts +30 -0
- package/vendor/dsh-llm-pi-ai/src/login.ts +161 -0
- package/vendor/dsh-llm-pi-ai/src/provider.ts +192 -0
- package/vendor/dsh-llm-pi-ai/src/replay.ts +249 -0
- package/vendor/dsh-llm-pi-ai/src/stream.ts +232 -0
- package/vendor/dsh-llm-pi-ai/tests/adapter.e2e.ts +168 -0
- package/vendor/dsh-llm-pi-ai/tests/adapter.spec.ts +1034 -0
- package/vendor/dsh-llm-pi-ai/tests/assemble.ts +32 -0
- package/vendor/dsh-llm-pi-ai/tests/auth-double.ts +39 -0
- package/vendor/dsh-llm-pi-ai/tests/auth.spec.ts +221 -0
- package/vendor/dsh-llm-pi-ai/tests/catalog.spec.ts +1220 -0
- package/vendor/dsh-llm-pi-ai/tests/config.spec.ts +111 -0
- package/vendor/dsh-llm-pi-ai/tests/context.spec.ts +474 -0
- package/vendor/dsh-llm-pi-ai/tests/convert.spec.ts +922 -0
- package/vendor/dsh-llm-pi-ai/tests/discovery.spec.ts +374 -0
- package/vendor/dsh-llm-pi-ai/tests/dynamic-config.spec.ts +241 -0
- package/vendor/dsh-llm-pi-ai/tests/fixtures/qr-code.png +0 -0
- package/vendor/dsh-llm-pi-ai/tests/loader-composition.spec.ts +244 -0
- package/vendor/dsh-llm-pi-ai/tests/login.spec.ts +198 -0
- package/vendor/dsh-llm-pi-ai/tests/mock-server.ts +82 -0
- package/vendor/dsh-llm-pi-ai/tests/provider-apis.e2e.ts +266 -0
- package/vendor/dsh-llm-pi-ai/tests/sdk-options.spec.ts +106 -0
- package/vendor/dsh-llm-pi-ai/tsconfig.json +4 -0
- 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
|
+
}
|