@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,349 @@
1
+ /**
2
+ * ChatCode CLI request-history conversion into pi-ai's Context vocabulary.
3
+ *
4
+ * @module dsh-llm-pi-ai/context
5
+ */
6
+
7
+ import { brandString } from '@deepseek-ai/dsh-brand'
8
+ import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, projectOffloadedImages, requestImageHandleText, requiredImageOffload } from '@deepseek-ai/dsh-llm'
9
+ import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message, ToolCallId } from '@deepseek-ai/dsh-llm'
10
+ import type {
11
+ AttachmentId,
12
+ AttachmentStore,
13
+ ImageAttachmentRef,
14
+ ImageRequestTarget,
15
+ RequestImageAttachment,
16
+ } from '@deepseek-ai/dsh-attachment'
17
+ import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai'
18
+ import { toPiAssistant } from './replay.ts'
19
+ import { requestImageDimensions } from '@deepseek-ai/dsh-attachment'
20
+ import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET } from './config.ts'
21
+
22
+ /** Join the text blocks of a harness message. */
23
+ function flattenText(message: Message): string {
24
+ return message.content
25
+ .filter(block => block.type === 'text')
26
+ .map(block => block.text)
27
+ .join('')
28
+ }
29
+
30
+
31
+ /** Flatten text recursively inside one tool result. */
32
+ function toolResultText(blocks: readonly ContentBlock[]): string {
33
+ return blocks.map(block => block.type === 'text'
34
+ ? block.text
35
+ : block.type === 'tool-result' ? toolResultText(block.content) : '').join('')
36
+ }
37
+
38
+ /** Reject image roles that pi-ai cannot replay before request-size offloading can replace them. */
39
+ function assertSupportedImageRoles(messages: readonly Message[]): void {
40
+ for (const message of messages) {
41
+ if (message.role !== 'user' && contentHasImage(message.content)) {
42
+ throw new LlmError(
43
+ `pi-ai cannot represent an image in an in-history ${message.role} message`,
44
+ 'UNSUPPORTED_CONTENT',
45
+ )
46
+ }
47
+ }
48
+ }
49
+
50
+ async function userContent(
51
+ blocks: readonly ContentBlock[],
52
+ requestImages: ReadonlyMap<AttachmentId, RequestImageAttachment>,
53
+ resolveImageAccess: ImageAttachmentAccessResolver,
54
+ ): Promise<string | (TextContent | ImageContent)[]> {
55
+ const content: (TextContent | ImageContent)[] = []
56
+ for (const block of blocks) {
57
+ switch (block.type) {
58
+ case 'text':
59
+ if (block.text.length > 0) content.push({ type: 'text', text: block.text })
60
+ break
61
+ case 'image': {
62
+ const version = requestImages.get(block.attachment.attachmentId) as RequestImageAttachment
63
+ content.push({
64
+ type: 'text',
65
+ text: requestImageHandleText(block.attachment, version, resolveImageAccess(block.attachment)),
66
+ })
67
+ content.push({
68
+ type: 'image',
69
+ data: Buffer.from(version.data).toString('base64'),
70
+ mimeType: version.mediaType,
71
+ })
72
+ break
73
+ }
74
+ case 'tool-result':
75
+ {
76
+ const nested = await userContent(block.content, requestImages, resolveImageAccess)
77
+ if (typeof nested === 'string') {
78
+ if (nested.length > 0) content.push({ type: 'text', text: nested })
79
+ } else {
80
+ content.push(...nested)
81
+ }
82
+ }
83
+ break
84
+ default:
85
+ // Other merge-extensible blocks are not user-input vocabulary for pi-ai.
86
+ break
87
+ }
88
+ }
89
+ if (content.every(block => block.type === 'text')) return content.map(block => block.text).join('')
90
+ return content
91
+ }
92
+
93
+ function collectImageRefs(
94
+ blocks: readonly ContentBlock[],
95
+ refs: Map<AttachmentId, ImageAttachmentRef>,
96
+ ): void {
97
+ for (const block of blocks) {
98
+ if (block.type === 'image') {
99
+ if (block.offloaded !== true) refs.set(block.attachment.attachmentId, block.attachment)
100
+ } else if (block.type === 'tool-result') {
101
+ collectImageRefs(block.content, refs)
102
+ }
103
+ }
104
+ }
105
+
106
+ async function prepareRequestImages(
107
+ messages: readonly Message[],
108
+ attachments: AttachmentStore,
109
+ budget: PiImageRequestBudget,
110
+ signal?: AbortSignal,
111
+ ): Promise<Map<AttachmentId, RequestImageAttachment>> {
112
+ const refs = new Map<AttachmentId, ImageAttachmentRef>()
113
+ for (const message of messages) collectImageRefs(message.content, refs)
114
+ const orderedRefs = [...refs.values()]
115
+ const prepared = await Promise.all(orderedRefs.map(
116
+ ref => attachments.readImageRequest(ref, requestImageTarget(ref, budget), signal),
117
+ ))
118
+ const versions = new Map<AttachmentId, RequestImageAttachment>()
119
+ for (const [index, ref] of orderedRefs.entries()) {
120
+ versions.set(ref.attachmentId, prepared[index] as RequestImageAttachment)
121
+ }
122
+ return versions
123
+ }
124
+
125
+ function toolsOf(options: GenerateOptions): PiTool[] | undefined {
126
+ return options.tools?.map(tool => ({
127
+ name: tool.name,
128
+ description: tool.description,
129
+ // ToolSchema.parameters is a JSON Schema object; pi-ai's TSchema
130
+ // (TypeBox) is structurally JSON Schema, so it assigns directly.
131
+ parameters: tool.parameters,
132
+ }))
133
+ }
134
+
135
+ /** The request split into pi-ai's single `systemPrompt` slot and the history that converts to `messages`. */
136
+ interface SystemPromptSplit {
137
+ /** Text for pi-ai's `systemPrompt`; `undefined` sends no system prompt. */
138
+ systemPrompt: string | undefined
139
+ /** History messages that convert to pi-ai `messages`. */
140
+ messages: readonly Message[]
141
+ }
142
+
143
+ /** Select the pi-ai system prompt source shared by both conversion paths. */
144
+ function splitSystemPrompt(options: GenerateOptions): SystemPromptSplit {
145
+ if (options.system !== undefined) return { systemPrompt: options.system, messages: options.messages }
146
+ const [first, ...rest] = options.messages
147
+ if (first?.role !== 'system') return { systemPrompt: undefined, messages: options.messages }
148
+ const text = flattenText(first)
149
+ return { systemPrompt: text.length > 0 ? text : undefined, messages: rest }
150
+ }
151
+
152
+ /** Assemble the request-level pi-ai context envelope shared by both conversion paths. */
153
+ function piContext(systemPrompt: string | undefined, options: GenerateOptions, messages: PiMessage[]): PiContext {
154
+ const tools = toolsOf(options)
155
+ return {
156
+ ...systemPrompt !== undefined ? { systemPrompt } : {},
157
+ messages,
158
+ ...tools !== undefined && tools.length > 0 ? { tools } : {},
159
+ }
160
+ }
161
+
162
+ function appendAssistant(
163
+ message: Message,
164
+ messages: PiMessage[],
165
+ toolNames: Map<ToolCallId, string>,
166
+ onReplayDegrade?: (reason: string) => void,
167
+ ): void {
168
+ const assistant = toPiAssistant(message, onReplayDegrade)
169
+ for (const block of assistant.content) {
170
+ if (block.type === 'toolCall') toolNames.set(brandString<ToolCallId>(block.id), block.name)
171
+ }
172
+ messages.push(assistant)
173
+ }
174
+
175
+ function textOnlyContext(options: GenerateOptions, onReplayDegrade?: (reason: string) => void): PiContext {
176
+ assertSupportedImageRoles(options.messages)
177
+ const split = splitSystemPrompt(options)
178
+ const toolNames = new Map<ToolCallId, string>()
179
+ const messages: PiMessage[] = []
180
+ for (const message of split.messages) {
181
+ if (contentHasImage(message.content)) {
182
+ throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT')
183
+ }
184
+ if (message.role === 'system') {
185
+ messages.push({ role: 'user', content: flattenText(message), timestamp: 0 })
186
+ continue
187
+ }
188
+ if (message.role === 'assistant') {
189
+ appendAssistant(message, messages, toolNames, onReplayDegrade)
190
+ continue
191
+ }
192
+ const text = flattenText(message)
193
+ const results = message.content.filter(block => block.type === 'tool-result')
194
+ if (text.length > 0 || results.length === 0) messages.push({ role: 'user', content: text, timestamp: 0 })
195
+ for (const result of results) {
196
+ messages.push({
197
+ role: 'toolResult',
198
+ toolCallId: result.toolCallId,
199
+ toolName: toolNames.get(result.toolCallId) ?? 'unknown',
200
+ content: [{
201
+ type: 'text',
202
+ text: toolResultText(result.content) || '(no output)',
203
+ }],
204
+ isError: result.isError ?? false,
205
+ timestamp: 0,
206
+ })
207
+ }
208
+ }
209
+ return piContext(split.systemPrompt, options, messages)
210
+ }
211
+
212
+ /** Inputs that bind deterministic request images to one current tool execution world. */
213
+ export interface PiImageRequestContext {
214
+ /** Durable provider that resolves request-image bytes and provider-owned host objects. */
215
+ attachments: AttachmentStore
216
+ /** Resolve current tool access separately from deterministic request-image versions. */
217
+ resolveImageAccess: ImageAttachmentAccessResolver
218
+ /** Request-level bound on the base64-encoded payload of retained images; omission leaves the bound unchecked. */
219
+ maxRequestImageBytes?: number
220
+ /** Route pixel and raw encoded-byte budgets. */
221
+ requestImagePolicy?: PiImageRequestBudget
222
+ }
223
+
224
+ /** Per-route budgets from which each request image's target is derived. */
225
+ export interface PiImageRequestBudget {
226
+ /** Total-pixel budget; larger sources are downscaled proportionally. */
227
+ maxPixels: number
228
+ /** Encoded-byte target for one request image. */
229
+ maxBytes: number
230
+ }
231
+
232
+ /** Deterministic request target for one source under the route budgets. */
233
+ function requestImageTarget(ref: ImageAttachmentRef, budget: PiImageRequestBudget): ImageRequestTarget {
234
+ return { ...requestImageDimensions(ref.width, ref.height, budget.maxPixels), maxBytes: budget.maxBytes }
235
+ }
236
+
237
+ /**
238
+ * Convert text-only harness history to a synchronous pi-ai Context. Tool
239
+ * result names are recovered from preceding assistant tool calls.
240
+ * @param options - the harness request; `options.system`, else a leading `system` message, maps to pi-ai's single `systemPrompt` slot.
241
+ * @param images - absent; selects the synchronous conversion.
242
+ * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
243
+ * @returns the pi-ai context; `tools` is omitted when the request declares none.
244
+ * @throws {LlmError} `UNSUPPORTED_CONTENT` for images in any history role, including a leading system message.
245
+ */
246
+ export function toPiContext(
247
+ options: GenerateOptions,
248
+ images?: undefined,
249
+ onReplayDegrade?: (reason: string) => void,
250
+ ): PiContext
251
+ /**
252
+ * Convert harness history to a pi-ai Context while resolving durable images.
253
+ * Tool result names are recovered from preceding assistant tool calls. Image
254
+ * occurrences the surface marks offloaded become text placeholders; when the
255
+ * retained occurrences' exact base64 payload still exceeds
256
+ * `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` naming
257
+ * how many more oldest occurrences must be offloaded.
258
+ * @param options - the harness request; `options.system`, else a leading `system` message, maps to pi-ai's single `systemPrompt` slot.
259
+ * @param images - attachment provider, current path resolver, and request limits.
260
+ * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
261
+ * @returns the asynchronously resolved pi-ai context.
262
+ */
263
+ export function toPiContext(
264
+ options: GenerateOptions,
265
+ images: PiImageRequestContext,
266
+ onReplayDegrade?: (reason: string) => void,
267
+ ): Promise<PiContext>
268
+ export function toPiContext(
269
+ options: GenerateOptions,
270
+ images?: PiImageRequestContext,
271
+ onReplayDegrade?: (reason: string) => void,
272
+ ): PiContext | Promise<PiContext> {
273
+ return images === undefined
274
+ ? textOnlyContext(options, onReplayDegrade)
275
+ : toPiContextWithImages(options, images, onReplayDegrade)
276
+ }
277
+
278
+ async function toPiContextWithImages(
279
+ options: GenerateOptions,
280
+ images: PiImageRequestContext,
281
+ onReplayDegrade?: (reason: string) => void,
282
+ ): Promise<PiContext> {
283
+ const { attachments, resolveImageAccess, maxRequestImageBytes } = images
284
+ const requestImagePolicy = images.requestImagePolicy ?? {
285
+ maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
286
+ maxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES,
287
+ }
288
+ assertSupportedImageRoles(options.messages)
289
+ const split = splitSystemPrompt(options)
290
+ const requestImages = await prepareRequestImages(split.messages, attachments, requestImagePolicy, options.signal)
291
+ if (maxRequestImageBytes !== undefined) {
292
+ const offloadImages = requiredImageOffload(
293
+ split.messages,
294
+ { representation: 'base64', maxBytes: maxRequestImageBytes },
295
+ block => (requestImages.get(block.attachment.attachmentId) as RequestImageAttachment).bytes,
296
+ )
297
+ if (offloadImages > 0) {
298
+ throw new LlmError(
299
+ `pi-ai request images exceed the ${maxRequestImageBytes}-byte base64 bound; ${offloadImages} more oldest occurrence(s) must be offloaded.`,
300
+ IMAGE_OFFLOAD_REQUIRED_CODE,
301
+ { offloadImages },
302
+ )
303
+ }
304
+ }
305
+ const exactMessages = projectOffloadedImages(
306
+ split.messages,
307
+ ref => offloadedImageText(ref, resolveImageAccess(ref)),
308
+ )
309
+ const toolNames = new Map<ToolCallId, string>()
310
+ const messages: PiMessage[] = []
311
+
312
+ for (const message of exactMessages) {
313
+ if (message.role === 'system') {
314
+ // pi-ai has a single systemPrompt slot; in-history system messages are
315
+ // folded into user messages to preserve order (rare in practice — the
316
+ // harness sends the system prompt via options.system).
317
+ messages.push({ role: 'user', content: flattenText(message), timestamp: 0 })
318
+ continue
319
+ }
320
+ if (message.role === 'assistant') {
321
+ appendAssistant(message, messages, toolNames, onReplayDegrade)
322
+ continue
323
+ }
324
+ // user role: text + tool results (each result becomes its own message).
325
+ const regular = message.content.filter(block => block.type !== 'tool-result')
326
+ const content = await userContent(regular, requestImages, resolveImageAccess)
327
+ const results = message.content.filter((block): block is Extract<ContentBlock, { type: 'tool-result' }> => (
328
+ block.type === 'tool-result'
329
+ ))
330
+ if (content.length > 0 || results.length === 0) {
331
+ messages.push({ role: 'user', content, timestamp: 0 })
332
+ }
333
+ for (const result of results) {
334
+ const resultContent = await userContent(result.content, requestImages, resolveImageAccess)
335
+ messages.push({
336
+ role: 'toolResult',
337
+ toolCallId: result.toolCallId,
338
+ toolName: toolNames.get(result.toolCallId) ?? 'unknown',
339
+ content: typeof resultContent === 'string'
340
+ ? [{ type: 'text', text: resultContent || '(no output)' }]
341
+ : resultContent,
342
+ isError: result.isError ?? false,
343
+ timestamp: 0,
344
+ })
345
+ }
346
+ }
347
+
348
+ return piContext(split.systemPrompt, options, messages)
349
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Answering "which models can this provider serve?" for the configuration
3
+ * surface's "fetch available models" action.
4
+ *
5
+ * A route the installed pi-ai catalog ships is answered **from that catalog**,
6
+ * with no network call at all: pi-ai's registry is the authoritative list for
7
+ * its own providers, and it carries the capacities a listing endpoint would
8
+ * not disclose. Only a route the catalog does not describe — a gateway, a
9
+ * self-hosted server — is interrogated over the wire.
10
+ *
11
+ * Neither path is a catalog refresh. Nothing here is stored: the request
12
+ * carries a draft the user is still editing, and the reply is candidate
13
+ * metadata the surface offers for adoption. `settings.yaml` remains the only
14
+ * thing that decides what a route serves.
15
+ *
16
+ * Only OpenAI-compatible protocols are interrogated. Their listing is the one
17
+ * shape a gateway, a self-hosted server, and the official endpoints all agree
18
+ * on, which is the case this action exists for; every other protocol reports
19
+ * that it cannot be interrogated so the surface falls back to hand-entry
20
+ * rather than guessing a response shape.
21
+ *
22
+ * @module dsh-llm-pi-ai/discovery
23
+ */
24
+
25
+ import { INVALID_CREDENTIAL_CODE, LlmError, normalizeApiKey } from '@deepseek-ai/dsh-llm'
26
+ import type { LlmDiscoveredModel, LlmModelDiscoveryOperation } from '@deepseek-ai/dsh-llm'
27
+ import { attributionHeaders } from '@deepseek-ai/dsh-llm'
28
+ import { catalogModels } from './catalog.ts'
29
+
30
+ /**
31
+ * Protocols whose model listing this module can read: the two that speak
32
+ * OpenAI's `GET /models` shape with bearer auth. Azure is absent despite its
33
+ * OpenAI lineage — it authenticates with an `api-key` header and requires an
34
+ * `api-version` query — and Codex authenticates through OAuth; guessing at
35
+ * either would report an authentication failure as a provider with no models.
36
+ * pi-ai's remaining protocols are absent for the same reason.
37
+ */
38
+ const LISTABLE_PROTOCOLS: ReadonlySet<string> = new Set([
39
+ 'openai-completions',
40
+ 'openai-responses',
41
+ ])
42
+
43
+ /**
44
+ * Endpoint replies larger than this are refused. The endpoint is whatever URL
45
+ * the user typed, so the ceiling holds on the bytes actually read rather than
46
+ * on the length the server claims — the same two-stage shape `dsh-web-fetch`
47
+ * uses for its own caller-supplied URLs, except that a truncated model listing
48
+ * is not parseable, so overflow rejects instead of truncating.
49
+ */
50
+ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
51
+
52
+ /** One entry of an OpenAI-compatible `GET /models` reply. */
53
+ interface ListingEntry {
54
+ id?: unknown
55
+ /** Common gateway extensions; absent from the official listings. */
56
+ name?: unknown
57
+ display_name?: unknown
58
+ context_window?: unknown
59
+ context_length?: unknown
60
+ max_tokens?: unknown
61
+ max_output_tokens?: unknown
62
+ }
63
+
64
+ /** A positive integer field of a listing entry, or `undefined` when absent or unusable. */
65
+ function capacity(...candidates: readonly unknown[]): number | undefined {
66
+ for (const candidate of candidates) {
67
+ if (typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0) return candidate
68
+ }
69
+ return undefined
70
+ }
71
+
72
+ /** A non-empty string field of a listing entry, or `undefined`. */
73
+ function label(...candidates: readonly unknown[]): string | undefined {
74
+ for (const candidate of candidates) {
75
+ if (typeof candidate === 'string' && candidate.length > 0) return candidate
76
+ }
77
+ return undefined
78
+ }
79
+
80
+ /**
81
+ * Join the endpoint base with the listing path. The base is treated as a
82
+ * prefix rather than a URL to resolve against, so a deployment path such as
83
+ * `https://gateway.example/openai/v1` keeps its segments instead of losing
84
+ * them to `URL` resolution.
85
+ */
86
+ function listingUrl(baseURL: string): string {
87
+ return `${baseURL.replace(/\/+$/, '')}/models`
88
+ }
89
+
90
+ /**
91
+ * Read a reply body, refusing one that outgrows the ceiling. A declared length
92
+ * is checked first so an honest server is turned away without transferring
93
+ * anything; the accumulated total is what actually enforces the bound, because
94
+ * a server that under-declares (or streams) tells us nothing up front.
95
+ */
96
+ async function readBounded(response: Response, url: string): Promise<string> {
97
+ const oversized = (): LlmError =>
98
+ new LlmError(`${url} answered with more than ${MAX_RESPONSE_BYTES} bytes`, 'DISCOVERY_FAILED')
99
+ const declared = Number(response.headers.get('content-length') ?? Number.NaN)
100
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
101
+ await response.body?.cancel()
102
+ throw oversized()
103
+ }
104
+ /* v8 ignore next -- fetch always exposes a body stream on a 2xx Response; the null guard is defensive. */
105
+ if (response.body === null) return ''
106
+ const reader = response.body.getReader()
107
+ const chunks: Uint8Array[] = []
108
+ let total = 0
109
+ try {
110
+ for (;;) {
111
+ const { done, value } = await reader.read()
112
+ if (done) break
113
+ total += value.byteLength
114
+ if (total > MAX_RESPONSE_BYTES) throw oversized()
115
+ chunks.push(value)
116
+ }
117
+ } finally {
118
+ /* v8 ignore next 4 -- cancel() after a completed or abandoned read settles without rejecting; unobserved best-effort cleanup. */
119
+ await reader.cancel().catch(() => {
120
+ // Cancel after a drained read, or after this function walked away from
121
+ // an oversized one, is cleanup; the reply is already decided either way.
122
+ })
123
+ }
124
+ const body = new Uint8Array(total)
125
+ let offset = 0
126
+ for (const chunk of chunks) {
127
+ body.set(chunk, offset)
128
+ offset += chunk.byteLength
129
+ }
130
+ return new TextDecoder().decode(body)
131
+ }
132
+
133
+ /**
134
+ * Read one OpenAI-compatible listing reply. Entries without a usable id are
135
+ * skipped rather than failing the whole interrogation: a single malformed row
136
+ * should not deny the user the rest of a working endpoint's catalog.
137
+ */
138
+ function readListing(body: unknown): LlmDiscoveredModel[] {
139
+ const data = (body as { data?: unknown } | null)?.data
140
+ if (!Array.isArray(data)) {
141
+ throw new LlmError(
142
+ 'the endpoint\'s model listing has no "data" array; enter this provider\'s models by hand',
143
+ 'DISCOVERY_FAILED',
144
+ )
145
+ }
146
+ const models: LlmDiscoveredModel[] = []
147
+ for (const raw of data) {
148
+ const entry = raw as ListingEntry | null
149
+ const id = label(entry?.id)
150
+ if (id === undefined) continue
151
+ const name = label(entry?.name, entry?.display_name)
152
+ const contextWindow = capacity(entry?.context_window, entry?.context_length)
153
+ const maxTokens = capacity(entry?.max_output_tokens, entry?.max_tokens)
154
+ models.push({
155
+ id,
156
+ ...name === undefined ? {} : { name },
157
+ ...contextWindow === undefined ? {} : { contextWindow },
158
+ ...maxTokens === undefined ? {} : { maxTokens },
159
+ })
160
+ }
161
+ return models
162
+ }
163
+
164
+ /**
165
+ * Accept one probe key, or refuse it before the header is built. Without this
166
+ * the `fetch` below would throw a ByteString `TypeError` that this function's
167
+ * catch reports as `could not reach <url>` — blaming the network for a local,
168
+ * deterministic fault.
169
+ * @param raw - the key typed into the form or read from storage.
170
+ * @returns the trimmed, usable key.
171
+ */
172
+ function usableProbeKey(raw: string): string {
173
+ const checked = normalizeApiKey(raw)
174
+ if (checked.ok) return checked.value
175
+ throw new LlmError(
176
+ checked.reason === 'empty'
177
+ ? 'this provider\'s API key is blank; enter it on the Models page, or clear it to probe unauthenticated'
178
+ : 'this provider\'s API key contains characters no HTTP header can carry; paste the raw key only',
179
+ INVALID_CREDENTIAL_CODE,
180
+ )
181
+ }
182
+
183
+ /**
184
+ * Interrogate one draft provider endpoint for the models it advertises.
185
+ * @param request - the endpoint, protocol, and one-shot credential to use.
186
+ * @param storedApiKey - the credential the named route already stored, asked
187
+ * for only when the draft carries none and only on the path that reaches the
188
+ * network. A configuration surface never holds a stored secret — it edits a
189
+ * redacted descriptor — so without this an already-configured route would be
190
+ * interrogated unauthenticated and answer 401.
191
+ * @returns the advertised models in endpoint order.
192
+ * @throws LlmError when the protocol has no readable listing, the endpoint
193
+ * refuses or fails the request, or the reply is not a model listing.
194
+ */
195
+ export async function discoverModels(
196
+ request: LlmModelDiscoveryOperation,
197
+ storedApiKey?: () => Promise<string | undefined>,
198
+ ): Promise<readonly LlmDiscoveredModel[]> {
199
+ // A catalog route already has its answer, and a better one: the installed
200
+ // entries carry context windows and output caps no listing endpoint reports.
201
+ if (request.provider !== undefined) {
202
+ const installed = catalogModels(request.provider)
203
+ if (installed.size > 0) {
204
+ return [...installed.values()].map(model => ({
205
+ id: model.id,
206
+ name: model.name,
207
+ contextWindow: model.contextWindow,
208
+ maxTokens: model.maxTokens,
209
+ }))
210
+ }
211
+ }
212
+ if (request.baseURL === undefined || request.baseURL.length === 0) {
213
+ throw new LlmError(
214
+ `pi-ai ships no catalog for provider "${request.provider ?? ''}", so its models can only come from its`
215
+ + " endpoint; set a baseURL, or enter this provider's models by hand",
216
+ 'DISCOVERY_FAILED',
217
+ )
218
+ }
219
+ // A draft that has not chosen a protocol yet is asked as OpenAI Chat
220
+ // Completions: it is the shape a gateway is overwhelmingly likely to speak,
221
+ // and the alternative — refusing until the field is filled — would withhold
222
+ // the action from the case it exists for. The cost is a misdirected message
223
+ // when the endpoint speaks something else (an Anthropic gateway answers 401,
224
+ // which reads as a credential problem), and hand-entry remains the way out.
225
+ const api = request.api ?? 'openai-completions'
226
+ if (!LISTABLE_PROTOCOLS.has(api)) {
227
+ throw new LlmError(
228
+ `pi-ai protocol "${api}" has no model listing this build can read; enter this provider's models by hand`,
229
+ 'DISCOVERY_UNSUPPORTED',
230
+ )
231
+ }
232
+ const url = listingUrl(request.baseURL)
233
+ // A key typed into the form wins: it is the one the user is testing, and it
234
+ // may be the replacement for exactly the stored key that is failing. The
235
+ // stored one is only asked for here, past the catalog short-circuit and the
236
+ // protocol check, so a route answered from the registry costs no credential
237
+ // lookup — and no diagnostic about a credential it never needed.
238
+ // A probe carrying no key stays unauthenticated, which is how a route that
239
+ // relies on the provider's own ambient discovery is meant to be asked.
240
+ const supplied = request.apiKey ?? await storedApiKey?.()
241
+ const apiKey = supplied === undefined ? undefined : usableProbeKey(supplied)
242
+ let response: Response
243
+ try {
244
+ response = await fetch(url, {
245
+ method: 'GET',
246
+ headers: {
247
+ accept: 'application/json',
248
+ ...apiKey === undefined ? {} : { authorization: `Bearer ${apiKey}` },
249
+ ...attributionHeaders(),
250
+ },
251
+ ...request.signal === undefined ? {} : { signal: request.signal },
252
+ })
253
+ } catch (error: unknown) {
254
+ if (request.signal?.aborted) {
255
+ throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error })
256
+ }
257
+ throw new LlmError(`could not reach ${url}`, 'DISCOVERY_FAILED', { cause: error })
258
+ }
259
+ if (!response.ok) {
260
+ throw new LlmError(
261
+ `${url} answered ${response.status}${response.status === 401 || response.status === 403 ? '; check the API key' : ''}`,
262
+ 'DISCOVERY_FAILED',
263
+ )
264
+ }
265
+ let text: string
266
+ try {
267
+ text = await readBounded(response, url)
268
+ } catch (error: unknown) {
269
+ // Cancellation during the body read rejects with the abort reason, which
270
+ // may be any value; the caller gets the same coded failure it would have
271
+ // for a cancellation before the request went out.
272
+ if (request.signal?.aborted) {
273
+ throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error })
274
+ }
275
+ throw error
276
+ }
277
+ let body: unknown
278
+ try {
279
+ body = JSON.parse(text)
280
+ } catch (error: unknown) {
281
+ throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error })
282
+ }
283
+ return readListing(body)
284
+ }