@celestea/llm 2.7.1

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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Provider profile -> client configuration (P2a).
3
+ *
4
+ * Mirrors `crates/runtime/src/compose.rs` (the DeepSeekConfig assembly):
5
+ * base_url = profile > DEEPSEEK_BASE_URL env > default; model from the profile;
6
+ * reasoning_effort passed through verbatim; timeouts resolved from the profile
7
+ * keys with the CELESTEA_LLM_* env vars taking precedence.
8
+ *
9
+ * The API key is read from the runtime configuration / environment ONLY. It is
10
+ * never written to disk, never logged, and never echoed into an error message
11
+ * or a serialized view (see OpenAiCompatClient.describe()).
12
+ */
13
+ import { LlmError } from "./errors.js";
14
+ import { resolveTimeoutTiers, timeoutMsOf, } from "./timeouts.js";
15
+ /** Environment variable holding the provider API key. */
16
+ export const API_KEY_ENV = "DEEPSEEK_API_KEY";
17
+ /** Environment variable overriding the provider base URL. */
18
+ export const BASE_URL_ENV = "DEEPSEEK_BASE_URL";
19
+ export const DEFAULT_BASE_URL = "https://api.deepseek.com";
20
+ export const DEFAULT_MODEL = "deepseek-chat";
21
+ /** The tiers as configured (null = disabled), derived from resolved ms values. */
22
+ export function tiersFromConfig(config) {
23
+ // W835 (R3 batch D / P2-6): route the 0 -> null mapping through the SAME
24
+ // helper the client constructor uses.
25
+ return {
26
+ connectMs: timeoutMsOf(config.connectTimeoutMs, null),
27
+ responseMs: timeoutMsOf(config.responseTimeoutMs, null),
28
+ idleMs: timeoutMsOf(config.streamIdleTimeoutMs, null),
29
+ };
30
+ }
31
+ function nonEmpty(v) {
32
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
33
+ }
34
+ /**
35
+ * Resolve the API key from the environment ONLY (`api_key_env` names the var).
36
+ * Returns null when unset/blank. This package never reads key files: that is
37
+ * the runtime's job (`resolve_api_key` in crates/runtime).
38
+ */
39
+ export function resolveApiKey(profile, env = process.env) {
40
+ const name = nonEmpty(profile?.api_key_env) ?? API_KEY_ENV;
41
+ const value = env[name];
42
+ if (typeof value !== "string")
43
+ return null;
44
+ const trimmed = value.trim();
45
+ return trimmed === "" ? null : trimmed;
46
+ }
47
+ /** Compose the effective client config from a runtime profile + environment. */
48
+ export function resolveClientConfig(profile, env = process.env) {
49
+ const tiers = resolveTimeoutTiers(profile, env);
50
+ const maxOut = profile?.max_output_tokens;
51
+ return {
52
+ baseUrl: nonEmpty(profile?.base_url) ?? nonEmpty(env[BASE_URL_ENV]) ?? DEFAULT_BASE_URL,
53
+ apiKey: resolveApiKey(profile, env) ?? "",
54
+ model: nonEmpty(profile?.model) ?? DEFAULT_MODEL,
55
+ reasoningEffort: typeof profile?.reasoning_effort === "string" ? profile.reasoning_effort : null,
56
+ // W835 (R3 batch D / P2-2): 0 means "clear the cap" (endpoints.json:540),
57
+ // so it resolves to null (the wire then omits max_tokens) rather than 0.
58
+ maxOutputTokens: typeof maxOut === "number" && Number.isInteger(maxOut) && maxOut > 0 ? maxOut : null,
59
+ connectTimeoutMs: tiers.connectMs ?? 0,
60
+ responseTimeoutMs: tiers.responseMs ?? 0,
61
+ streamIdleTimeoutMs: tiers.idleMs ?? 0,
62
+ };
63
+ }
64
+ /**
65
+ * reasoning_effort is a FREE STRING: user-defined tiers ("max",
66
+ * "xhigh-custom", provider-specific labels) reach the upstream exactly as
67
+ * written. Only null/undefined means "not configured" — no trimming, no
68
+ * folding onto an enum, no renaming.
69
+ */
70
+ export function normalizeReasoningEffort(v) {
71
+ return v === null || v === undefined ? null : v;
72
+ }
73
+ /**
74
+ * Model names are free-form: the OpenAI-compatible endpoint decides its own
75
+ * catalog (a local shim may expose deepseek-v4-flash), so the only hard rule is
76
+ * that a model must be supplied.
77
+ */
78
+ export function validateModel(model) {
79
+ if (model.trim() === "") {
80
+ throw new LlmError("model must not be empty", "generate");
81
+ }
82
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Provider registration (P2a).
3
+ *
4
+ * Mirrors `crates/llm/src/registry.rs` (+ `DeepSeekLlm::from_env`): the
5
+ * DeepSeek adapter is registered under the canonical name "deepseek", and the
6
+ * from-env path requires a non-empty API key.
7
+ *
8
+ * A1 (W746): the registry is CORE's `LlmRegistry` (the `llm.rs` port) — the
9
+ * local `Map`-based copy is deleted, so registration/resolution semantics are
10
+ * the seam's, not a second implementation's.
11
+ */
12
+ import { LlmRegistry } from "@celestea/core";
13
+ import { OpenAiCompatClient } from "./client.js";
14
+ import { type LlmProfile } from "./profile.js";
15
+ import type { Llm } from "./seam.js";
16
+ import type { EnvLike } from "./timeouts.js";
17
+ /** Canonical provider name (mirrors `deepseek_registry`). */
18
+ export declare const DEEPSEEK_PROVIDER_NAME = "deepseek";
19
+ export { LlmRegistry };
20
+ /**
21
+ * Build the DeepSeek provider from a runtime profile + environment. The API key
22
+ * is read from the environment only; a missing key is an error, never a silent
23
+ * unauthenticated request.
24
+ */
25
+ export declare function createDeepSeekLlm(profile?: LlmProfile | null, env?: EnvLike): OpenAiCompatClient;
26
+ /** A registry holding `llm` under the canonical "deepseek" name. */
27
+ export declare function createDeepSeekRegistry(llm: Llm): LlmRegistry<Llm>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Provider registration (P2a).
3
+ *
4
+ * Mirrors `crates/llm/src/registry.rs` (+ `DeepSeekLlm::from_env`): the
5
+ * DeepSeek adapter is registered under the canonical name "deepseek", and the
6
+ * from-env path requires a non-empty API key.
7
+ *
8
+ * A1 (W746): the registry is CORE's `LlmRegistry` (the `llm.rs` port) — the
9
+ * local `Map`-based copy is deleted, so registration/resolution semantics are
10
+ * the seam's, not a second implementation's.
11
+ */
12
+ import { LlmRegistry } from "@celestea/core";
13
+ import { OpenAiCompatClient } from "./client.js";
14
+ import { LlmError } from "./errors.js";
15
+ import { resolveApiKey } from "./profile.js";
16
+ import { resolveClientConfig } from "./profile.js";
17
+ /** Canonical provider name (mirrors `deepseek_registry`). */
18
+ export const DEEPSEEK_PROVIDER_NAME = "deepseek";
19
+ export { LlmRegistry };
20
+ /**
21
+ * Build the DeepSeek provider from a runtime profile + environment. The API key
22
+ * is read from the environment only; a missing key is an error, never a silent
23
+ * unauthenticated request.
24
+ */
25
+ export function createDeepSeekLlm(profile, env = process.env) {
26
+ if (resolveApiKey(profile, env) === null) {
27
+ const name = profile?.api_key_env ?? "DEEPSEEK_API_KEY";
28
+ throw new LlmError(`${name} is not set`, "generate");
29
+ }
30
+ return OpenAiCompatClient.fromConfig(resolveClientConfig(profile, env));
31
+ }
32
+ /** A registry holding `llm` under the canonical "deepseek" name. */
33
+ export function createDeepSeekRegistry(llm) {
34
+ const registry = new LlmRegistry();
35
+ registry.register(DEEPSEEK_PROVIDER_NAME, llm);
36
+ return registry;
37
+ }
package/dist/seam.d.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The LLM seam — the vocabulary a provider adapter and its callers share.
3
+ *
4
+ * A1 (W746): the seam vocabulary is CORE's. `@celestea/core` is the single
5
+ * source of `Llm` / `Message` / `Content` / `Role` / `ToolCall` / `ToolSpec` /
6
+ * `ModelRequest` / `Usage` / `LlmError`, and this module re-exports it instead
7
+ * of redeclaring a second, structurally-identical universe. The old
8
+ * `TODO(core-seam)` ("core's seam is still in flight") is discharged: core has
9
+ * exported all of these since W271, and `packages/llm` now imports core.
10
+ *
11
+ * Field names and content tag names are contract, not style (`type` +
12
+ * `content`, `tool_call_id`, flat usage counters): do not rename anything.
13
+ *
14
+ * ONE member cannot be a pure re-export — see [StreamEvent].
15
+ */
16
+ import type { Content, Message, ModelRequest, StreamEvent as CoreStreamEvent } from "@celestea/core";
17
+ export { assistantText, assistantToolCall, attachmentRefsOfValue, IMAGE_MEDIA_TYPES, imageContent, isImageContent, isImageMediaType, isImageRef, messageImages, messageToolCalls, normalizeImageRef, ROLES, systemMessage, toolResultMessage, toolResultWithImages, userMessage, userMessageWithImages, } from "@celestea/core";
18
+ export type { AttachmentRef, Content, ImageContent, ImageMediaType, ImageRef, LlmError, LlmErrorKind, LlmErrorOptions, Message, ModelRequest, Role, TextContent, TimeoutStage, ToolCall, ToolCallContent, ToolSpec, Usage, } from "@celestea/core";
19
+ /**
20
+ * `StreamEvent` — core's streamed-turn union with exactly ONE member widened.
21
+ *
22
+ * A provider's SSE idle guard is a terminal `failed{kindOf:"timeout"}` (the
23
+ * legacy engine's `StreamEvent::Failed { kind, .. }` carried a free-form kind,
24
+ * whose live values were "stream" and "timeout"), while core's union lists
25
+ * "generate" | "stream".
26
+ *
27
+ * TODO(core-timeout-kind) — why the widening stays HERE for now: folding it
28
+ * into core needs three files this cut may not touch or must not change:
29
+ * 1. `contracts/session-event.schema.json` freezes `TurnOutcome.error.kind`
30
+ * to exactly ["generate","stream"], and W744 EXECUTES that schema
31
+ * (`tests/contract-parity.test.ts:68,119`): widening core's
32
+ * `TurnOutcome.error.kind` would let the engine mint rows the frozen
33
+ * contract rejects;
34
+ * 2. `packages/agent-loop/src/step.ts:49` forwards `kindOf` into
35
+ * `TurnOutcome.error.kind` verbatim, so `StreamEvent.failed.kindOf`
36
+ * cannot be widened alone (agent-loop is W747's file);
37
+ * 3. `contracts/` is frozen — a real widening is a contract change with a
38
+ * decision record, not a worker's bounded cut.
39
+ * Until then the delta is this single member: everything else is derived from
40
+ * core's union, so a variant added in core appears here automatically.
41
+ */
42
+ export type StreamEvent = Exclude<CoreStreamEvent, {
43
+ kind: "failed";
44
+ }> | {
45
+ kind: "failed";
46
+ kindOf: "generate" | "stream" | "timeout";
47
+ message: string;
48
+ };
49
+ /**
50
+ * A request DRAFT — what a DIRECT caller of this provider may pass: every field
51
+ * of core's `ModelRequest` optional except `messages`.
52
+ *
53
+ * The engine always hands over a fully-filled core `ModelRequest` (which IS a
54
+ * draft: it is assignable to this type), but the provider is also driven
55
+ * one-shot (`packages/llm/**` tests, an embedding host), where `model` /
56
+ * `system` / `tools` / `max_tokens` / `temperature` are simply absent — the
57
+ * wire mapper's documented "absent == empty" fallbacks handle exactly that, and
58
+ * have always handled it. Keeping the permissive form HERE (instead of
59
+ * loosening core's `ModelRequest`, which `apps/studio` reads as a fully-filled
60
+ * shape) is what makes the shared seam strict and the provider usable.
61
+ */
62
+ export type ModelRequestDraft = Partial<ModelRequest> & {
63
+ messages: Message[];
64
+ /**
65
+ * W804: the REQUEST-scoped image resolution table (attachment_id -> data URL)
66
+ * the host fills before the wire mapper runs. It is not part of core's
67
+ * `ModelRequest` (the log never carries bytes); the wire layer reads it and
68
+ * emits an OpenAI content array. Absent = no image table.
69
+ */
70
+ images?: ResolvedImages;
71
+ };
72
+ /** attachment_id -> data URL, resolved for ONE request. */
73
+ export type ResolvedImages = Readonly<Record<string, string>>;
74
+ /** The streamed turn: an async iterable of events. */
75
+ export type LlmStream = AsyncIterable<StreamEvent>;
76
+ /**
77
+ * The `Llm` seam every provider adapter implements: core's `Llm` with the
78
+ * [StreamEvent] widening above (hence not a re-export — the return type is the
79
+ * provider's stream). A provider stream is therefore NOT assignable to core's
80
+ * `Llm`; the single host adapter converts it (and drops "timeout" to "stream"
81
+ * for the frozen contract): `apps/studio/src/runtime/llm-assembly.ts:69-96`.
82
+ */
83
+ export interface Llm {
84
+ /** Start a streaming turn; pre-stream failures reject with an LlmError. */
85
+ generate(req: ModelRequestDraft): Promise<LlmStream>;
86
+ }
87
+ /** Concatenate the text parts of a message's content (joined with "\n"). */
88
+ export declare function collectMessageText(content: readonly Content[]): string;
89
+ /** Drain a stream into an array (helper for tests/CLI; consumers stream live). */
90
+ export declare function collectStream(stream: LlmStream): Promise<StreamEvent[]>;
package/dist/seam.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The LLM seam — the vocabulary a provider adapter and its callers share.
3
+ *
4
+ * A1 (W746): the seam vocabulary is CORE's. `@celestea/core` is the single
5
+ * source of `Llm` / `Message` / `Content` / `Role` / `ToolCall` / `ToolSpec` /
6
+ * `ModelRequest` / `Usage` / `LlmError`, and this module re-exports it instead
7
+ * of redeclaring a second, structurally-identical universe. The old
8
+ * `TODO(core-seam)` ("core's seam is still in flight") is discharged: core has
9
+ * exported all of these since W271, and `packages/llm` now imports core.
10
+ *
11
+ * Field names and content tag names are contract, not style (`type` +
12
+ * `content`, `tool_call_id`, flat usage counters): do not rename anything.
13
+ *
14
+ * ONE member cannot be a pure re-export — see [StreamEvent].
15
+ */
16
+ // The seam vocabulary, verbatim from core (values keep their identity too, so
17
+ // `userMessage(...)` here IS core's `userMessage(...)`).
18
+ export { assistantText, assistantToolCall, attachmentRefsOfValue, IMAGE_MEDIA_TYPES, imageContent, isImageContent, isImageMediaType, isImageRef, messageImages, messageToolCalls, normalizeImageRef, ROLES, systemMessage, toolResultMessage, toolResultWithImages, userMessage, userMessageWithImages, } from "@celestea/core";
19
+ // ---------------------------------------------------------------------------
20
+ // Message helpers over core's shapes (convenience, not seam vocabulary)
21
+ // ---------------------------------------------------------------------------
22
+ /** Concatenate the text parts of a message's content (joined with "\n"). */
23
+ export function collectMessageText(content) {
24
+ return content
25
+ .filter((part) => part.type === "text")
26
+ .map((part) => part.content)
27
+ .join("\n");
28
+ }
29
+ /** Drain a stream into an array (helper for tests/CLI; consumers stream live). */
30
+ export async function collectStream(stream) {
31
+ const events = [];
32
+ for await (const event of stream)
33
+ events.push(event);
34
+ return events;
35
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Raw chat-completions chunk parsing (P2a).
3
+ *
4
+ * Mirrors `crates/llm/src/client.rs`:
5
+ * parse_raw_chunk — delta view of one SSE data payload;
6
+ * extract_reasoning — choices[].delta.reasoning_content, joined in wire order;
7
+ * thinking_event — blank-gated thinking event for a reasoning delta;
8
+ * parse_arguments — tool-call arguments (malformed JSON kept raw).
9
+ *
10
+ * Non-JSON payloads (heartbeats, noise) and payloads carrying neither a delta
11
+ * nor usage (e.g. `{"choices":[]}`) return undefined, so the caller skips them.
12
+ */
13
+ import { type Usage } from "../usage.js";
14
+ import type { StreamEvent } from "../seam.js";
15
+ /** One streamed tool-call fragment (id/name/arguments arrive piecemeal). */
16
+ export interface RawToolCallDelta {
17
+ index: number;
18
+ id?: string;
19
+ name?: string;
20
+ arguments?: string;
21
+ }
22
+ /** The content + tool_calls part of a single choice's delta. */
23
+ export interface RawChoiceDelta {
24
+ text?: string;
25
+ toolCalls: RawToolCallDelta[];
26
+ }
27
+ /** One decoded chat-completions stream chunk (raw wire shape). */
28
+ export interface RawChunk {
29
+ /** Joined reasoning_content across choices (absent when none). */
30
+ reasoning?: string;
31
+ /** Per-choice content / tool-call deltas, in wire order. */
32
+ choices: RawChoiceDelta[];
33
+ /** Provider-reported usage, when the chunk carries some. */
34
+ usage?: Usage;
35
+ }
36
+ /**
37
+ * Extract chain-of-thought text: DeepSeek streams the CoT in
38
+ * choices[].delta.reasoning_content (absent for non-reasoning models);
39
+ * multi-choice deltas join in wire order.
40
+ */
41
+ export declare function extractReasoning(chunk: unknown): string | undefined;
42
+ /**
43
+ * Parse one SSE data payload into the delta view. Returns undefined for
44
+ * non-JSON payloads, `[DONE]`, and JSON payloads without deltas or usage.
45
+ */
46
+ export declare function parseRawChunk(data: string): RawChunk | undefined;
47
+ /**
48
+ * The upstream-reported error message of one SSE payload, or undefined when the
49
+ * payload carries none (W835 R3 batch C / P1-1).
50
+ *
51
+ * OpenAI-compatible gateways report a failed generation as a 200 SSE frame
52
+ * whose body is `{"error":{...}}` (some send `{"message":"..."}`). Such a frame
53
+ * carries no choices/usage, so [parseRawChunk] returns undefined; without this
54
+ * check it was silently dropped and a following `[DONE]` could even turn it
55
+ * into a "successful" empty reply. Recognising it lets the stream terminate as
56
+ * `failed{kindOf:"stream"}` instead.
57
+ */
58
+ export declare function parseStreamError(data: string): string | undefined;
59
+ /** Build a thinking event for a non-blank reasoning delta (blank-gated). */
60
+ export declare function thinkingEvent(reasoning: string): StreamEvent | null;
61
+ /** Parse accumulated tool-call arguments; malformed JSON is preserved raw. */
62
+ export declare function parseArguments(raw: string): unknown;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Raw chat-completions chunk parsing (P2a).
3
+ *
4
+ * Mirrors `crates/llm/src/client.rs`:
5
+ * parse_raw_chunk — delta view of one SSE data payload;
6
+ * extract_reasoning — choices[].delta.reasoning_content, joined in wire order;
7
+ * thinking_event — blank-gated thinking event for a reasoning delta;
8
+ * parse_arguments — tool-call arguments (malformed JSON kept raw).
9
+ *
10
+ * Non-JSON payloads (heartbeats, noise) and payloads carrying neither a delta
11
+ * nor usage (e.g. `{"choices":[]}`) return undefined, so the caller skips them.
12
+ */
13
+ import { parseUsage } from "../usage.js";
14
+ function isRecord(v) {
15
+ return typeof v === "object" && v !== null && !Array.isArray(v);
16
+ }
17
+ function str(v) {
18
+ return typeof v === "string" ? v : undefined;
19
+ }
20
+ function choiceIndex(v) {
21
+ return typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : 0;
22
+ }
23
+ /**
24
+ * Extract chain-of-thought text: DeepSeek streams the CoT in
25
+ * choices[].delta.reasoning_content (absent for non-reasoning models);
26
+ * multi-choice deltas join in wire order.
27
+ */
28
+ export function extractReasoning(chunk) {
29
+ if (!isRecord(chunk))
30
+ return undefined;
31
+ const choices = chunk["choices"];
32
+ if (!Array.isArray(choices))
33
+ return undefined;
34
+ const parts = [];
35
+ for (const choice of choices) {
36
+ if (!isRecord(choice))
37
+ continue;
38
+ const delta = choice["delta"];
39
+ if (!isRecord(delta))
40
+ continue;
41
+ const reasoning = str(delta["reasoning_content"]);
42
+ if (reasoning !== undefined && reasoning !== "")
43
+ parts.push(reasoning);
44
+ }
45
+ return parts.length === 0 ? undefined : parts.join("");
46
+ }
47
+ /** Parse one tool_calls array entry into a fragment. */
48
+ function parseToolCallDelta(call) {
49
+ if (!isRecord(call))
50
+ return undefined;
51
+ const fn = isRecord(call["function"]) ? call["function"] : {};
52
+ const fragment = { index: choiceIndex(call["index"]) };
53
+ const id = str(call["id"]);
54
+ const name = str(fn["name"]);
55
+ const args = str(fn["arguments"]);
56
+ if (id !== undefined)
57
+ fragment.id = id;
58
+ if (name !== undefined)
59
+ fragment.name = name;
60
+ if (args !== undefined)
61
+ fragment.arguments = args;
62
+ return fragment;
63
+ }
64
+ /** Parse one choices[] entry into its delta view, or undefined when empty. */
65
+ function parseChoiceDelta(choice) {
66
+ if (!isRecord(choice))
67
+ return undefined;
68
+ const delta = choice["delta"];
69
+ if (!isRecord(delta))
70
+ return undefined;
71
+ const rawText = str(delta["content"]);
72
+ const toolCalls = [];
73
+ const rawCalls = delta["tool_calls"];
74
+ if (Array.isArray(rawCalls)) {
75
+ for (const call of rawCalls) {
76
+ const fragment = parseToolCallDelta(call);
77
+ if (fragment !== undefined)
78
+ toolCalls.push(fragment);
79
+ }
80
+ }
81
+ const out = { toolCalls };
82
+ if (rawText !== undefined && rawText !== "")
83
+ out.text = rawText;
84
+ if (out.text === undefined && toolCalls.length === 0)
85
+ return undefined;
86
+ return out;
87
+ }
88
+ /**
89
+ * Parse one SSE data payload into the delta view. Returns undefined for
90
+ * non-JSON payloads, `[DONE]`, and JSON payloads without deltas or usage.
91
+ */
92
+ export function parseRawChunk(data) {
93
+ let value;
94
+ try {
95
+ value = JSON.parse(data);
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ if (!isRecord(value))
101
+ return undefined;
102
+ const reasoning = extractReasoning(value);
103
+ const usage = parseUsage(value);
104
+ const choices = [];
105
+ const rawChoices = value["choices"];
106
+ if (Array.isArray(rawChoices)) {
107
+ for (const choice of rawChoices) {
108
+ const delta = parseChoiceDelta(choice);
109
+ if (delta !== undefined)
110
+ choices.push(delta);
111
+ }
112
+ }
113
+ if (reasoning === undefined && choices.length === 0 && usage === undefined)
114
+ return undefined;
115
+ const chunk = { choices };
116
+ if (reasoning !== undefined)
117
+ chunk.reasoning = reasoning;
118
+ if (usage !== undefined)
119
+ chunk.usage = usage;
120
+ return chunk;
121
+ }
122
+ /**
123
+ * The upstream-reported error message of one SSE payload, or undefined when the
124
+ * payload carries none (W835 R3 batch C / P1-1).
125
+ *
126
+ * OpenAI-compatible gateways report a failed generation as a 200 SSE frame
127
+ * whose body is `{"error":{...}}` (some send `{"message":"..."}`). Such a frame
128
+ * carries no choices/usage, so [parseRawChunk] returns undefined; without this
129
+ * check it was silently dropped and a following `[DONE]` could even turn it
130
+ * into a "successful" empty reply. Recognising it lets the stream terminate as
131
+ * `failed{kindOf:"stream"}` instead.
132
+ */
133
+ export function parseStreamError(data) {
134
+ let value;
135
+ try {
136
+ value = JSON.parse(data);
137
+ }
138
+ catch {
139
+ return undefined;
140
+ }
141
+ if (!isRecord(value))
142
+ return undefined;
143
+ const error = value["error"];
144
+ if (error !== undefined && error !== null) {
145
+ const text = errorText(error);
146
+ if (text !== undefined)
147
+ return text;
148
+ }
149
+ const message = str(value["message"]);
150
+ return message !== undefined && message.trim() !== "" ? message : undefined;
151
+ }
152
+ /** Best-effort text of an `error` payload (string, or a nested message/detail). */
153
+ function errorText(error) {
154
+ if (typeof error === "string")
155
+ return error.trim() === "" ? undefined : error;
156
+ if (!isRecord(error))
157
+ return undefined;
158
+ for (const key of ["message", "detail"]) {
159
+ const text = str(error[key]);
160
+ if (text !== undefined && text.trim() !== "")
161
+ return text;
162
+ }
163
+ const json = JSON.stringify(error);
164
+ return json === undefined || json === "{}" ? undefined : json;
165
+ }
166
+ /** Build a thinking event for a non-blank reasoning delta (blank-gated). */
167
+ export function thinkingEvent(reasoning) {
168
+ return reasoning.trim() === "" ? null : { kind: "thinking", text: reasoning };
169
+ }
170
+ /** Parse accumulated tool-call arguments; malformed JSON is preserved raw. */
171
+ export function parseArguments(raw) {
172
+ try {
173
+ return JSON.parse(raw);
174
+ }
175
+ catch {
176
+ return raw;
177
+ }
178
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Incremental SSE frame decoder (P2a).
3
+ *
4
+ * Mirrors the `eventsource-stream` layer used by
5
+ * `crates/llm/src/client.rs::raw_chunk_stream`: frames are newline-terminated
6
+ * and dispatched by a blank line, comment lines (`: keepalive`) and unknown
7
+ * fields are ignored, and an unterminated trailing frame at EOF is dropped.
8
+ *
9
+ * The decoder is incremental on purpose: bytes may arrive split anywhere —
10
+ * inside a frame, between CR and LF, or inside a multi-byte UTF-8 character
11
+ * (the byte-level driver feeds it decoded text via a StringDecoder).
12
+ */
13
+ /**
14
+ * Hard cap on the decoded, not-yet-framed buffer (W835 R3 batch E / P2-5).
15
+ *
16
+ * A stream that never emits a newline would otherwise grow the buffer without
17
+ * bound (the idle guard only fires when NO bytes arrive). 4 MiB is ~8x the
18
+ * largest plausible single frame: providers chunk output deltas, and even a
19
+ * full 128k-token answer delivered as one frame stays below ~0.5 MiB.
20
+ * Exceeding it is a terminal failed{kindOf:"stream"} on the stream path.
21
+ */
22
+ export declare const MAX_SSE_BUFFER_BYTES: number;
23
+ /** The decoder's newline-less buffer exceeded [MAX_SSE_BUFFER_BYTES]. */
24
+ export declare class SseBufferOverflowError extends Error {
25
+ readonly limit: number;
26
+ constructor(limit: number);
27
+ }
28
+ /** One fully decoded SSE frame (blank-line terminated). */
29
+ export interface SseFrame {
30
+ /** Event name; "message" when the frame carried no `event:` field. */
31
+ event: string;
32
+ /** Joined `data:` lines (never "" — empty frames are not dispatched). */
33
+ data: string;
34
+ }
35
+ export declare class SseDecoder {
36
+ #private;
37
+ /** Feed decoded text; returns the frames that completed on this input. */
38
+ push(text: string): SseFrame[];
39
+ /** End of input: flush complete lines, drop the torn remainder. */
40
+ flush(): SseFrame[];
41
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Incremental SSE frame decoder (P2a).
3
+ *
4
+ * Mirrors the `eventsource-stream` layer used by
5
+ * `crates/llm/src/client.rs::raw_chunk_stream`: frames are newline-terminated
6
+ * and dispatched by a blank line, comment lines (`: keepalive`) and unknown
7
+ * fields are ignored, and an unterminated trailing frame at EOF is dropped.
8
+ *
9
+ * The decoder is incremental on purpose: bytes may arrive split anywhere —
10
+ * inside a frame, between CR and LF, or inside a multi-byte UTF-8 character
11
+ * (the byte-level driver feeds it decoded text via a StringDecoder).
12
+ */
13
+ /**
14
+ * Hard cap on the decoded, not-yet-framed buffer (W835 R3 batch E / P2-5).
15
+ *
16
+ * A stream that never emits a newline would otherwise grow the buffer without
17
+ * bound (the idle guard only fires when NO bytes arrive). 4 MiB is ~8x the
18
+ * largest plausible single frame: providers chunk output deltas, and even a
19
+ * full 128k-token answer delivered as one frame stays below ~0.5 MiB.
20
+ * Exceeding it is a terminal failed{kindOf:"stream"} on the stream path.
21
+ */
22
+ export const MAX_SSE_BUFFER_BYTES = 4 * 1024 * 1024;
23
+ /** The decoder's newline-less buffer exceeded [MAX_SSE_BUFFER_BYTES]. */
24
+ export class SseBufferOverflowError extends Error {
25
+ limit;
26
+ constructor(limit) {
27
+ super("SSE decoder buffer exceeded " + limit + " bytes without a frame boundary");
28
+ this.limit = limit;
29
+ this.name = "SseBufferOverflowError";
30
+ }
31
+ }
32
+ export class SseDecoder {
33
+ #buffer = "";
34
+ #event = "";
35
+ #data = [];
36
+ #bytes = 0;
37
+ /** Feed decoded text; returns the frames that completed on this input. */
38
+ push(text) {
39
+ this.#buffer += text;
40
+ this.#bytes += Buffer.byteLength(text, "utf8");
41
+ const frames = this.#drain(false);
42
+ // Check AFTER draining: a big batch of COMPLETE frames is fine; only a
43
+ // buffer that cannot be framed within the cap is a runaway (P2-5).
44
+ if (this.#bytes > MAX_SSE_BUFFER_BYTES)
45
+ throw new SseBufferOverflowError(MAX_SSE_BUFFER_BYTES);
46
+ return frames;
47
+ }
48
+ /** End of input: flush complete lines, drop the torn remainder. */
49
+ flush() {
50
+ const frames = this.#drain(true);
51
+ this.#buffer = "";
52
+ this.#bytes = 0;
53
+ return frames;
54
+ }
55
+ #drain(final) {
56
+ const frames = [];
57
+ for (;;) {
58
+ const line = this.#nextLine(final);
59
+ if (line === null)
60
+ break;
61
+ if (line === "") {
62
+ const frame = this.#dispatch();
63
+ if (frame !== null)
64
+ frames.push(frame);
65
+ continue;
66
+ }
67
+ if (line.startsWith(":"))
68
+ continue; // comment / keepalive line
69
+ this.#consumeField(line);
70
+ }
71
+ return frames;
72
+ }
73
+ /** Next complete line, or null when more bytes are needed. */
74
+ #nextLine(final) {
75
+ const buf = this.#buffer;
76
+ const lf = buf.indexOf("\n");
77
+ const cr = buf.indexOf("\r");
78
+ let at;
79
+ let len = 1;
80
+ if (lf !== -1 && (cr === -1 || lf < cr)) {
81
+ at = lf;
82
+ }
83
+ else if (cr !== -1) {
84
+ // A trailing CR may be the first half of CRLF: wait for more bytes.
85
+ if (cr === buf.length - 1 && !final)
86
+ return null;
87
+ at = cr;
88
+ if (buf.charAt(cr + 1) === "\n")
89
+ len = 2;
90
+ }
91
+ else {
92
+ return null;
93
+ }
94
+ const line = buf.slice(0, at);
95
+ const consumed = buf.slice(0, at + len);
96
+ this.#buffer = buf.slice(at + len);
97
+ this.#bytes -= Buffer.byteLength(consumed, "utf8");
98
+ return line;
99
+ }
100
+ #consumeField(line) {
101
+ const colon = line.indexOf(":");
102
+ const field = colon === -1 ? line : line.slice(0, colon);
103
+ let value = colon === -1 ? "" : line.slice(colon + 1);
104
+ if (value.startsWith(" "))
105
+ value = value.slice(1);
106
+ if (field === "event")
107
+ this.#event = value;
108
+ else if (field === "data")
109
+ this.#data.push(value);
110
+ // id / retry / unknown fields: not part of this contract, ignored.
111
+ }
112
+ #dispatch() {
113
+ const event = this.#event;
114
+ const data = this.#data;
115
+ this.#event = "";
116
+ this.#data = [];
117
+ // Per the SSE spec an empty data buffer dispatches nothing.
118
+ if (data.length === 0)
119
+ return null;
120
+ return { event: event === "" ? "message" : event, data: data.join("\n") };
121
+ }
122
+ }