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