@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mcd0LUO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # @celestea/llm — OpenAI-compatible LLM provider (P2a)
2
+
3
+ Parity target: `celestea_harness/crates/llm` (+ `crates/runtime/src/compose.rs`
4
+ for profile resolution). Raw SSE transport, usage/cache-hit parsing, three
5
+ timeout tiers, free-form `reasoning_effort` passthrough.
6
+
7
+ ## 职责
8
+
9
+ * **请求构造** — `POST {base_url}/chat/completions`,体为
10
+ `{model, messages, tools?, reasoning_effort?, max_tokens?, temperature?, stream:true}`。
11
+ `reasoning_effort` 是**自由字符串**,原样透传(`"max"` 就是 `"max"`,不改名、不折叠、不裁剪);
12
+ 请求的 `max_tokens` 优先,缺省回落到配置的 `max_output_tokens`。
13
+ * **SSE 解析** — 逐行 `data:`、空行分帧;跳过注释/keepalive/空帧/非 JSON;`[DONE]` 结束;
14
+ `reasoning_content` → `thinking` 增量(实时 CoT),`content` → `text` 增量,
15
+ `tool_calls` 按 index 分片累积,`usage` 帧(含“只有 usage 的尾帧”)在终态事件前下发。
16
+ * **用量解析** — `Usage{prompt_tokens, completion_tokens, total_tokens, cache_read, reasoning_tokens}`;
17
+ `cache_read` 兼容三种键:`prompt_cache_hit_tokens` / `cache_read_input_tokens` /
18
+ `prompt_tokens_details.cached_tokens`(前两者优先),
19
+ `reasoning_tokens` 取 `completion_tokens_details.reasoning_tokens`。
20
+ * **三档超时(无总请求超时)** — connect 15s、响应头 60s、流空闲 90s;
21
+ 0 = 关闭该档;长生成只靠“帧间隔”判活,永不因总时长被杀。
22
+ * **错误语义** — 响应头超时抛
23
+ `llm timeout: response headers not received within {N}ms ({url})`(`kind="generate"`);
24
+ connect 超时抛 `llm timeout: connect timeout: ...`;
25
+ 流空闲 → 终态事件 `failed{kindOf:"timeout"}`;流中途解码/传输错误 → `failed{kindOf:"stream"}`;
26
+ 未收到 `[DONE]` 而流结束 → `interrupted`;HTTP 非 2xx → `stream request failed: {status}: {片段}`。
27
+ **任何路径都不会伪造 done**(R1)。
28
+ * **密钥安全** — API key 只从运行时 profile / 环境变量读取(`api_key_env`,默认
29
+ `DEEPSEEK_API_KEY`),只作为 `Authorization: Bearer` 头发送;不落盘、不写日志、
30
+ 不进入错误文案,`describe()` 视图也不含 key。
31
+
32
+ ## 公开 API(唯一出口:`src/index.ts`)
33
+
34
+ | 分类 | 导出 |
35
+ |---|---|
36
+ | seam 类型(re-export core) | `Llm`, `LlmStream`, `StreamEvent`, `Message`, `Content`, `TextContent`, `ToolCallContent`, `ToolCall`, `Role`, `ROLES`, `ToolSpec`, `ModelRequest`, `ModelRequestDraft`, `LlmError` |
37
+ | seam 工具(re-export core,除 helper) | `userMessage`, `systemMessage`, `assistantText`, `assistantToolCall`, `toolResultMessage`, `collectMessageText`, `messageToolCalls`, `collectStream` |
38
+ | 用量 | `Usage`, `parseUsage`, `usageFromObject`, `usageIsEmpty`, `zeroUsage`, `ZERO_USAGE`, `cacheHitRatio`, `USAGE_REQUIRED_KEYS`, `CACHE_READ_FLAT_KEYS`, `CACHE_READ_NESTED`, `REASONING_TOKENS_NESTED` |
39
+ | 错误 | `LlmError`, `LlmErrorKind`, `TimeoutStage`, `TIMEOUT_ERROR_PREFIX`, `timeoutError`, `responseHeaderTimeoutError`, `connectTimeoutError`, `streamIdleTimeoutMessage`, `isTimeoutError`, `errorKind` |
40
+ | 超时三档 | `TimeoutTiers`, `TimeoutProfile`, `DEFAULT_TIMEOUTS`, `DEFAULT_*_TIMEOUT_MS`, `CONNECT_TIMEOUT_ENV`, `RESPONSE_TIMEOUT_ENV`, `STREAM_IDLE_TIMEOUT_ENV`, `PROFILE_TIMEOUT_KEYS`, `resolveTimeoutMs`, `resolveTimeoutTiers`, `readTimeoutProfile`, `isTimeoutMs`, `EnvLike` |
41
+ | profile→配置 | `LlmProfile`, `ResolvedClientConfig`, `resolveClientConfig`, `resolveApiKey`, `tiersFromConfig`, `normalizeReasoningEffort`, `validateModel`, `API_KEY_ENV`, `BASE_URL_ENV`, `DEFAULT_BASE_URL`, `DEFAULT_MODEL` |
42
+ | 适配器 | `OpenAiCompatClient`(实现 `Llm`), `OpenAiCompatOptions`, `createDeepSeekLlm`, `LlmRegistry`, `createDeepSeekRegistry`, `DEEPSEEK_PROVIDER_NAME` |
43
+
44
+ 调用方只依赖 `Llm` seam 与上面的类型;SSE 分帧、wire 映射、HTTP 传输是包内实现,
45
+ **不从 index.ts 导出**(`packages/llm/src/sse/*`、`wire.ts`、`transport.ts`、`stream.ts`)。
46
+
47
+ ## 超时契约
48
+
49
+ | 档位 | 默认 | profile 键 | 环境变量 | 语义 |
50
+ |---|---|---|---|---|
51
+ | connect | 15000 ms | `llm_connect_timeout_ms` | `CELESTEA_LLM_CONNECT_TIMEOUT_MS` | TCP/TLS 握手 |
52
+ | response | 60000 ms | `llm_response_timeout_ms` | `CELESTEA_LLM_RESPONSE_TIMEOUT_MS` | `send()` → 响应头 |
53
+ | stream idle | 90000 ms | `llm_stream_idle_timeout_ms` | `CELESTEA_LLM_STREAM_IDLE_TIMEOUT_MS` | 相邻两个数据帧的间隔(含首帧等待) |
54
+
55
+ 优先级 **env > profile 键 > 内置默认**;`0` 关闭该档;env 空白/不可解析则回落到 profile 键
56
+ (与 `crates/runtime/src/config.rs::resolve_llm_timeout_ms` 一致)。
57
+ **不存在总请求超时**:只有“响应头没来”和“帧间隔断了”会触发,长时间生成不会被杀。
58
+
59
+ ## 用量契约
60
+
61
+ `Usage` 五个扁平计数即 `statusline.usage` 的字段(`prompt_tokens` / `completion_tokens` /
62
+ `total_tokens` / `cache_read` / `reasoning_tokens`);`cache_hit_ratio = clamp(cache_read /
63
+ prompt_tokens, 0, 1)`,4 位小数。全零 usage 视为“没有 usage 帧”(`undefined`),
64
+ 非数字/负数/小数一律按 0 处理(对齐 serde `as_u64`)。
65
+
66
+ ## 扩展点:新增一个 provider
67
+
68
+ 1. 若新 provider 也讲 OpenAI `chat/completions`(仅 base_url / 模型名不同),直接复用
69
+ `OpenAiCompatClient`,用 profile 覆盖 `base_url` / `model` / `reasoning_effort` / 超时键即可。
70
+ 2. 若请求体或流格式不同(例如 `responses` / `anthropic_messages`),实现 `Llm` seam:
71
+
72
+ ```ts
73
+ class MyProvider implements Llm {
74
+ async generate(req: ModelRequest): Promise<LlmStream> { /* 自己的 wire + SSE */ }
75
+ }
76
+ ```
77
+
78
+ 复用 `timeouts.ts`(三档解析)、`usage.ts`(用量解析)、`errors.ts`(超时前缀/kind 映射)
79
+ 与 `stream.ts` 的 `TurnAccumulator`,即可继承同样的超时/用量/终态语义。
80
+ 3. 注册:`registry.register("myprovider", new MyProvider(...))`。
81
+ `crates/llm/src/registry.rs` 的对应物是 `createDeepSeekRegistry(llm)`。
82
+
83
+ ## core seam(A1 / W746:已收口)
84
+
85
+ `packages/llm` 以**插件**形式实现 `packages/core` 的 LLM seam,只依赖 `packages/core`
86
+ (不依赖 session / tools / agent-loop / runtime)。W746 之前本包自带 `src/seam.ts` 这份与 core
87
+ 同名的词汇表(`src/*.ts` 对 core 的 import 数为 **0**);现在词汇表**就是 core 的**:
88
+
89
+ ```ts
90
+ // seam.ts:直接 re-export core(值也 re-export,`LlmError` 因此跨包 instanceof 一致)
91
+ export { assistantText, messageToolCalls, userMessage, ROLES, … } from "@celestea/core";
92
+ export type { Content, LlmError, Message, ModelRequest, Role, StreamEvent, … } from "@celestea/core";
93
+ ```
94
+
95
+ - **零重复定义**:`Message` / `Content` / `Role` / `ToolCall` / `ToolSpec` / `ModelRequest` /
96
+ `Usage` / `LlmError` / `LlmRegistry` 全部来自 core(`seam.ts` / `usage.ts` / `errors.ts` /
97
+ `provider.ts` 只保留「解析」「构造器」「provider 注册」这类 provider 侧代码)。
98
+ - **`instanceof` 跨包一致**:`LlmError` 类本体在 `packages/core/src/stream.ts`,本包
99
+ `statusError()` / `timeoutError()` 造出的错误在 core 侧同样是 `instanceof LlmError`
100
+ (`src/seam.test.ts` 断言类对象同一性,不是结构相同)。
101
+ - **`LlmRegistry`**:删除本包同名最小实现,`createDeepSeekRegistry` 改用 core 的
102
+ `LlmRegistry<Llm>`(core 的注册表加了带默认值的类型参数,见 `core/src/llm.ts`)。
103
+
104
+ ### 唯一保留的差异(`StreamEvent.failed.kindOf`)
105
+
106
+ | 项 | core | 本包 | 处置 |
107
+ |---|---|---|---|
108
+ | `StreamEvent.failed.kindOf` | `"generate" \| "stream"` | `"generate" \| "stream" \| "timeout"` | **本包唯一放宽的成员**(SSE 空闲守卫 → `"timeout"`,旧实现侧 `Failed{kind}` 本是自由字符串)。其余成员由 core 的联合类型派生(`Exclude<CoreStreamEvent, {kind:"failed"}>`),core 新增变体会自动出现。见 `src/seam.ts` 的 `TODO(core-timeout-kind)` |
109
+ | `ModelRequest` | 全字段必填 | `ModelRequest` 照旧 re-export;另有 `ModelRequestDraft = Partial<ModelRequest> & { messages }` 作为**直连调用**的入参 | 引擎交给本包的一定是全字段 core `ModelRequest`(可直接当 draft 用);一次性调用只给 `messages` 也合法,wire 映射的「缺省即空」回退语义未变 |
110
+ | `LlmError` | 结构化类(`kind`/`isTimeout`/`timeoutStage`/`httpStatus`/`retryable`) | 同一个类 | 类本体已在 core,本包只留 `TIMEOUT_ERROR_PREFIX` 与构造器 |
111
+ | seam 词汇(`Role`/`Content`/`ToolCall`/`Message`/`ToolSpec`/`Usage`) | `message.ts` + `types.ts` | re-export | 无差异 |
112
+ | `Llm` service token | `LLM_SERVICE` / `LLM_REGISTRY_SERVICE` | 无 | 组合期(compose)由 runtime 侧使用;本包不涉及 |
113
+
114
+ **为什么放宽没进 core**(选择理由):要放宽 `kindOf` 就必须同时放宽 core 的
115
+ `TurnOutcome.error.kind`,因为 `packages/agent-loop/src/step.ts:49` 把 `kindOf` 原样写进
116
+ `TurnOutcome.error.kind`;而 `TurnOutcome.error.kind` 的取值被冻结契约
117
+ `contracts/session-event.schema.json` 钉成 `["generate","stream"]`,且 W744 起该 schema 会被
118
+ `tests/contract-parity.test.ts` 真实执行(第 68 / 119 行)——放宽 core 会让引擎能产出契约拒绝
119
+ 的行。契约冻结 + agent-loop 不在本刀范围内,所以本刀选择「保留一层最小适配」:差异只此一个
120
+ 成员,有损降级点收敛在唯一的 host 适配器
121
+ (`apps/studio/src/runtime/llm-assembly.ts:69-96`,`kindOf: "timeout"` → `"stream"`,
122
+ `llm timeout:` 前缀保住细节)。真正放宽需要 契约 + agent-loop + 本包 三处一起动。
123
+
124
+ ## 目录
125
+
126
+ | 文件 | 行数(约) | 职责 |
127
+ |---|---|---|
128
+ | `src/index.ts` | 127 | 唯一公开出口 |
129
+ | `src/seam.ts` | 125 | core 词汇表的 re-export + `Llm` 接口(唯一放宽)+ 消息/流 helper |
130
+ | `src/client.ts` | 176 | `OpenAiCompatClient`(实现 `Llm`)与 HTTP 错误包装 |
131
+ | `src/transport.ts` | 150 | HTTP 传输 + connect/响应头两档超时 + 错误体片段 + 脱敏 |
132
+ | `src/stream.ts` | 246 | 流空闲超时读体 + `TurnAccumulator` + 终态事件(done/failed/interrupted) |
133
+ | `src/sse/frames.ts` | 98 | 增量 SSE 分帧器 |
134
+ | `src/sse/chunks.ts` | 151 | chunk 视图、`reasoning_content`、tool-call 分片、参数解析 |
135
+ | `src/wire.ts` | 135 | 消息/工具映射与请求体构造 |
136
+ | `src/usage.ts` | 107 | 用量与三种 cache 键解析(`Usage`/`zeroUsage`/`usageIsEmpty` re-export core) |
137
+ | `src/errors.ts` | 108 | 超时前缀 + 结构化错误构造器(`LlmError` 类本体在 core) |
138
+ | `src/timeouts.ts` | 153 | 三档超时解析(profile 键 + env + 默认) |
139
+ | `src/profile.ts` | 121 | profile→配置、api key 只从 env、effort 直通 |
140
+ | `src/provider.ts` | 48 | provider 注册与 from-env 构造(用 core 的 `LlmRegistry`) |
141
+ | `src/*.test.ts`, `src/mock-upstream.test-util.ts` | — | 本地 mock HTTP server 测试(无网络);`seam.test.ts` 锁 A1 不变量 |
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The OpenAI-compatible provider client (P2a).
3
+ *
4
+ * Implements the `Llm` seam on top of the pieces of this package:
5
+ * wire.ts (request body), transport.ts (connect + response-header guards),
6
+ * stream.ts (idle-guarded SSE decoding), usage.ts (usage/cache parsing).
7
+ *
8
+ * Error semantics:
9
+ * * response-header timeout -> LlmError `llm timeout: response headers not
10
+ * received within {N}ms ({url})`, kind "generate";
11
+ * * connect timeout -> LlmError `llm timeout: connect timeout: ...`;
12
+ * * non-2xx -> LlmError `stream request failed: <status>:
13
+ * <body snippet>` (no API key);
14
+ * * stream idle stall -> stream event failed{kind:"timeout"};
15
+ * * mid-stream decode error -> stream event failed{kind:"stream"};
16
+ * * missing [DONE] -> stream event interrupted.
17
+ *
18
+ * The API key lives in a private field, is sent only as a Bearer header, and
19
+ * is never logged, serialized or echoed into an error message.
20
+ */
21
+ import { type LlmProfile, type ResolvedClientConfig } from "./profile.js";
22
+ import { type ChatCompletionsBody } from "./wire.js";
23
+ import type { Llm, LlmStream, ModelRequestDraft } from "./seam.js";
24
+ import { type EnvLike, type TimeoutTiers } from "./timeouts.js";
25
+ /** Constructor options. Timeout fields: 0 = disabled (`ms_to_duration`). */
26
+ export interface OpenAiCompatOptions {
27
+ baseUrl: string;
28
+ apiKey: string;
29
+ model: string;
30
+ /** Free-form tier string; passed through verbatim, never folded/renamed. */
31
+ reasoningEffort?: string | null;
32
+ maxOutputTokens?: number | null;
33
+ connectTimeoutMs?: number | null;
34
+ responseTimeoutMs?: number | null;
35
+ streamIdleTimeoutMs?: number | null;
36
+ }
37
+ export declare class OpenAiCompatClient implements Llm {
38
+ #private;
39
+ constructor(options: OpenAiCompatOptions);
40
+ /** The configured model (used when a request leaves its model empty). */
41
+ get model(): string;
42
+ /** Effective timeouts (null = that stage is disabled). */
43
+ timeouts(): TimeoutTiers;
44
+ /** Secret-free view of the configuration (safe to log/serialize). */
45
+ describe(): {
46
+ baseUrl: string;
47
+ model: string;
48
+ reasoningEffort: string | null;
49
+ maxOutputTokens: number | null;
50
+ timeouts: TimeoutTiers;
51
+ };
52
+ /** The endpoint this client posts to. */
53
+ endpoint(): string;
54
+ /** Request model wins; the configured model is the fallback. */
55
+ effectiveModel(req: ModelRequestDraft): string;
56
+ /** Serialized request body (reasoning_effort injected verbatim). */
57
+ requestBody(req: ModelRequestDraft, model?: string): ChatCompletionsBody;
58
+ /**
59
+ * Start a streaming turn. Pre-stream failures (model validation, connect /
60
+ * response-header timeout, transport error, non-2xx status) reject with an
61
+ * LlmError; the returned stream then carries the terminal state as an event.
62
+ */
63
+ generate(req: ModelRequestDraft): Promise<LlmStream>;
64
+ /** Build a client from a resolved configuration. */
65
+ static fromConfig(config: ResolvedClientConfig): OpenAiCompatClient;
66
+ /** Options from a runtime profile + environment (api key from env only). */
67
+ static fromProfile(profile?: LlmProfile | null, env?: EnvLike): OpenAiCompatClient;
68
+ }
package/dist/client.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The OpenAI-compatible provider client (P2a).
3
+ *
4
+ * Implements the `Llm` seam on top of the pieces of this package:
5
+ * wire.ts (request body), transport.ts (connect + response-header guards),
6
+ * stream.ts (idle-guarded SSE decoding), usage.ts (usage/cache parsing).
7
+ *
8
+ * Error semantics:
9
+ * * response-header timeout -> LlmError `llm timeout: response headers not
10
+ * received within {N}ms ({url})`, kind "generate";
11
+ * * connect timeout -> LlmError `llm timeout: connect timeout: ...`;
12
+ * * non-2xx -> LlmError `stream request failed: <status>:
13
+ * <body snippet>` (no API key);
14
+ * * stream idle stall -> stream event failed{kind:"timeout"};
15
+ * * mid-stream decode error -> stream event failed{kind:"stream"};
16
+ * * missing [DONE] -> stream event interrupted.
17
+ *
18
+ * The API key lives in a private field, is sent only as a Bearer header, and
19
+ * is never logged, serialized or echoed into an error message.
20
+ */
21
+ import { ImageUnsupportedError, isImageUnsupportedBody, parseRetryAfterHeader, setRetryAfterMs, statusError, } from "./errors.js";
22
+ import { resolveClientConfig, validateModel, } from "./profile.js";
23
+ import { buildRequestBody, chatCompletionsUrl, } from "./wire.js";
24
+ import { httpStatusLabel, readBodySnippet, redact, sendChatRequest } from "./transport.js";
25
+ import { streamEvents } from "./stream.js";
26
+ import { DEFAULT_TIMEOUTS, timeoutMsOf } from "./timeouts.js";
27
+ export class OpenAiCompatClient {
28
+ #baseUrl;
29
+ #apiKey;
30
+ #model;
31
+ #reasoningEffort;
32
+ #maxOutputTokens;
33
+ #connectMs;
34
+ #responseMs;
35
+ #idleMs;
36
+ constructor(options) {
37
+ this.#baseUrl = options.baseUrl;
38
+ this.#apiKey = options.apiKey;
39
+ this.#model = options.model;
40
+ this.#reasoningEffort = options.reasoningEffort ?? null;
41
+ // W835 (R3 batch D / P2-2): 0 = "clear cap" (contracts/endpoints.json:540),
42
+ // never a literal max_tokens:0 on the wire.
43
+ const maxOut = options.maxOutputTokens;
44
+ this.#maxOutputTokens = typeof maxOut === "number" && Number.isFinite(maxOut) && maxOut > 0 ? Math.floor(maxOut) : null;
45
+ this.#connectMs = timeoutMsOf(options.connectTimeoutMs, DEFAULT_TIMEOUTS.connectMs);
46
+ this.#responseMs = timeoutMsOf(options.responseTimeoutMs, DEFAULT_TIMEOUTS.responseMs);
47
+ this.#idleMs = timeoutMsOf(options.streamIdleTimeoutMs, DEFAULT_TIMEOUTS.idleMs);
48
+ }
49
+ /** The configured model (used when a request leaves its model empty). */
50
+ get model() {
51
+ return this.#model;
52
+ }
53
+ /** Effective timeouts (null = that stage is disabled). */
54
+ timeouts() {
55
+ return { connectMs: this.#connectMs, responseMs: this.#responseMs, idleMs: this.#idleMs };
56
+ }
57
+ /** Secret-free view of the configuration (safe to log/serialize). */
58
+ describe() {
59
+ return {
60
+ baseUrl: this.#baseUrl,
61
+ model: this.#model,
62
+ reasoningEffort: this.#reasoningEffort,
63
+ maxOutputTokens: this.#maxOutputTokens,
64
+ timeouts: this.timeouts(),
65
+ };
66
+ }
67
+ /** The endpoint this client posts to. */
68
+ endpoint() {
69
+ return chatCompletionsUrl(this.#baseUrl);
70
+ }
71
+ /** Request model wins; the configured model is the fallback. */
72
+ effectiveModel(req) {
73
+ return req.model === undefined || req.model === "" ? this.#model : req.model;
74
+ }
75
+ /** Serialized request body (reasoning_effort injected verbatim). */
76
+ requestBody(req, model = this.effectiveModel(req)) {
77
+ return buildRequestBody(req, {
78
+ model,
79
+ reasoningEffort: this.#reasoningEffort,
80
+ maxOutputTokens: this.#maxOutputTokens,
81
+ });
82
+ }
83
+ /**
84
+ * Start a streaming turn. Pre-stream failures (model validation, connect /
85
+ * response-header timeout, transport error, non-2xx status) reject with an
86
+ * LlmError; the returned stream then carries the terminal state as an event.
87
+ */
88
+ async generate(req) {
89
+ const model = this.effectiveModel(req);
90
+ validateModel(model);
91
+ const body = this.requestBody(req, model);
92
+ const url = this.endpoint();
93
+ const response = await sendChatRequest({
94
+ url,
95
+ apiKey: this.#apiKey,
96
+ body: JSON.stringify(body),
97
+ connectMs: this.#connectMs,
98
+ responseMs: this.#responseMs,
99
+ });
100
+ await assertSuccess(response, this.#apiKey);
101
+ return streamEvents(response, this.#idleMs);
102
+ }
103
+ /** Build a client from a resolved configuration. */
104
+ static fromConfig(config) {
105
+ return new OpenAiCompatClient({
106
+ baseUrl: config.baseUrl,
107
+ apiKey: config.apiKey,
108
+ model: config.model,
109
+ reasoningEffort: config.reasoningEffort,
110
+ maxOutputTokens: config.maxOutputTokens,
111
+ connectTimeoutMs: config.connectTimeoutMs,
112
+ responseTimeoutMs: config.responseTimeoutMs,
113
+ streamIdleTimeoutMs: config.streamIdleTimeoutMs,
114
+ });
115
+ }
116
+ /** Options from a runtime profile + environment (api key from env only). */
117
+ static fromProfile(profile, env = process.env) {
118
+ return OpenAiCompatClient.fromConfig(resolveClientConfig(profile, env));
119
+ }
120
+ }
121
+ /** Reject with a status-bearing error when the response is not 2xx. */
122
+ async function assertSuccess(response, apiKey) {
123
+ const status = response.statusCode ?? 0;
124
+ if (status >= 200 && status < 300)
125
+ return;
126
+ const text = await readBodySnippet(response);
127
+ response.destroy();
128
+ const label = httpStatusLabel(status, response.statusMessage);
129
+ // W804 section 7.6: a 4xx whose body names the image modality is classified
130
+ // BEFORE the generic status error, so the downgrade decorator can react.
131
+ // W824 N2: also replace this client's own key literally - an upstream may
132
+ // echo an arbitrary provider key that matches no token shape.
133
+ const body = redact(text, [apiKey]);
134
+ const error = status >= 400 && status < 500 && isImageUnsupportedBody(text)
135
+ ? new ImageUnsupportedError(status, label, body)
136
+ : statusError(status, label, body);
137
+ // E §4.2.2 P1: the header is captured here, where the response is still in
138
+ // hand; the P0 error object and its message stay byte-for-byte unchanged.
139
+ setRetryAfterMs(error, parseRetryAfterHeader(response.headers["retry-after"]));
140
+ throw error;
141
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Structured LLM errors (P2a; status/retryability fields: iteration E §4 P0).
3
+ *
4
+ * A1 (W746): the `LlmError` class and its `LlmErrorKind` / `TimeoutStage` /
5
+ * `LlmErrorOptions` vocabulary live in `@celestea/core` (`core/src/stream.ts`)
6
+ * and are re-exported here — this package no longer owns a second `LlmError`,
7
+ * so `instanceof LlmError` and `isTimeoutError` agree across packages. What
8
+ * stays here is provider-side and cannot move to core: the canonical timeout
9
+ * prefix and the error builders.
10
+ *
11
+ * The semantics ride the canonical `llm timeout` message prefix: an error
12
+ * thrown out of `generate` maps to `TurnOutcome::Error { kind: "generate" }`, a
13
+ * stalled stream maps to `kind: "timeout"`, a mid-stream decode failure to
14
+ * `kind: "stream"`. TypeScript can also carry that distinction explicitly, so
15
+ * `LlmError` exposes `kind` (the turn-outcome kind a caller should report) plus
16
+ * `isTimeout` and the stage that tripped.
17
+ *
18
+ * Iteration E §4 P0 adds the machine-readable *failure cause* on top of the
19
+ * message text: `httpStatus` (the status of the response that failed, `null`
20
+ * when no response ever arrived) and `retryable` (whether another attempt or
21
+ * another target could plausibly succeed). The defaults are the conservative
22
+ * pair `(null, false)`: a failure carrying no evidence of being transient is
23
+ * treated as a local/configuration problem, not as something to retry.
24
+ * Nothing reads these fields yet — P0 is observability only, so every message,
25
+ * throw site, SSE frame and statusline field is byte-for-byte unchanged.
26
+ */
27
+ import { LlmError, type LlmErrorKind, type LlmErrorOptions, type TimeoutStage } from "@celestea/core";
28
+ export { LlmError };
29
+ export type { LlmErrorKind, LlmErrorOptions, TimeoutStage };
30
+ /** Canonical prefix of every timeout error (`TIMEOUT_ERROR_PREFIX`). */
31
+ export declare const TIMEOUT_ERROR_PREFIX = "llm timeout";
32
+ /**
33
+ * Non-5xx statuses worth retrying (§4.2.2 `retryableStatuses`): request
34
+ * timeout, too early, rate limited. Every 5xx counts as retryable as well.
35
+ */
36
+ export declare const RETRYABLE_HTTP_STATUSES: readonly number[];
37
+ /** Would another attempt / another target help, judged from the status alone? */
38
+ export declare function isRetryableStatus(status: number | null): boolean;
39
+ /**
40
+ * Build the non-2xx error. The message format is unchanged from W511
41
+ * (`stream request failed: <label>: <body snippet>`): the status becomes
42
+ * machine-readable *in addition* to the text, never instead of it.
43
+ */
44
+ export declare function statusError(status: number, label: string, bodySnippet?: string): LlmError;
45
+ /**
46
+ * W804 (section 7.6): the upstream report patterns that mean "this model cannot
47
+ * take an image". ONLY known patterns are classified; anything else stays an
48
+ * ordinary status error (we never guess).
49
+ */
50
+ export declare const IMAGE_UNSUPPORTED_MARKERS: readonly string[];
51
+ /** True when an upstream error body carries a known image-unsupported marker. */
52
+ export declare function isImageUnsupportedBody(body: string): boolean;
53
+ /**
54
+ * A 4xx whose body proves the model rejected image input (section 7.6). It is an
55
+ * LlmError (kind "generate", httpStatus set, NOT retryable to another target)
56
+ * and carries `imageUnsupported = true` so the downgrade decorator recognises it.
57
+ */
58
+ export declare class ImageUnsupportedError extends LlmError {
59
+ readonly imageUnsupported = true;
60
+ constructor(status: number, label: string, bodySnippet: string);
61
+ }
62
+ /** Recognise an [ImageUnsupportedError] across a structural (re-boxed) boundary. */
63
+ export declare function isImageUnsupportedError(e: unknown): e is ImageUnsupportedError;
64
+ /** Transport failure before any response (DNS/TCP/TLS/socket): retryable. */
65
+ export declare function networkError(message: string): LlmError;
66
+ /**
67
+ * The caller aborted the turn. Cooperative cancellation does not normally
68
+ * reach this package (the runner resolves it as the `cancelled` outcome), so
69
+ * this is the structured vocabulary for an aborted request rather than a new
70
+ * throw site; an abort is never retryable (§4.2.2).
71
+ */
72
+ export declare function cancelledError(message?: string): LlmError;
73
+ /** Build a structured timeout error with the canonical prefix. */
74
+ export declare function timeoutError(detail: string, stage?: TimeoutStage | null): LlmError;
75
+ /** Response headers never arrived: `llm timeout: response headers ... (url)`. */
76
+ export declare function responseHeaderTimeoutError(ms: number, url: string): LlmError;
77
+ /** TCP/TLS connect never completed within the connect timeout. */
78
+ export declare function connectTimeoutError(ms: number, url: string): LlmError;
79
+ /** Message of a stalled-stream failure (yielded as failed{kind:"timeout"}). */
80
+ export declare function streamIdleTimeoutMessage(ms: number): string;
81
+ /** Attach a parsed `Retry-After` (ms) to the error that carries it. */
82
+ export declare function setRetryAfterMs(error: unknown, ms: number | null): void;
83
+ /** The parsed `Retry-After` of an error, or null when it carried none. */
84
+ export declare function retryAfterMsOf(error: unknown): number | null;
85
+ /**
86
+ * `Retry-After` -> milliseconds. Both forms of RFC 9110 are accepted: a
87
+ * delta-seconds value and an HTTP-date. An unparsable header (or a date in the
88
+ * past) is "no wait", never a thrown error.
89
+ */
90
+ export declare function parseRetryAfterHeader(value: string | string[] | undefined, now?: number): number | null;
91
+ /** True for timeout errors (these map to kind "generate" out of generate()). */
92
+ export declare function isTimeoutError(e: unknown): boolean;
93
+ /** The turn-outcome kind an error maps to (defaults to "generate"). */
94
+ export declare function errorKind(e: unknown): LlmErrorKind;
package/dist/errors.js ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Structured LLM errors (P2a; status/retryability fields: iteration E §4 P0).
3
+ *
4
+ * A1 (W746): the `LlmError` class and its `LlmErrorKind` / `TimeoutStage` /
5
+ * `LlmErrorOptions` vocabulary live in `@celestea/core` (`core/src/stream.ts`)
6
+ * and are re-exported here — this package no longer owns a second `LlmError`,
7
+ * so `instanceof LlmError` and `isTimeoutError` agree across packages. What
8
+ * stays here is provider-side and cannot move to core: the canonical timeout
9
+ * prefix and the error builders.
10
+ *
11
+ * The semantics ride the canonical `llm timeout` message prefix: an error
12
+ * thrown out of `generate` maps to `TurnOutcome::Error { kind: "generate" }`, a
13
+ * stalled stream maps to `kind: "timeout"`, a mid-stream decode failure to
14
+ * `kind: "stream"`. TypeScript can also carry that distinction explicitly, so
15
+ * `LlmError` exposes `kind` (the turn-outcome kind a caller should report) plus
16
+ * `isTimeout` and the stage that tripped.
17
+ *
18
+ * Iteration E §4 P0 adds the machine-readable *failure cause* on top of the
19
+ * message text: `httpStatus` (the status of the response that failed, `null`
20
+ * when no response ever arrived) and `retryable` (whether another attempt or
21
+ * another target could plausibly succeed). The defaults are the conservative
22
+ * pair `(null, false)`: a failure carrying no evidence of being transient is
23
+ * treated as a local/configuration problem, not as something to retry.
24
+ * Nothing reads these fields yet — P0 is observability only, so every message,
25
+ * throw site, SSE frame and statusline field is byte-for-byte unchanged.
26
+ */
27
+ import { LlmError } from "@celestea/core";
28
+ export { LlmError };
29
+ /** Canonical prefix of every timeout error (`TIMEOUT_ERROR_PREFIX`). */
30
+ export const TIMEOUT_ERROR_PREFIX = "llm timeout";
31
+ /**
32
+ * Non-5xx statuses worth retrying (§4.2.2 `retryableStatuses`): request
33
+ * timeout, too early, rate limited. Every 5xx counts as retryable as well.
34
+ */
35
+ export const RETRYABLE_HTTP_STATUSES = [408, 425, 429];
36
+ /** Would another attempt / another target help, judged from the status alone? */
37
+ export function isRetryableStatus(status) {
38
+ if (status === null)
39
+ return false;
40
+ return status >= 500 || RETRYABLE_HTTP_STATUSES.includes(status);
41
+ }
42
+ /**
43
+ * Build the non-2xx error. The message format is unchanged from W511
44
+ * (`stream request failed: <label>: <body snippet>`): the status becomes
45
+ * machine-readable *in addition* to the text, never instead of it.
46
+ */
47
+ export function statusError(status, label, bodySnippet = "") {
48
+ return new LlmError(`stream request failed: ${label}: ${bodySnippet}`, "generate", {
49
+ httpStatus: status,
50
+ retryable: isRetryableStatus(status),
51
+ });
52
+ }
53
+ /**
54
+ * W804 (section 7.6): the upstream report patterns that mean "this model cannot
55
+ * take an image". ONLY known patterns are classified; anything else stays an
56
+ * ordinary status error (we never guess).
57
+ */
58
+ export const IMAGE_UNSUPPORTED_MARKERS = [
59
+ "multimodal input is not supported",
60
+ "model only supports text input",
61
+ "unsupported content type 'image_url'",
62
+ "unsupported content type image_url",
63
+ ];
64
+ /** True when an upstream error body carries a known image-unsupported marker. */
65
+ export function isImageUnsupportedBody(body) {
66
+ const text = body.toLowerCase();
67
+ return IMAGE_UNSUPPORTED_MARKERS.some((marker) => text.includes(marker));
68
+ }
69
+ /**
70
+ * A 4xx whose body proves the model rejected image input (section 7.6). It is an
71
+ * LlmError (kind "generate", httpStatus set, NOT retryable to another target)
72
+ * and carries `imageUnsupported = true` so the downgrade decorator recognises it.
73
+ */
74
+ export class ImageUnsupportedError extends LlmError {
75
+ imageUnsupported = true;
76
+ constructor(status, label, bodySnippet) {
77
+ super(`stream request failed: ${label}: ${bodySnippet}`, "generate", { httpStatus: status, retryable: false });
78
+ this.name = "ImageUnsupportedError";
79
+ }
80
+ }
81
+ /** Recognise an [ImageUnsupportedError] across a structural (re-boxed) boundary. */
82
+ export function isImageUnsupportedError(e) {
83
+ if (e instanceof ImageUnsupportedError)
84
+ return true;
85
+ return typeof e === "object" && e !== null && e["imageUnsupported"] === true;
86
+ }
87
+ /** Transport failure before any response (DNS/TCP/TLS/socket): retryable. */
88
+ export function networkError(message) {
89
+ return new LlmError(message, "generate", { retryable: true });
90
+ }
91
+ /**
92
+ * The caller aborted the turn. Cooperative cancellation does not normally
93
+ * reach this package (the runner resolves it as the `cancelled` outcome), so
94
+ * this is the structured vocabulary for an aborted request rather than a new
95
+ * throw site; an abort is never retryable (§4.2.2).
96
+ */
97
+ export function cancelledError(message = "turn cancelled by the caller") {
98
+ return new LlmError(message, "generate", { retryable: false });
99
+ }
100
+ /** Build a structured timeout error with the canonical prefix. */
101
+ export function timeoutError(detail, stage = null) {
102
+ return new LlmError(`${TIMEOUT_ERROR_PREFIX}: ${detail}`, "generate", {
103
+ isTimeout: true,
104
+ timeoutStage: stage,
105
+ retryable: true,
106
+ });
107
+ }
108
+ /** Response headers never arrived: `llm timeout: response headers ... (url)`. */
109
+ export function responseHeaderTimeoutError(ms, url) {
110
+ return timeoutError(`response headers not received within ${ms}ms (${url})`, "response");
111
+ }
112
+ /** TCP/TLS connect never completed within the connect timeout. */
113
+ export function connectTimeoutError(ms, url) {
114
+ return timeoutError(`connect timeout: no TCP connection within ${ms}ms (${url})`, "connect");
115
+ }
116
+ /** Message of a stalled-stream failure (yielded as failed{kind:"timeout"}). */
117
+ export function streamIdleTimeoutMessage(ms) {
118
+ return `stream idle timeout: no data chunk for ${ms}ms`;
119
+ }
120
+ /**
121
+ * `Retry-After` of a failed response, kept OUT of `LlmError`.
122
+ *
123
+ * E §4.2.2 P1 honours the header, but `LlmError` lives in `@celestea/core` and
124
+ * K7/§4.6 forbid widening it for a host-side policy detail. The header is
125
+ * therefore attached on a side channel keyed by the error object: still
126
+ * zero-copy, still invisible to every existing reader (`errors.test.ts` asserts
127
+ * the core fields, which are unchanged), and it never leaks into a message.
128
+ */
129
+ const RETRY_AFTER_MS = new WeakMap();
130
+ /** Attach a parsed `Retry-After` (ms) to the error that carries it. */
131
+ export function setRetryAfterMs(error, ms) {
132
+ if (ms === null || !Number.isFinite(ms) || ms < 0)
133
+ return;
134
+ if (typeof error === "object" && error !== null)
135
+ RETRY_AFTER_MS.set(error, Math.floor(ms));
136
+ }
137
+ /** The parsed `Retry-After` of an error, or null when it carried none. */
138
+ export function retryAfterMsOf(error) {
139
+ if (typeof error !== "object" || error === null)
140
+ return null;
141
+ return RETRY_AFTER_MS.get(error) ?? null;
142
+ }
143
+ /**
144
+ * `Retry-After` -> milliseconds. Both forms of RFC 9110 are accepted: a
145
+ * delta-seconds value and an HTTP-date. An unparsable header (or a date in the
146
+ * past) is "no wait", never a thrown error.
147
+ */
148
+ export function parseRetryAfterHeader(value, now = Date.now()) {
149
+ const raw = Array.isArray(value) ? value[0] : value;
150
+ if (raw === undefined)
151
+ return null;
152
+ const text = raw.trim();
153
+ if (text === "")
154
+ return null;
155
+ if (/^\d+$/.test(text))
156
+ return Number.parseInt(text, 10) * 1000;
157
+ const at = Date.parse(text);
158
+ if (Number.isNaN(at))
159
+ return null;
160
+ return Math.max(0, at - now);
161
+ }
162
+ /** True for timeout errors (these map to kind "generate" out of generate()). */
163
+ export function isTimeoutError(e) {
164
+ return e instanceof LlmError && e.isTimeout;
165
+ }
166
+ /** The turn-outcome kind an error maps to (defaults to "generate"). */
167
+ export function errorKind(e) {
168
+ return e instanceof LlmError ? e.kind : "generate";
169
+ }