@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,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Construction of the pi-ai `Provider` that one configured route registers into
|
|
3
|
+
* the adapter's `Models` collection.
|
|
4
|
+
*
|
|
5
|
+
* Two constructions, one decision: a route the installed catalog ships, whose
|
|
6
|
+
* profile does not override the wire protocol, **reuses that catalog provider**
|
|
7
|
+
* with its models replaced — the catalog provider owns API implementations this
|
|
8
|
+
* package cannot reconstruct (Bedrock loads its Smithy module through a
|
|
9
|
+
* separate entry point), so rebuilding it from parts would silently narrow
|
|
10
|
+
* which providers work. Every other route — one pi-ai has never heard of, or a
|
|
11
|
+
* catalog route pointed at a different protocol — is built by `createProvider`
|
|
12
|
+
* over the protocol table below.
|
|
13
|
+
*
|
|
14
|
+
* Credentials never reach this module's storage: the harness resolves a route's
|
|
15
|
+
* key through `ctx.credentials` before the request enters pi-ai and hands it
|
|
16
|
+
* over as a stream option, which `Models` presents to `resolve()` as the
|
|
17
|
+
* credential key.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-llm-pi-ai/provider
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createProvider } from '@earendil-works/pi-ai'
|
|
23
|
+
import type { Api, ApiKeyAuth, Model, Provider, ProviderStreams } from '@earendil-works/pi-ai'
|
|
24
|
+
import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy'
|
|
25
|
+
import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
|
|
26
|
+
import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy'
|
|
27
|
+
import { catalogProvider } from './catalog.ts'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wire protocols a configured route may name, mapped to pi-ai's lazily loaded
|
|
31
|
+
* implementations. Each entry is the factory that pi-ai's matching provider
|
|
32
|
+
* factory uses, so a hand-declared route reaches exactly the implementation a
|
|
33
|
+
* catalog route would.
|
|
34
|
+
*
|
|
35
|
+
* The table is deliberately narrow: the protocols a hand-declared route
|
|
36
|
+
* actually reads, each completely describable with a key, an
|
|
37
|
+
* endpoint, and headers. Bedrock signs with SigV4 over AWS credentials and a
|
|
38
|
+
* region, Vertex needs a project, a location, and application-default
|
|
39
|
+
* credentials, Azure needs provider environment plus an api-version, and Codex
|
|
40
|
+
* authenticates through OAuth — none of which this configuration shape can
|
|
41
|
+
* express, so offering them would hand back a provider that cannot
|
|
42
|
+
* authenticate. The remainder are absent for want of a consumer rather than a
|
|
43
|
+
* blocker: each is one line here once a deployment needs it. Catalog routes
|
|
44
|
+
* still reach every protocol through their own provider; only an explicit
|
|
45
|
+
* override is refused.
|
|
46
|
+
*/
|
|
47
|
+
const PROTOCOLS: Readonly<Record<string, () => ProviderStreams>> = {
|
|
48
|
+
'openai-completions': openAICompletionsApi,
|
|
49
|
+
'openai-responses': openAIResponsesApi,
|
|
50
|
+
'anthropic-messages': anthropicMessagesApi,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every wire protocol a configured route may name, most-reached first. The
|
|
55
|
+
* order is the table's and therefore stable; a configuration surface offering
|
|
56
|
+
* a choice presents the first as its default, which is why the protocol a
|
|
57
|
+
* hand-declared gateway most often speaks — and the one endpoint interrogation
|
|
58
|
+
* can read — leads.
|
|
59
|
+
* @returns the supported protocol identifiers.
|
|
60
|
+
*/
|
|
61
|
+
export function supportedProtocols(): readonly string[] {
|
|
62
|
+
return Object.keys(PROTOCOLS)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Api-key auth for a route the harness authenticates itself. `Models` calls
|
|
67
|
+
* this after the adapter has already resolved the route's credential, so a
|
|
68
|
+
* missing key here is not this layer's failure: a named-but-unresolvable
|
|
69
|
+
* reference has already failed the request with `MISSING_CREDENTIAL`, and a
|
|
70
|
+
* route naming no credential at all is deliberately unauthenticated. Reporting
|
|
71
|
+
* it as configured hands the decision to the protocol, which is where the
|
|
72
|
+
* requirement actually lives — pi-ai's OpenAI-compatible implementation, for
|
|
73
|
+
* one, still insists on a key or an `Authorization` header of its own.
|
|
74
|
+
* @param name - display name used as the resolution's status label.
|
|
75
|
+
* @returns the api-key auth for a harness-authenticated route.
|
|
76
|
+
*/
|
|
77
|
+
function harnessApiKeyAuth(name: string): ApiKeyAuth {
|
|
78
|
+
return {
|
|
79
|
+
name,
|
|
80
|
+
resolve: ({ credential }) => Promise.resolve({
|
|
81
|
+
auth: credential?.key === undefined ? {} : { apiKey: credential.key },
|
|
82
|
+
source: name,
|
|
83
|
+
}),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The resolved route facts provider construction reads. */
|
|
88
|
+
export interface ProviderSpec {
|
|
89
|
+
/** Provider route key; also the `Models` collection key and each model's `provider`. */
|
|
90
|
+
provider: string
|
|
91
|
+
/** Display name for selectors and status labels. */
|
|
92
|
+
displayName: string
|
|
93
|
+
/** Wire protocol override; absent means each model keeps its catalog protocol. */
|
|
94
|
+
api?: string
|
|
95
|
+
/** Endpoint override already applied to {@link models}; kept for provider-level display. */
|
|
96
|
+
baseURL?: string
|
|
97
|
+
/** The route's materialized models, in configuration order. */
|
|
98
|
+
models: readonly Model<Api>[]
|
|
99
|
+
/**
|
|
100
|
+
* Whether the profile names a credential, which it does through `apiKeyEnv`
|
|
101
|
+
* alone: configuration carries the reference, never the secret. Only that
|
|
102
|
+
* decides whether {@link routeAuth} adds the harness's own api-key method to
|
|
103
|
+
* a catalog provider that offers none; the key itself still arrives per
|
|
104
|
+
* request, never at construction.
|
|
105
|
+
*/
|
|
106
|
+
namesCredential: boolean
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The auth one route resolves its credential through.
|
|
111
|
+
*
|
|
112
|
+
* A catalog route keeps the installed provider's own auth, which is what
|
|
113
|
+
* preserves provider-native ambient discovery for a profile naming no
|
|
114
|
+
* credential. That holds even when the profile repoints the protocol: which
|
|
115
|
+
* environment a provider reads is a property of the provider, not of the wire
|
|
116
|
+
* format its models speak.
|
|
117
|
+
*
|
|
118
|
+
* The single addition covers a catalog provider that offers no api-key method
|
|
119
|
+
* at all. pi-ai resolves a request's `apiKey` override only when the provider
|
|
120
|
+
* declares one (`resolveProviderAuth` checks `provider.auth.apiKey` before
|
|
121
|
+
* honouring the override), so an OAuth-only provider — `openai-codex` is the
|
|
122
|
+
* one the installed catalog ships — would refuse a profile's explicit key with
|
|
123
|
+
* `Provider is not configured` before any request went out. Adding the harness
|
|
124
|
+
* method beside the provider's own restores that route. A keyless profile adds
|
|
125
|
+
* nothing and still reports the honest refusal, because this adapter resolves
|
|
126
|
+
* credentials through its own seam and holds no OAuth store to fall back on.
|
|
127
|
+
* @param spec - the resolved route facts.
|
|
128
|
+
* @param catalog - the installed catalog provider, when pi-ai ships one.
|
|
129
|
+
* @returns the auth to construct this route's provider with.
|
|
130
|
+
*/
|
|
131
|
+
function routeAuth(spec: ProviderSpec, catalog: Provider | undefined): Provider['auth'] {
|
|
132
|
+
if (catalog === undefined) return { apiKey: harnessApiKeyAuth(spec.displayName) }
|
|
133
|
+
if (catalog.auth.apiKey !== undefined || !spec.namesCredential) return catalog.auth
|
|
134
|
+
return { ...catalog.auth, apiKey: harnessApiKeyAuth(spec.displayName) }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Reuse an installed catalog provider with this route's models and identity.
|
|
139
|
+
* Model dispatch stays with the catalog provider, so its API implementations,
|
|
140
|
+
* compatibility quirks, and ambient credential discovery are preserved exactly.
|
|
141
|
+
* Catalog-owned dynamic refresh is dropped: this route's catalog is the
|
|
142
|
+
* settings document, and a background refresh would contradict it.
|
|
143
|
+
*/
|
|
144
|
+
function reuseCatalogProvider(base: Provider, spec: ProviderSpec): Provider {
|
|
145
|
+
// Provider-level `baseUrl` is display metadata: pi-ai routes every request
|
|
146
|
+
// through `Model.baseUrl`, which model resolution has already overridden.
|
|
147
|
+
const baseUrl = spec.baseURL ?? base.baseUrl
|
|
148
|
+
return {
|
|
149
|
+
id: spec.provider,
|
|
150
|
+
name: spec.displayName,
|
|
151
|
+
...baseUrl === undefined ? {} : { baseUrl },
|
|
152
|
+
auth: routeAuth(spec, base),
|
|
153
|
+
getModels: () => spec.models,
|
|
154
|
+
// Delegated rather than copied: the catalog provider stays the receiver, so
|
|
155
|
+
// an implementation holding state on itself keeps working.
|
|
156
|
+
stream: (model, context, options) => base.stream(model, context, options),
|
|
157
|
+
streamSimple: (model, context, options) => base.streamSimple(model, context, options),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Build the pi-ai provider for one resolved route.
|
|
163
|
+
* @param spec - the resolved route facts.
|
|
164
|
+
* @returns the provider to register in the adapter's `Models` collection.
|
|
165
|
+
* @throws Error when the route names a wire protocol this build cannot serve.
|
|
166
|
+
*/
|
|
167
|
+
export function buildProvider(spec: ProviderSpec): Provider {
|
|
168
|
+
const catalog = catalogProvider(spec.provider)
|
|
169
|
+
// A catalog route keeping its catalog protocol reuses the catalog provider;
|
|
170
|
+
// an explicit protocol means the deployment is repointing the route at a
|
|
171
|
+
// different wire format, which only the protocol table can serve.
|
|
172
|
+
if (catalog !== undefined && spec.api === undefined) return reuseCatalogProvider(catalog, spec)
|
|
173
|
+
|
|
174
|
+
// Every model on this path carries the route's protocol: model resolution
|
|
175
|
+
// requires one for a route the catalog cannot default, and an explicit one
|
|
176
|
+
// replaces each catalog model's own. So the route has a single API.
|
|
177
|
+
const factory = spec.api === undefined ? undefined : PROTOCOLS[spec.api]
|
|
178
|
+
if (factory === undefined) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`llm-pi-ai: provider "${spec.provider}" names api "${spec.api}", which this build cannot serve;`
|
|
181
|
+
+ ` supported protocols are ${supportedProtocols().join(', ')}`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
return createProvider({
|
|
185
|
+
id: spec.provider,
|
|
186
|
+
name: spec.displayName,
|
|
187
|
+
...spec.baseURL === undefined ? {} : { baseUrl: spec.baseURL },
|
|
188
|
+
auth: routeAuth(spec, catalog),
|
|
189
|
+
models: spec.models,
|
|
190
|
+
api: factory(),
|
|
191
|
+
})
|
|
192
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable pi-ai replay metadata and assistant-history reconstruction.
|
|
3
|
+
*
|
|
4
|
+
* ChatCode CLI content remains the durable source for text and tool calls. This
|
|
5
|
+
* module stores only the provider-native metadata needed to reconstruct a
|
|
6
|
+
* pi-ai assistant message on a later request.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-llm-pi-ai/replay
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { LlmError } from '@deepseek-ai/dsh-llm'
|
|
12
|
+
import type { Message, ModelMessageSource, ReplayEnvelope } from '@deepseek-ai/dsh-llm'
|
|
13
|
+
import type { Api, AssistantMessage, Usage as PiUsage } from '@earendil-works/pi-ai'
|
|
14
|
+
|
|
15
|
+
/** Per-block half of the pi-ai replay envelope, one entry per content block. */
|
|
16
|
+
export type PiAiReplayBlock =
|
|
17
|
+
| { type: 'text'; textSignature?: string }
|
|
18
|
+
| { type: 'reasoning'; thinkingSignature?: string; redacted?: boolean }
|
|
19
|
+
| { type: 'tool-call'; thoughtSignature?: string }
|
|
20
|
+
|
|
21
|
+
/** Versioned response-level half of the pi-ai replay envelope. */
|
|
22
|
+
export interface PiAiReplayResponse {
|
|
23
|
+
kind: 'pi-ai'
|
|
24
|
+
version: 2
|
|
25
|
+
api: Api
|
|
26
|
+
provider: string
|
|
27
|
+
model: string
|
|
28
|
+
responseModel?: string
|
|
29
|
+
responseId?: string
|
|
30
|
+
stopReason: AssistantMessage['stopReason']
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The validated halves of one pi-ai replay envelope. */
|
|
34
|
+
interface PiAiReplayState {
|
|
35
|
+
response: PiAiReplayResponse
|
|
36
|
+
blocks: PiAiReplayBlock[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Parse tool-call argument JSON; tolerate model malformations with {}. */
|
|
40
|
+
function parseArguments(raw: string): Record<string, unknown> {
|
|
41
|
+
try {
|
|
42
|
+
const parsed: unknown = JSON.parse(raw)
|
|
43
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
44
|
+
return parsed as Record<string, unknown>
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// fall through
|
|
48
|
+
}
|
|
49
|
+
return {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Construct the zero usage value required by historical pi-ai messages. */
|
|
53
|
+
function emptyPiUsage(): PiUsage {
|
|
54
|
+
return {
|
|
55
|
+
input: 0,
|
|
56
|
+
output: 0,
|
|
57
|
+
cacheRead: 0,
|
|
58
|
+
cacheWrite: 0,
|
|
59
|
+
totalTokens: 0,
|
|
60
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Project a successful pi-ai response into the minimal durable replay state.
|
|
66
|
+
* The per-block half is index-aligned with the streamed blocks (pi-ai content
|
|
67
|
+
* order), so `BlockAssembler` prunes an entry with its block whenever assembly
|
|
68
|
+
* removes one.
|
|
69
|
+
* @param message - completed native pi-ai assistant response.
|
|
70
|
+
* @returns the versioned lossless-JSON replay projection.
|
|
71
|
+
*/
|
|
72
|
+
export function toPiReplayState(message: AssistantMessage): ReplayEnvelope {
|
|
73
|
+
const response: PiAiReplayResponse = {
|
|
74
|
+
kind: 'pi-ai',
|
|
75
|
+
version: 2,
|
|
76
|
+
api: message.api,
|
|
77
|
+
provider: message.provider,
|
|
78
|
+
model: message.model,
|
|
79
|
+
...message.responseModel === undefined ? {} : { responseModel: message.responseModel },
|
|
80
|
+
...message.responseId === undefined ? {} : { responseId: message.responseId },
|
|
81
|
+
stopReason: message.stopReason,
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
response,
|
|
85
|
+
blocks: message.content.map((block): PiAiReplayBlock => {
|
|
86
|
+
switch (block.type) {
|
|
87
|
+
case 'text': return {
|
|
88
|
+
type: 'text',
|
|
89
|
+
...block.textSignature === undefined ? {} : { textSignature: block.textSignature },
|
|
90
|
+
}
|
|
91
|
+
case 'thinking': return {
|
|
92
|
+
type: 'reasoning',
|
|
93
|
+
...block.thinkingSignature === undefined ? {} : { thinkingSignature: block.thinkingSignature },
|
|
94
|
+
...block.redacted === undefined ? {} : { redacted: block.redacted },
|
|
95
|
+
}
|
|
96
|
+
case 'toolCall': return {
|
|
97
|
+
type: 'tool-call',
|
|
98
|
+
...block.thoughtSignature === undefined ? {} : { thoughtSignature: block.thoughtSignature },
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function invalidReplay(message: string): never {
|
|
106
|
+
throw new LlmError(`invalid pi-ai replay state: ${message}`, 'INVALID_REPLAY_STATE')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Validate the durable adapter-private envelope before it reaches pi-ai. */
|
|
110
|
+
function readReplayState(value: unknown): PiAiReplayState {
|
|
111
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidReplay('expected a replay envelope')
|
|
112
|
+
const envelope = value as Record<string, unknown>
|
|
113
|
+
const rawResponse = envelope['response']
|
|
114
|
+
if (typeof rawResponse !== 'object' || rawResponse === null || Array.isArray(rawResponse)) return invalidReplay('expected a response object')
|
|
115
|
+
const response = rawResponse as Record<string, unknown>
|
|
116
|
+
if (response['kind'] !== 'pi-ai') return invalidReplay('unknown state kind')
|
|
117
|
+
if (response['version'] !== 2) return invalidReplay(`unsupported version ${String(response['version'])}`)
|
|
118
|
+
for (const key of ['api', 'provider', 'model'] as const) {
|
|
119
|
+
if (typeof response[key] !== 'string' || response[key].length === 0) return invalidReplay(`${key} must be a non-empty string`)
|
|
120
|
+
}
|
|
121
|
+
if (!['stop', 'length', 'toolUse', 'error', 'aborted'].includes(String(response['stopReason']))) {
|
|
122
|
+
return invalidReplay('unknown stopReason')
|
|
123
|
+
}
|
|
124
|
+
if (response['responseModel'] !== undefined && typeof response['responseModel'] !== 'string') return invalidReplay('responseModel must be a string')
|
|
125
|
+
if (response['responseId'] !== undefined && typeof response['responseId'] !== 'string') return invalidReplay('responseId must be a string')
|
|
126
|
+
const blocks = envelope['blocks']
|
|
127
|
+
if (!Array.isArray(blocks)) return invalidReplay('blocks must be an array')
|
|
128
|
+
for (const [index, value] of blocks.entries()) {
|
|
129
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidReplay(`block ${index} must be an object`)
|
|
130
|
+
const block = value as Record<string, unknown>
|
|
131
|
+
if (!['text', 'reasoning', 'tool-call'].includes(String(block['type']))) return invalidReplay(`block ${index} has an unknown type`)
|
|
132
|
+
for (const signature of ['textSignature', 'thinkingSignature', 'thoughtSignature'] as const) {
|
|
133
|
+
if (block[signature] !== undefined && typeof block[signature] !== 'string') return invalidReplay(`block ${index} ${signature} must be a string`)
|
|
134
|
+
}
|
|
135
|
+
if (block['redacted'] !== undefined && typeof block['redacted'] !== 'boolean') return invalidReplay(`block ${index} redacted must be boolean`)
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
response: response as unknown as PiAiReplayResponse,
|
|
139
|
+
blocks: blocks as PiAiReplayBlock[],
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Convert provider-neutral blocks without trusting them as same-model replay. */
|
|
144
|
+
function foreignAssistant(message: Message): AssistantMessage {
|
|
145
|
+
const source = message.source.kind === 'model' ? message.source : undefined
|
|
146
|
+
const content: AssistantMessage['content'] = []
|
|
147
|
+
for (const block of message.content) {
|
|
148
|
+
switch (block.type) {
|
|
149
|
+
case 'text': content.push({ type: 'text', text: block.text }); break
|
|
150
|
+
case 'reasoning': content.push({ type: 'thinking', thinking: block.text }); break
|
|
151
|
+
case 'tool-call': content.push({
|
|
152
|
+
type: 'toolCall',
|
|
153
|
+
id: block.id,
|
|
154
|
+
name: block.name,
|
|
155
|
+
arguments: parseArguments(block.arguments),
|
|
156
|
+
}); break
|
|
157
|
+
case 'image':
|
|
158
|
+
throw new LlmError('pi-ai chat history cannot represent structured assistant image output', 'UNSUPPORTED_CONTENT')
|
|
159
|
+
default:
|
|
160
|
+
// plugin-added block types are not representable in pi-ai.
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
role: 'assistant',
|
|
166
|
+
content,
|
|
167
|
+
// Deliberately never equals a catalog API: absent replay state is foreign
|
|
168
|
+
// even if source names the same provider/model as this request.
|
|
169
|
+
api: 'dsh-foreign',
|
|
170
|
+
provider: source?.provider ?? 'dsh-foreign',
|
|
171
|
+
model: source?.model ?? 'dsh-foreign',
|
|
172
|
+
usage: emptyPiUsage(),
|
|
173
|
+
stopReason: content.some(piece => piece.type === 'toolCall') ? 'toolUse' : 'stop',
|
|
174
|
+
timestamp: 0,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Recombine durable ChatCode CLI content with validated pi-ai replay metadata. */
|
|
179
|
+
function replayedAssistant(message: Message, source: ModelMessageSource, rawState: unknown): AssistantMessage {
|
|
180
|
+
const state = readReplayState(rawState)
|
|
181
|
+
if (state.response.provider !== source.provider) return invalidReplay('provider does not match assistant source')
|
|
182
|
+
if (state.response.model !== source.model) return invalidReplay('model does not match assistant source')
|
|
183
|
+
if (state.blocks.length !== message.content.length) return invalidReplay('block count does not match assistant content')
|
|
184
|
+
const content: AssistantMessage['content'] = message.content.map((block, index) => {
|
|
185
|
+
const replay = state.blocks[index]
|
|
186
|
+
if (replay === undefined || replay.type !== block.type) return invalidReplay(`block ${index} does not match assistant content`)
|
|
187
|
+
switch (block.type) {
|
|
188
|
+
case 'text': return {
|
|
189
|
+
type: 'text',
|
|
190
|
+
text: block.text,
|
|
191
|
+
...replay.type === 'text' && replay.textSignature !== undefined ? { textSignature: replay.textSignature } : {},
|
|
192
|
+
}
|
|
193
|
+
case 'reasoning': return {
|
|
194
|
+
type: 'thinking',
|
|
195
|
+
thinking: block.text,
|
|
196
|
+
...replay.type === 'reasoning' && replay.thinkingSignature !== undefined ? { thinkingSignature: replay.thinkingSignature } : {},
|
|
197
|
+
...replay.type === 'reasoning' && replay.redacted !== undefined ? { redacted: replay.redacted } : {},
|
|
198
|
+
}
|
|
199
|
+
case 'tool-call': return {
|
|
200
|
+
type: 'toolCall',
|
|
201
|
+
id: block.id,
|
|
202
|
+
name: block.name,
|
|
203
|
+
arguments: parseArguments(block.arguments),
|
|
204
|
+
...replay.type === 'tool-call' && replay.thoughtSignature !== undefined ? { thoughtSignature: replay.thoughtSignature } : {},
|
|
205
|
+
}
|
|
206
|
+
/* v8 ignore next -- readReplayState rejects unknown replay tags, so an equal plugin-added ChatCode CLI tag cannot reach this switch */
|
|
207
|
+
default: return invalidReplay(`block ${index} has an unsupported ChatCode CLI type`)
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
return {
|
|
211
|
+
role: 'assistant',
|
|
212
|
+
content,
|
|
213
|
+
api: state.response.api,
|
|
214
|
+
provider: state.response.provider,
|
|
215
|
+
model: state.response.model,
|
|
216
|
+
...state.response.responseModel === undefined ? {} : { responseModel: state.response.responseModel },
|
|
217
|
+
...state.response.responseId === undefined ? {} : { responseId: state.response.responseId },
|
|
218
|
+
usage: emptyPiUsage(),
|
|
219
|
+
stopReason: state.response.stopReason,
|
|
220
|
+
timestamp: 0,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Convert one durable ChatCode CLI assistant message into pi-ai history.
|
|
226
|
+
*
|
|
227
|
+
* Durable content is the authoritative record; replay metadata only restores
|
|
228
|
+
* native fidelity (ids, signatures). A replay state this build cannot use —
|
|
229
|
+
* another adapter's kind, another version, a malformed value, or metadata that
|
|
230
|
+
* no longer matches the content — therefore degrades the one message to
|
|
231
|
+
* provider-neutral history instead of failing the request.
|
|
232
|
+
* @param message - assistant content with required source and optional adapter-owned replay metadata.
|
|
233
|
+
* @param onDegrade - called with the diagnostic reason when an unusable replay
|
|
234
|
+
* state falls back to provider-neutral conversion.
|
|
235
|
+
* @returns a native pi-ai assistant message reconstructed from durable content.
|
|
236
|
+
*/
|
|
237
|
+
export function toPiAssistant(message: Message, onDegrade?: (reason: string) => void): AssistantMessage {
|
|
238
|
+
const source = message.source
|
|
239
|
+
if (source.kind !== 'model' || source.replayState === undefined) return foreignAssistant(message)
|
|
240
|
+
try {
|
|
241
|
+
return replayedAssistant(message, source, source.replayState)
|
|
242
|
+
} catch (error: unknown) {
|
|
243
|
+
/* v8 ignore next -- replayedAssistant throws only INVALID_REPLAY_STATE LlmErrors; the
|
|
244
|
+
guard keeps a future non-replay failure loud instead of silently degrading it */
|
|
245
|
+
if (!(error instanceof LlmError) || error.code !== 'INVALID_REPLAY_STATE') throw error
|
|
246
|
+
onDegrade?.(error.message)
|
|
247
|
+
return foreignAssistant(message)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-ai assistant event translation into the ChatCode CLI streaming protocol.
|
|
3
|
+
*
|
|
4
|
+
* pi-ai tool-call arguments are parsed objects while ChatCode CLI keeps their
|
|
5
|
+
* raw JSON representation. pi-ai also reports failures as terminal stream
|
|
6
|
+
* events, which this module maps into ChatCode CLI finish chunks.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-llm-pi-ai/stream
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { brandString } from '@deepseek-ai/dsh-brand'
|
|
12
|
+
import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
|
|
13
|
+
import type { FinishReason, StreamChunk, TokenUsage, ToolCallId } from '@deepseek-ai/dsh-llm'
|
|
14
|
+
import { isContextOverflow } from '@earendil-works/pi-ai'
|
|
15
|
+
import type { AssistantMessage, AssistantMessageEvent, Usage as PiUsage } from '@earendil-works/pi-ai'
|
|
16
|
+
import { toPiReplayState } from './replay.ts'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Map pi-ai usage (reasoning folded into output by pi-ai).
|
|
20
|
+
* @param usage - cumulative usage from the terminal pi-ai event.
|
|
21
|
+
* @returns harness counts with pi-ai's exact total; cache fields appear only
|
|
22
|
+
* when non-zero (pi-ai reports zeros, not absence).
|
|
23
|
+
*/
|
|
24
|
+
export function mapUsage(usage: PiUsage): TokenUsage {
|
|
25
|
+
return {
|
|
26
|
+
inputTokens: usage.input,
|
|
27
|
+
outputTokens: usage.output,
|
|
28
|
+
totalTokens: usage.totalTokens,
|
|
29
|
+
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
|
|
30
|
+
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// XXX(pi-ai upstream): pi-ai flattens the caught error to `error.message`
|
|
35
|
+
// (api/anthropic-messages.js: `errorMessage = error instanceof Error ?
|
|
36
|
+
// error.message : JSON.stringify(error)`), discarding the original Error and its
|
|
37
|
+
// `cause` chain before it reaches us. undici carries the actionable transport
|
|
38
|
+
// detail on `cause` (e.g. `SocketError: other side closed`) but hands the fetch
|
|
39
|
+
// wrapper a bare `terminated`, so we are left pattern-matching terse words here.
|
|
40
|
+
// If pi-ai ever forwards the original Error (or a fetch/dispatcher hook that lets
|
|
41
|
+
// us capture the cause ourselves), classify on `code`/`cause` instead of text.
|
|
42
|
+
function classifyPiAiError(message: string): string {
|
|
43
|
+
if (/\b(?:401|403)\b/.test(message)) return 'AUTH'
|
|
44
|
+
if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE
|
|
45
|
+
if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT'
|
|
46
|
+
// A rejected request body (gateway or provider size cap): resending the
|
|
47
|
+
// same request cannot succeed, so it is invalid, not transient.
|
|
48
|
+
if (/\b413\b|failed to buffer the request body:\s*length limit exceeded|payload too large|request body too large/i.test(message)) return 'INVALID_REQUEST'
|
|
49
|
+
if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST'
|
|
50
|
+
if (/\b5\d\d\b/.test(message)) return 'SERVER'
|
|
51
|
+
if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return 'TIMEOUT'
|
|
52
|
+
// A stream truncated before the provider's terminal event: each pi-ai provider
|
|
53
|
+
// throws its own wording when the wire closes mid-response without a terminal
|
|
54
|
+
// event (`… stream ended before message_stop`, `… before a terminal response
|
|
55
|
+
// event`, `… ended without a terminal event`, `Stream ended without
|
|
56
|
+
// finish_reason`). The connection dropped mid-response, so this is a transport
|
|
57
|
+
// truncation, not a model-level error.
|
|
58
|
+
if (/stream ended (?:before|without)\b/i.test(message)) return 'TRANSPORT'
|
|
59
|
+
if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message)
|
|
60
|
+
|| /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)
|
|
61
|
+
// undici renders a mid-stream socket drop as a bare `terminated` (its
|
|
62
|
+
// `cause` — the real SocketError — was flattened away upstream); Node's
|
|
63
|
+
// stream layer says `Premature close`.
|
|
64
|
+
|| /\bterminated\b|premature close/i.test(message)) {
|
|
65
|
+
return 'TRANSPORT'
|
|
66
|
+
}
|
|
67
|
+
return 'PI_AI_ERROR'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Map a terminal pi-ai event to the harness finish reason.
|
|
72
|
+
* @param message - the assistant message carried by the `done` or `error` event.
|
|
73
|
+
* @param contextWindow - resolved catalog capacity for usage-based overflow detection.
|
|
74
|
+
* @returns the mapped harness reason. Recognized error text, `stop` usage above
|
|
75
|
+
* `contextWindow`, and zero-output `length` usage that fills the window map
|
|
76
|
+
* to `CONTEXT_WINDOW_EXCEEDED`; a `stop` with no content blocks maps to an
|
|
77
|
+
* `EMPTY_RESPONSE` error, while terminal `pending` and `deferred` states map
|
|
78
|
+
* to non-retryable `PI_AI_ERROR` failures.
|
|
79
|
+
*/
|
|
80
|
+
export function mapStopReason(message: AssistantMessage, contextWindow?: number): FinishReason {
|
|
81
|
+
const piAiOverflow = isContextOverflow(message, contextWindow)
|
|
82
|
+
const harnessOverflow = message.stopReason === 'error'
|
|
83
|
+
&& message.errorMessage !== undefined
|
|
84
|
+
&& isContextWindowExceededError(message.errorMessage)
|
|
85
|
+
if (piAiOverflow || harnessOverflow) {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'error',
|
|
88
|
+
failure: {
|
|
89
|
+
message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`,
|
|
90
|
+
code: CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
switch (message.stopReason) {
|
|
96
|
+
case 'stop':
|
|
97
|
+
// A terminal stop that produced no content blocks is a degenerate
|
|
98
|
+
// provider completion, not a successful (empty) assistant message.
|
|
99
|
+
if (message.content.length === 0) {
|
|
100
|
+
return {
|
|
101
|
+
kind: 'error',
|
|
102
|
+
failure: {
|
|
103
|
+
message: `model "${message.model}" returned a completed response with no content`,
|
|
104
|
+
code: EMPTY_RESPONSE_CODE,
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { kind: 'stop' }
|
|
109
|
+
case 'length': return { kind: 'max-tokens' }
|
|
110
|
+
case 'toolUse': return { kind: 'tool-calls' }
|
|
111
|
+
case 'pending': return {
|
|
112
|
+
kind: 'error',
|
|
113
|
+
failure: { message: `pi-ai stream for model "${message.model}" ended pending`, code: 'PI_AI_ERROR' },
|
|
114
|
+
}
|
|
115
|
+
case 'deferred': return {
|
|
116
|
+
kind: 'error',
|
|
117
|
+
failure: { message: `pi-ai deferred response for model "${message.model}" is not supported`, code: 'PI_AI_ERROR' },
|
|
118
|
+
}
|
|
119
|
+
case 'aborted': return {
|
|
120
|
+
kind: 'aborted',
|
|
121
|
+
failure: { message: message.errorMessage ?? 'pi-ai stream aborted', code: 'ABORTED' },
|
|
122
|
+
}
|
|
123
|
+
case 'error': {
|
|
124
|
+
const text = message.errorMessage ?? 'pi-ai stream error'
|
|
125
|
+
return { kind: 'error', failure: { message: text, code: classifyPiAiError(text) } }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Translate the pi-ai event stream into StreamChunks. pi-ai never throws
|
|
132
|
+
* mid-stream — failures arrive as `error` events, which become error/aborted
|
|
133
|
+
* `finish` chunks (the harness protocol's other error-delivery style).
|
|
134
|
+
* @param events - one assistant turn's pi-ai event stream.
|
|
135
|
+
* @param contextWindow - resolved catalog capacity for usage-based overflow detection.
|
|
136
|
+
* @param callerSignal - caller cancellation state; an aborted caller makes any
|
|
137
|
+
* in-band terminal error an aborted finish.
|
|
138
|
+
* @returns the harness chunks, ending with `usage` then `finish`; throws
|
|
139
|
+
* `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event.
|
|
140
|
+
*/
|
|
141
|
+
export async function* toStreamChunks(
|
|
142
|
+
events: AsyncIterable<AssistantMessageEvent>,
|
|
143
|
+
contextWindow?: number,
|
|
144
|
+
callerSignal?: AbortSignal,
|
|
145
|
+
): AsyncGenerator<StreamChunk> {
|
|
146
|
+
// pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0
|
|
147
|
+
// in stream order), but we track ids per index for tool calls.
|
|
148
|
+
const toolIds = new Map<number, { id: string; name: string }>()
|
|
149
|
+
|
|
150
|
+
for await (const event of events) {
|
|
151
|
+
switch (event.type) {
|
|
152
|
+
case 'start':
|
|
153
|
+
break
|
|
154
|
+
case 'text_start':
|
|
155
|
+
yield { type: 'block-start', index: event.contentIndex, blockType: 'text' }
|
|
156
|
+
break
|
|
157
|
+
case 'text_delta':
|
|
158
|
+
yield { type: 'text-delta', index: event.contentIndex, text: event.delta }
|
|
159
|
+
break
|
|
160
|
+
case 'text_end':
|
|
161
|
+
yield { type: 'block-end', index: event.contentIndex, block: { type: 'text', text: event.content } }
|
|
162
|
+
break
|
|
163
|
+
case 'thinking_start':
|
|
164
|
+
yield { type: 'block-start', index: event.contentIndex, blockType: 'reasoning' }
|
|
165
|
+
break
|
|
166
|
+
case 'thinking_delta':
|
|
167
|
+
yield { type: 'reasoning-delta', index: event.contentIndex, text: event.delta }
|
|
168
|
+
break
|
|
169
|
+
case 'thinking_end':
|
|
170
|
+
yield { type: 'block-end', index: event.contentIndex, block: { type: 'reasoning', text: event.content } }
|
|
171
|
+
break
|
|
172
|
+
case 'toolcall_start': {
|
|
173
|
+
// The id/name live on the partial's content at this index.
|
|
174
|
+
const partial = event.partial.content[event.contentIndex]
|
|
175
|
+
const id = partial?.type === 'toolCall' ? partial.id : ''
|
|
176
|
+
const name = partial?.type === 'toolCall' ? partial.name : ''
|
|
177
|
+
toolIds.set(event.contentIndex, { id, name })
|
|
178
|
+
yield { type: 'block-start', index: event.contentIndex, blockType: 'tool-call' }
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
case 'toolcall_delta': {
|
|
182
|
+
const known = toolIds.get(event.contentIndex)
|
|
183
|
+
yield {
|
|
184
|
+
type: 'tool-call-delta',
|
|
185
|
+
index: event.contentIndex,
|
|
186
|
+
id: brandString<ToolCallId>(known?.id ?? ''),
|
|
187
|
+
...known?.name !== undefined && known.name.length > 0 ? { name: known.name } : {},
|
|
188
|
+
argumentsDelta: event.delta,
|
|
189
|
+
}
|
|
190
|
+
break
|
|
191
|
+
}
|
|
192
|
+
case 'toolcall_end':
|
|
193
|
+
yield {
|
|
194
|
+
type: 'block-end',
|
|
195
|
+
index: event.contentIndex,
|
|
196
|
+
block: {
|
|
197
|
+
type: 'tool-call',
|
|
198
|
+
id: brandString<ToolCallId>(event.toolCall.id),
|
|
199
|
+
name: event.toolCall.name,
|
|
200
|
+
// pi-ai hands back the PARSED arguments; the harness vocabulary
|
|
201
|
+
// keeps the raw string.
|
|
202
|
+
arguments: JSON.stringify(event.toolCall.arguments),
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
break
|
|
206
|
+
case 'done':
|
|
207
|
+
yield { type: 'usage', usage: mapUsage(event.message.usage) }
|
|
208
|
+
yield {
|
|
209
|
+
type: 'finish',
|
|
210
|
+
reason: mapStopReason(event.message, contextWindow),
|
|
211
|
+
replayState: toPiReplayState(event.message),
|
|
212
|
+
}
|
|
213
|
+
return
|
|
214
|
+
case 'error':
|
|
215
|
+
// In-stream error delivery (pi-ai's style) → error finish chunk
|
|
216
|
+
// (the harness's other sanctioned error path besides throwing).
|
|
217
|
+
yield { type: 'usage', usage: mapUsage(event.error.usage) }
|
|
218
|
+
yield {
|
|
219
|
+
type: 'finish',
|
|
220
|
+
reason: mapStopReason(
|
|
221
|
+
callerSignal?.aborted ? { ...event.error, stopReason: 'aborted' } : event.error,
|
|
222
|
+
contextWindow,
|
|
223
|
+
),
|
|
224
|
+
}
|
|
225
|
+
return
|
|
226
|
+
// no default: AssistantMessageEvent is pi-ai's closed union; a new
|
|
227
|
+
// event type should fail compilation here via tsc's exhaustiveness
|
|
228
|
+
// when one is added (switch covers all current variants).
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
throw new LlmError('pi-ai event stream ended without done/error', 'STREAM_CLOSED')
|
|
232
|
+
}
|