@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,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
|
+
}
|