@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,478 @@
1
+ /**
2
+ * Configuration schema and provider-profile validation for the pi-ai adapter.
3
+ * Profiles are a dict keyed by provider route, so the composition base and a
4
+ * user-settings layer merge per provider and the route set is structural.
5
+ *
6
+ * A route key is not required to name an installed pi-ai provider. When it does,
7
+ * that provider's endpoint, protocol, display name, and model catalog are the
8
+ * profile's defaults and the profile overrides them field by field; when it does
9
+ * not, the profile is the whole provider declaration. Resolution therefore ends
10
+ * in a built pi-ai `Provider` per route: everything a request needs is decided
11
+ * once, while the configuration key that made a route unserviceable can still be
12
+ * named in the failure.
13
+ *
14
+ * @module dsh-llm-pi-ai/config
15
+ */
16
+
17
+ import type { CacheRetention, ChatTemplateKwargValue, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
18
+ import z from '@deepseek-ai/schemastery'
19
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
20
+ import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
21
+ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
22
+ import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
23
+ import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
24
+ import {
25
+ CACHE_CONTROL_FORMATS,
26
+ CHAT_TEMPLATE_VARS,
27
+ MAX_TOKENS_FIELDS,
28
+ MODALITIES,
29
+ resolveRouteModels,
30
+ SUPPORTED_THINKING_FORMATS,
31
+ THINKING_LEVELS,
32
+ } from './catalog.ts'
33
+ import type {
34
+ PiAiCompatProfile,
35
+ PiAiModality,
36
+ PiAiModelOverride,
37
+ PiAiModelProfile,
38
+ PiAiReasoningEfforts,
39
+ } from './catalog.ts'
40
+ import { buildProvider, supportedProtocols } from './provider.ts'
41
+
42
+ /** Default maximum idle interval while an adapter stream read is outstanding. */
43
+ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
44
+
45
+ /**
46
+ * Default request-level bound on base64-encoded image payload. Every image in
47
+ * history is re-encoded into every request body, so an unbounded conversation
48
+ * eventually exceeds a provider or gateway request-size cap and the session
49
+ * can never complete another request. The 20MiB default admits fifteen 1MiB
50
+ * request versions after base64 expansion and reserves request capacity for
51
+ * system prompts, history, tools, and JSON.
52
+ * Deployments behind stricter gateways lower it per route.
53
+ */
54
+ export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024
55
+ /** Default total-pixel budget preserves the complete 2048px normalized attachment. */
56
+ export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048
57
+ /** Default raw encoded-byte target before inline base64 expansion; the smallest quality-ladder output is used when no quality fits. */
58
+ export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024
59
+
60
+ /** Context capacity assumed for a model neither configuration nor the catalog sizes. */
61
+ export const DEFAULT_CONTEXT_WINDOW = 262_144
62
+
63
+ /** Output capability assumed for a model neither configuration nor the catalog sizes. */
64
+ export const DEFAULT_MAX_TOKENS = 32_768
65
+
66
+ /**
67
+ * Modalities assumed for a model neither configuration nor the catalog
68
+ * declares. Text is the floor every supported protocol certainly carries, so
69
+ * this is the absence of a declaration rather than a guess at the endpoint:
70
+ * nothing can interrogate a gateway for its modalities, and the two wrong
71
+ * answers do not cost the same. Under-claiming refuses the image before it is
72
+ * attached, naming the model. Over-claiming admits one the provider then
73
+ * rejects mid-turn, after the message is durable, leaving the session
74
+ * repeating a request that cannot succeed.
75
+ */
76
+ export const DEFAULT_INPUT: readonly PiAiModality[] = ['text']
77
+
78
+ export type {
79
+ PiAiCompatProfile,
80
+ PiAiModality,
81
+ PiAiModelOverride,
82
+ PiAiModelProfile,
83
+ PiAiReasoningEfforts,
84
+ PiAiThinkingFormat,
85
+ } from './catalog.ts'
86
+
87
+ /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
88
+ export interface PiAiProviderProfile {
89
+ /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
90
+ apiKeyEnv?: string
91
+ /** Name shown by configuration surfaces; defaults to the route key. */
92
+ displayName?: string
93
+ /**
94
+ * Wire protocol every model on this route speaks. Omission keeps each
95
+ * installed catalog model's own protocol, which is why a catalog route needs
96
+ * no protocol at all; a route the catalog does not ship must name one.
97
+ */
98
+ api?: string
99
+ /** Endpoint for this route's models; defaults to the installed catalog's endpoint. */
100
+ baseURL?: string
101
+ /**
102
+ * This route's model catalog. Omission serves the installed catalog for the
103
+ * route unchanged; an explicit list replaces it, each entry defaulting its
104
+ * unset fields from the installed model of the same id.
105
+ */
106
+ models?: PiAiModelProfile[]
107
+ /**
108
+ * Installed-catalog customizations by model id: each entry reshapes that
109
+ * one model with the same fields a {@link models} entry takes, while the
110
+ * rest of the catalog keeps serving untouched. Only meaningful on a catalog
111
+ * route with no `models` list — `models` already replaces the catalog, so
112
+ * an override beside it, on a route the catalog does not ship, or naming a
113
+ * model the catalog does not describe is refused rather than skipped.
114
+ */
115
+ modelOverrides?: Record<string, PiAiModelOverride>
116
+ /**
117
+ * pi-ai wire-compatibility switches defaulting every model on this route
118
+ * whose protocol declares them; each model's own `compat` overrides per
119
+ * field. What neither sets keeps the installed catalog entry's value, then
120
+ * pi-ai's own detection. A switch no model on the route could read is
121
+ * refused rather than left looking applied.
122
+ */
123
+ compat?: PiAiCompatProfile
124
+ /**
125
+ * Context capacity for a model this route lists that neither the entry nor
126
+ * the installed catalog sizes (default 262,144). A guess by construction, so
127
+ * a deployment whose gateway serves smaller models corrects it here.
128
+ */
129
+ defaultContextWindow?: number
130
+ /**
131
+ * Output capability for a model this route lists that neither the entry nor
132
+ * the installed catalog sizes (default 32,768). This sizes the model; it
133
+ * never becomes a per-request cap on its own.
134
+ */
135
+ defaultMaxTokens?: number
136
+ /**
137
+ * Request modalities for a model this route lists that neither its entry's
138
+ * {@link PiAiModelProfile.input} nor the installed catalog declares (default
139
+ * `[text]`). A fallback like the capacities above, not an override: a
140
+ * catalog model keeps the modalities the catalog records for it, and this
141
+ * value never narrows one. A gateway serving vision models the catalog does
142
+ * not describe declares `[text, image]` once here instead of on every entry.
143
+ * Unlike an entry's list, this one may not be empty — nothing sits below it
144
+ * to answer instead.
145
+ */
146
+ defaultInput?: PiAiModality[]
147
+ /** Provider request headers; host attribution wins reserved names. */
148
+ headers?: Record<string, string>
149
+ /** Provider-neutral pi-ai reasoning level. */
150
+ reasoning?: ModelThinkingLevel
151
+ /** Send reasoning_split to an OpenAI Chat Completions gateway; omission leaves its response format unchanged. */
152
+ reasoningSplit?: boolean
153
+ /** Token budgets used by reasoning providers that support them. */
154
+ thinkingBudgets?: ThinkingBudgets
155
+ /** Prompt-cache retention preference. */
156
+ cacheRetention?: CacheRetention
157
+ /** Streaming transport preference. */
158
+ transport?: Transport
159
+ /** HTTP/provider SDK timeout in milliseconds. */
160
+ timeoutMs?: number
161
+ /** WebSocket connection timeout in milliseconds. */
162
+ websocketConnectTimeoutMs?: number
163
+ /** Maximum provider idle time while one stream read is outstanding. */
164
+ streamIdleTimeoutMs?: number
165
+ /**
166
+ * Maximum base64-encoded image payload per request. When a request's
167
+ * accumulated images exceed it, the oldest images are replaced by text
168
+ * placeholders until the request fits, so a long session keeps completing
169
+ * requests instead of being rejected by a request-size cap.
170
+ */
171
+ maxRequestImageBytes?: number
172
+ /** Total-pixel budget for each deterministic inline request version. */
173
+ requestImagePixelBudget?: number
174
+ /**
175
+ * Raw encoded-byte target for each deterministic inline request version;
176
+ * the smallest quality-ladder output is used when no quality fits.
177
+ */
178
+ requestImageMaxBytes?: number
179
+ /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
180
+ retryPolicy?: RetryPolicyConfig
181
+ }
182
+
183
+ /** Validated profile with its route stamped and every adapter-owned default resolved. */
184
+ export interface ResolvedPiAiProviderProfile
185
+ extends Omit<PiAiProviderProfile, 'apiKeyEnv' | 'retryPolicy' | 'models' | 'displayName'> {
186
+ /** ChatCode CLI route key and the `Models` collection key (the configuration dict key). */
187
+ provider: string
188
+ /** Resolved display name for selectors and configuration surfaces. */
189
+ displayName: string
190
+ /** Validated credential reference, when one is configured. */
191
+ apiKeyEnv?: CredentialRef
192
+ /** Positive finite provider-idle interval after defaulting. */
193
+ streamIdleTimeoutMs: number
194
+ /** Positive request-level base64 image payload bound after defaulting. */
195
+ maxRequestImageBytes: number
196
+ /** Positive total-pixel request-version budget after defaulting. */
197
+ requestImagePixelBudget: number
198
+ /** Positive raw request-version byte target after defaulting; the smallest quality-ladder output is used when no quality fits. */
199
+ requestImageMaxBytes: number
200
+ /** Immutable retry policy captured with this provider route. */
201
+ retryPolicy: ResolvedRetryPolicy
202
+ /**
203
+ * The pi-ai provider this route registers, built from the resolved models.
204
+ * Construction happens here so an unserviceable protocol or an underspecified
205
+ * model fails with the rest of resolution, leaving the last good route set
206
+ * serving requests.
207
+ */
208
+ piProvider: Provider
209
+ /**
210
+ * Per-request output caps this profile explicitly configured, by model id.
211
+ * The seam materializes one only into a request that names no cap of its
212
+ * own, so a catalog capability must not appear here.
213
+ */
214
+ configuredMaxTokens: ReadonlyMap<string, number>
215
+ }
216
+
217
+ /** Plugin configuration: the provider routes this instance owns. */
218
+ export interface Config {
219
+ /**
220
+ * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is
221
+ * the dormant settings-driven posture: the adapter mounts with no routes
222
+ * and registers them the moment a settings section supplies profiles.
223
+ */
224
+ providers?: Record<string, PiAiProviderProfile>
225
+ }
226
+
227
+ const thinkingBudgets = z.object({
228
+ minimal: z.number(),
229
+ low: z.number(),
230
+ medium: z.number(),
231
+ high: z.number(),
232
+ })
233
+
234
+ /**
235
+ * One `chat_template_kwargs` or `chat_template_args` value. The `$var` member
236
+ * is pi-ai's placeholder for a value dispatch fills from the request's
237
+ * thinking state, which makes a template-driven gateway configurable without
238
+ * restating its template.
239
+ */
240
+ const chatTemplateKwarg: z<ChatTemplateKwargValue> = z.union([
241
+ z.string(),
242
+ z.number(),
243
+ z.boolean(),
244
+ z.const(null),
245
+ z.object({
246
+ $var: z.union(CHAT_TEMPLATE_VARS).required(),
247
+ omitWhenOff: z.boolean(),
248
+ }),
249
+ ])
250
+
251
+ const compatProfile: z<PiAiCompatProfile> = z.object({
252
+ supportsStore: z.boolean(),
253
+ supportsDeveloperRole: z.boolean(),
254
+ supportsReasoningEffort: z.boolean(),
255
+ supportsUsageInStreaming: z.boolean(),
256
+ supportsFinishReason: z.boolean(),
257
+ maxTokensField: z.union(MAX_TOKENS_FIELDS),
258
+ requiresToolResultName: z.boolean(),
259
+ requiresAssistantAfterToolResult: z.boolean(),
260
+ requiresThinkingAsText: z.boolean(),
261
+ requiresReasoningContentOnAssistantMessages: z.boolean(),
262
+ thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS),
263
+ chatTemplateKwargs: z.dict(chatTemplateKwarg),
264
+ chatTemplateArgs: z.dict(chatTemplateKwarg),
265
+ supportsThinkingTokenBudget: z.boolean(),
266
+ supportsStrictMode: z.boolean(),
267
+ cacheControlFormat: z.union(CACHE_CONTROL_FORMATS),
268
+ supportsLongCacheRetention: z.boolean(),
269
+ supportsEagerToolInputStreaming: z.boolean(),
270
+ supportsCacheControlOnTools: z.boolean(),
271
+ supportsTemperature: z.boolean(),
272
+ forceAdaptiveThinking: z.boolean(),
273
+ allowEmptySignature: z.boolean(),
274
+ supportsStrictTools: z.boolean(),
275
+ })
276
+
277
+ /**
278
+ * Keys are the offered levels, values their wire spellings. A valueless key
279
+ * (`off:`) survives validation because schemastery passes nullable data
280
+ * through before any member schema runs — `z.const(null)` only controls the
281
+ * error for non-null wrong values and what a configuration UI renders.
282
+ * Only resolution decides which levels may leave the value empty, so the
283
+ * diagnostic can name the route and model. The assertion narrows
284
+ * schemastery's `Dict`, which types every literal key as required; dict
285
+ * validation checks only present keys, so the runtime value is a partial record.
286
+ */
287
+ const reasoningEfforts = z.dict(
288
+ z.union([z.string(), z.const(null)]),
289
+ z.union(THINKING_LEVELS),
290
+ ) as unknown as z<PiAiReasoningEfforts>
291
+
292
+ /** The fields a `models` entry and a `modelOverrides` value share; only the id's home differs. */
293
+ const modelFields = {
294
+ name: z.string(),
295
+ contextWindow: z.number().step(1).min(1),
296
+ maxTokens: z.number().step(1).min(1),
297
+ // No explicit default, unlike the route's `defaultInput`: schemastery
298
+ // materializes `[]` for an absent array, and resolution reads that as "no
299
+ // answer here" so the catalog entry below still applies.
300
+ input: z.array(z.union(MODALITIES)),
301
+ // The union, not a bare dict: schemastery materializes an absent dict as
302
+ // `{}`, and absent must stay distinguishable — it means "inherit the
303
+ // installed catalog's capability", while `false` disables reasoning.
304
+ reasoningEfforts: z.union([z.const(false), reasoningEfforts]),
305
+ compat: compatProfile,
306
+ }
307
+
308
+ const modelProfile: z<PiAiModelProfile> = z.object({
309
+ id: z.string().required(),
310
+ ...modelFields,
311
+ })
312
+
313
+ /** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */
314
+ const modelOverride: z<PiAiModelOverride> = z.object(modelFields)
315
+
316
+ const profile = z.object({
317
+ apiKeyEnv: z.string().role('credential-ref'),
318
+ displayName: z.string(),
319
+ api: z.union(supportedProtocols()),
320
+ baseURL: z.string(),
321
+ models: z.array(modelProfile),
322
+ modelOverrides: z.dict(modelOverride),
323
+ compat: compatProfile,
324
+ defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
325
+ defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
326
+ defaultInput: z.array(z.union(MODALITIES)).default([...DEFAULT_INPUT]),
327
+ headers: z.dict(z.string()),
328
+ reasoning: z.union(THINKING_LEVELS),
329
+ reasoningSplit: z.boolean(),
330
+ thinkingBudgets,
331
+ cacheRetention: z.union(['none', 'short', 'long']),
332
+ transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']),
333
+ timeoutMs: z.natural(),
334
+ websocketConnectTimeoutMs: z.natural(),
335
+ streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
336
+ maxRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES),
337
+ requestImagePixelBudget: z.number().step(1).min(1).default(DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET),
338
+ requestImageMaxBytes: z.number().step(1).min(1).default(DEFAULT_REQUEST_IMAGE_MAX_BYTES),
339
+ retryPolicy: RetryPolicySchema,
340
+ })
341
+
342
+ /** Runtime schema for {@link Config}. */
343
+ export const Config: z<Config> = z.object({
344
+ providers: z.dict(profile).default({}),
345
+ })
346
+
347
+ /**
348
+ * Reject a section this adapter could not serve. Registered as the settings
349
+ * namespace's validator, so an unserviceable profile is refused where it is
350
+ * *written* — `settings.mutate` answers `settings-rejected` with the offending
351
+ * route and model named — instead of being stored and then quietly disabling
352
+ * every route in the namespace. It stays a validator rather than a schema
353
+ * transform because the schema is also the shape a configuration surface
354
+ * renders and the value an absent section resolves to; wrapping it would break
355
+ * both.
356
+ * @param config - the resolved section to check.
357
+ * @throws Error naming the route and model that cannot be served.
358
+ */
359
+ export function assertServiceable(config: Config): void {
360
+ resolveProfiles(config.providers)
361
+ }
362
+
363
+ /** Reject removed pre-release profile fields and name their replacements. */
364
+ function rejectRemovedFields(provider: string, source: PiAiProviderProfile): void {
365
+ const legacy = source as PiAiProviderProfile & {
366
+ provider?: unknown
367
+ maxRetries?: unknown
368
+ maxRetryDelayMs?: unknown
369
+ }
370
+ if ('provider' in legacy) {
371
+ throw new Error(`llm-pi-ai: provider "${provider}" sets "provider", which moved to the providers dict key`)
372
+ }
373
+ if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) {
374
+ throw new Error(
375
+ `llm-pi-ai: provider "${provider}" sets maxRetries or maxRetryDelayMs, which were removed;`
376
+ + ' compose agent recovery with dsh-llm-retry',
377
+ )
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Validate profiles and return a detached route-keyed map suitable for
383
+ * per-request reads. This is the one explicit resolve step, so an omitted dict
384
+ * resolves to the empty (dormant) route set here rather than through a hidden
385
+ * fallback, and each route's models and pi-ai provider are materialized once.
386
+ * @param providers - configured provider profiles keyed by route.
387
+ * @returns validated profiles in configuration order.
388
+ */
389
+ export function resolveProfiles(
390
+ providers: Readonly<Record<string, PiAiProviderProfile>> | undefined,
391
+ ): Map<string, ResolvedPiAiProviderProfile> {
392
+ if (Array.isArray(providers)) {
393
+ throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles')
394
+ }
395
+ const entries = Object.entries(providers ?? {})
396
+ const resolved = new Map<string, ResolvedPiAiProviderProfile>()
397
+ for (const [provider, source] of entries) {
398
+ rejectRemovedFields(provider, source)
399
+ if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
400
+ if (source.baseURL !== undefined && source.baseURL.length === 0) {
401
+ throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`)
402
+ }
403
+ if (source.displayName !== undefined && source.displayName.length === 0) {
404
+ throw new Error(`llm-pi-ai: provider "${provider}" has an empty displayName`)
405
+ }
406
+ const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
407
+ if (!Number.isFinite(streamIdleTimeoutMs)
408
+ || streamIdleTimeoutMs <= 0
409
+ || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
410
+ throw new Error(
411
+ `llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
412
+ )
413
+ }
414
+ const maxRequestImageBytes = source.maxRequestImageBytes ?? DEFAULT_MAX_REQUEST_IMAGE_BYTES
415
+ if (!Number.isInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) {
416
+ throw new Error(`llm-pi-ai: provider "${provider}" maxRequestImageBytes must be a positive integer`)
417
+ }
418
+ const requestImagePixelBudget = source.requestImagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET
419
+ if (!Number.isSafeInteger(requestImagePixelBudget) || requestImagePixelBudget <= 0) {
420
+ throw new Error(`llm-pi-ai: provider "${provider}" requestImagePixelBudget must be a positive safe integer`)
421
+ }
422
+ const requestImageMaxBytes = source.requestImageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES
423
+ if (!Number.isSafeInteger(requestImageMaxBytes) || requestImageMaxBytes <= 0) {
424
+ throw new Error(`llm-pi-ai: provider "${provider}" requestImageMaxBytes must be a positive safe integer`)
425
+ }
426
+ // Detached from the configuration object because pi-ai types `Model.input`
427
+ // mutable. The schema's explicit default covers an absent key, so an empty
428
+ // list here is always one someone typed — and unlike an entry's, nothing
429
+ // below it can answer instead — so it is refused rather than read as "no
430
+ // answer".
431
+ const defaultInput = [...source.defaultInput ?? DEFAULT_INPUT]
432
+ if (defaultInput.length === 0) {
433
+ throw new Error(`llm-pi-ai: provider "${provider}" defaultInput must name at least one modality`)
434
+ }
435
+ // The route key, not the installed provider's own name: the directory has
436
+ // always shown route keys, and a catalog route must not silently rename
437
+ // itself on every configuration surface just because it gained a profile.
438
+ const displayName = source.displayName ?? provider
439
+ const catalog = resolveRouteModels({
440
+ provider,
441
+ ...source.api === undefined ? {} : { api: source.api },
442
+ ...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
443
+ ...source.models === undefined ? {} : { models: source.models },
444
+ ...source.modelOverrides === undefined ? {} : { modelOverrides: source.modelOverrides },
445
+ ...source.compat === undefined ? {} : { compat: source.compat },
446
+ defaultInput,
447
+ defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
448
+ defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS,
449
+ })
450
+ if (source.reasoningSplit !== undefined && catalog.models.some(model => model.api !== 'openai-completions')) {
451
+ throw new Error(`llm-pi-ai: provider "${provider}" reasoningSplit requires every model to use openai-completions`)
452
+ }
453
+ const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source
454
+ resolved.set(provider, {
455
+ ...rest,
456
+ provider,
457
+ displayName,
458
+ ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
459
+ streamIdleTimeoutMs,
460
+ maxRequestImageBytes,
461
+ requestImagePixelBudget,
462
+ requestImageMaxBytes,
463
+ retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
464
+ ...rest.headers === undefined ? {} : { headers: { ...rest.headers } },
465
+ ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } },
466
+ configuredMaxTokens: catalog.configuredMaxTokens,
467
+ piProvider: buildProvider({
468
+ provider,
469
+ displayName,
470
+ ...source.api === undefined ? {} : { api: source.api },
471
+ ...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
472
+ models: catalog.models,
473
+ namesCredential: apiKeyEnv !== undefined,
474
+ }),
475
+ })
476
+ }
477
+ return resolved
478
+ }