@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 +21 -0
- package/README.md +141 -0
- package/dist/client.d.ts +68 -0
- package/dist/client.js +141 -0
- package/dist/errors.d.ts +94 -0
- package/dist/errors.js +169 -0
- package/dist/factory.d.ts +57 -0
- package/dist/factory.js +75 -0
- package/dist/fallback-config.d.ts +62 -0
- package/dist/fallback-config.js +151 -0
- package/dist/fallback.d.ts +163 -0
- package/dist/fallback.js +304 -0
- package/dist/host.d.ts +10 -0
- package/dist/host.js +19 -0
- package/dist/image-fallback.d.ts +77 -0
- package/dist/image-fallback.js +129 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +22 -0
- package/dist/profile.d.ts +65 -0
- package/dist/profile.js +82 -0
- package/dist/provider.d.ts +27 -0
- package/dist/provider.js +37 -0
- package/dist/seam.d.ts +90 -0
- package/dist/seam.js +35 -0
- package/dist/sse/chunks.d.ts +62 -0
- package/dist/sse/chunks.js +178 -0
- package/dist/sse/frames.d.ts +41 -0
- package/dist/sse/frames.js +122 -0
- package/dist/stream.d.ts +46 -0
- package/dist/stream.js +240 -0
- package/dist/timeouts.d.ts +73 -0
- package/dist/timeouts.js +121 -0
- package/dist/transport.d.ts +41 -0
- package/dist/transport.js +147 -0
- package/dist/usage.d.ts +54 -0
- package/dist/usage.js +92 -0
- package/dist/wire.d.ts +103 -0
- package/dist/wire.js +190 -0
- package/package.json +28 -0
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE stream -> seam events (P2a).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `crates/llm/src/client.rs::{raw_chunk_stream, stream_events}`:
|
|
5
|
+
* * the body is read chunk by chunk with the SSE idle guard applied to the
|
|
6
|
+
* gap between any two chunks (including the wait for the first one);
|
|
7
|
+
* * frames are decoded incrementally, `[DONE]` terminates, keepalive and
|
|
8
|
+
* non-JSON frames are skipped, and an upstream error frame terminates the
|
|
9
|
+
* stream as failed{kind:"stream"} (never a fake done);
|
|
10
|
+
* * reasoning deltas stream as thinking events, content as text deltas, tool
|
|
11
|
+
* calls accumulate per index, usage is surfaced just before the terminal
|
|
12
|
+
* event;
|
|
13
|
+
* * the terminal event is exactly one of done / failed / interrupted — an
|
|
14
|
+
* idle stall yields failed{kind:"timeout"}, a mid-stream decode error
|
|
15
|
+
* failed{kind:"stream"}, and a stream that ends without [DONE] yields
|
|
16
|
+
* interrupted. Never a fake done (R1).
|
|
17
|
+
*/
|
|
18
|
+
import type http from "node:http";
|
|
19
|
+
import { type RawChunk } from "./sse/chunks.js";
|
|
20
|
+
import type { Message, StreamEvent } from "./seam.js";
|
|
21
|
+
import type { Usage } from "./usage.js";
|
|
22
|
+
/** Internal signal: the idle guard tripped and terminated the body read. */
|
|
23
|
+
export declare class StreamIdleAbort extends Error {
|
|
24
|
+
}
|
|
25
|
+
/** Accumulates one turn's deltas and assembles the terminal assistant message. */
|
|
26
|
+
export declare class TurnAccumulator {
|
|
27
|
+
#private;
|
|
28
|
+
/** Fold one decoded chunk in; returns the live events it produced. */
|
|
29
|
+
push(chunk: RawChunk): StreamEvent[];
|
|
30
|
+
/** The last seen provider usage (usage-only final frame or last chunk). */
|
|
31
|
+
get usage(): Usage | null;
|
|
32
|
+
/** The assembled assistant turn (text first, then tool calls by index). */
|
|
33
|
+
doneMessage(): Message;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Byte chunks of the response body with the SSE idle guard applied to the gap
|
|
37
|
+
* between any two chunks. Throws StreamIdleAbort when the upstream stalls and
|
|
38
|
+
* the transport error itself when the body read fails mid-stream.
|
|
39
|
+
*/
|
|
40
|
+
export declare function readBodyChunks(response: http.IncomingMessage, idleMs: number | null): AsyncGenerator<Buffer>;
|
|
41
|
+
/**
|
|
42
|
+
* Decode a 2xx SSE response into seam events. The generator always ends with
|
|
43
|
+
* exactly one terminal event (done | failed | interrupted) or an empty stream
|
|
44
|
+
* only when the caller stops iterating early.
|
|
45
|
+
*/
|
|
46
|
+
export declare function streamEvents(response: http.IncomingMessage, idleMs: number | null): AsyncGenerator<StreamEvent>;
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE stream -> seam events (P2a).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `crates/llm/src/client.rs::{raw_chunk_stream, stream_events}`:
|
|
5
|
+
* * the body is read chunk by chunk with the SSE idle guard applied to the
|
|
6
|
+
* gap between any two chunks (including the wait for the first one);
|
|
7
|
+
* * frames are decoded incrementally, `[DONE]` terminates, keepalive and
|
|
8
|
+
* non-JSON frames are skipped, and an upstream error frame terminates the
|
|
9
|
+
* stream as failed{kind:"stream"} (never a fake done);
|
|
10
|
+
* * reasoning deltas stream as thinking events, content as text deltas, tool
|
|
11
|
+
* calls accumulate per index, usage is surfaced just before the terminal
|
|
12
|
+
* event;
|
|
13
|
+
* * the terminal event is exactly one of done / failed / interrupted — an
|
|
14
|
+
* idle stall yields failed{kind:"timeout"}, a mid-stream decode error
|
|
15
|
+
* failed{kind:"stream"}, and a stream that ends without [DONE] yields
|
|
16
|
+
* interrupted. Never a fake done (R1).
|
|
17
|
+
*/
|
|
18
|
+
import { StringDecoder } from "node:string_decoder";
|
|
19
|
+
import { streamIdleTimeoutMessage } from "./errors.js";
|
|
20
|
+
import { SseDecoder } from "./sse/frames.js";
|
|
21
|
+
import { parseArguments, parseRawChunk, parseStreamError, thinkingEvent, } from "./sse/chunks.js";
|
|
22
|
+
/** Internal signal: the idle guard tripped and terminated the body read. */
|
|
23
|
+
export class StreamIdleAbort extends Error {
|
|
24
|
+
}
|
|
25
|
+
/** Accumulates one turn's deltas and assembles the terminal assistant message. */
|
|
26
|
+
export class TurnAccumulator {
|
|
27
|
+
#text = "";
|
|
28
|
+
#usage = null;
|
|
29
|
+
#calls = new Map();
|
|
30
|
+
/** Fold one decoded chunk in; returns the live events it produced. */
|
|
31
|
+
push(chunk) {
|
|
32
|
+
const events = [];
|
|
33
|
+
if (chunk.reasoning !== undefined) {
|
|
34
|
+
const event = thinkingEvent(chunk.reasoning);
|
|
35
|
+
if (event !== null)
|
|
36
|
+
events.push(event);
|
|
37
|
+
}
|
|
38
|
+
for (const choice of chunk.choices) {
|
|
39
|
+
if (choice.text !== undefined && choice.text !== "") {
|
|
40
|
+
this.#text += choice.text;
|
|
41
|
+
events.push({ kind: "text", text: choice.text });
|
|
42
|
+
}
|
|
43
|
+
for (const fragment of choice.toolCalls)
|
|
44
|
+
this.#addFragment(fragment);
|
|
45
|
+
}
|
|
46
|
+
if (chunk.usage !== undefined)
|
|
47
|
+
this.#usage = chunk.usage;
|
|
48
|
+
return events;
|
|
49
|
+
}
|
|
50
|
+
#addFragment(fragment) {
|
|
51
|
+
const entry = this.#calls.get(fragment.index) ?? { id: "", name: "", arguments: "" };
|
|
52
|
+
if (fragment.id !== undefined)
|
|
53
|
+
entry.id = fragment.id;
|
|
54
|
+
if (fragment.name !== undefined)
|
|
55
|
+
entry.name += fragment.name;
|
|
56
|
+
if (fragment.arguments !== undefined)
|
|
57
|
+
entry.arguments += fragment.arguments;
|
|
58
|
+
this.#calls.set(fragment.index, entry);
|
|
59
|
+
}
|
|
60
|
+
/** The last seen provider usage (usage-only final frame or last chunk). */
|
|
61
|
+
get usage() {
|
|
62
|
+
return this.#usage;
|
|
63
|
+
}
|
|
64
|
+
/** The assembled assistant turn (text first, then tool calls by index). */
|
|
65
|
+
doneMessage() {
|
|
66
|
+
const content = [];
|
|
67
|
+
if (this.#text !== "")
|
|
68
|
+
content.push({ type: "text", content: this.#text });
|
|
69
|
+
for (const index of [...this.#calls.keys()].sort((a, b) => a - b)) {
|
|
70
|
+
const entry = this.#calls.get(index);
|
|
71
|
+
if (entry === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
content.push({
|
|
74
|
+
type: "tool_call",
|
|
75
|
+
content: { id: entry.id, name: entry.name, args: parseArguments(entry.arguments) },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return { role: "assistant", content, tool_call_id: null };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Fold a batch of frames in; `done` reports the [DONE] sentinel and `failure`
|
|
83
|
+
* an upstream error frame (W835 R3 batch C / P1-1).
|
|
84
|
+
*/
|
|
85
|
+
function processFrames(frames, turn) {
|
|
86
|
+
const events = [];
|
|
87
|
+
for (const frame of frames) {
|
|
88
|
+
if (frame.data === "[DONE]")
|
|
89
|
+
return { events, done: true, failure: null };
|
|
90
|
+
if (frame.event === "keepalive")
|
|
91
|
+
continue;
|
|
92
|
+
const chunk = parseRawChunk(frame.data);
|
|
93
|
+
if (chunk !== undefined) {
|
|
94
|
+
events.push(...turn.push(chunk));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const error = parseStreamError(frame.data);
|
|
98
|
+
if (error !== undefined) {
|
|
99
|
+
// An upstream error frame is a terminal failure, never skippable noise:
|
|
100
|
+
// a following [DONE] must not turn it into a fake `done`.
|
|
101
|
+
return {
|
|
102
|
+
events,
|
|
103
|
+
done: false,
|
|
104
|
+
failure: { kind: "failed", kindOf: "stream", message: "upstream stream error: " + error },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { events, done: false, failure: null };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Byte chunks of the response body with the SSE idle guard applied to the gap
|
|
112
|
+
* between any two chunks. Throws StreamIdleAbort when the upstream stalls and
|
|
113
|
+
* the transport error itself when the body read fails mid-stream.
|
|
114
|
+
*/
|
|
115
|
+
export async function* readBodyChunks(response, idleMs) {
|
|
116
|
+
const queue = [];
|
|
117
|
+
let ended = false;
|
|
118
|
+
let failure = null;
|
|
119
|
+
let notify = null;
|
|
120
|
+
const wake = () => {
|
|
121
|
+
const fn = notify;
|
|
122
|
+
notify = null;
|
|
123
|
+
if (fn !== null)
|
|
124
|
+
fn();
|
|
125
|
+
};
|
|
126
|
+
const onData = (chunk) => {
|
|
127
|
+
queue.push(chunk);
|
|
128
|
+
wake();
|
|
129
|
+
};
|
|
130
|
+
const onEnd = () => {
|
|
131
|
+
ended = true;
|
|
132
|
+
wake();
|
|
133
|
+
};
|
|
134
|
+
const onError = (err) => {
|
|
135
|
+
failure = err;
|
|
136
|
+
wake();
|
|
137
|
+
};
|
|
138
|
+
const onClose = () => {
|
|
139
|
+
ended = true;
|
|
140
|
+
wake();
|
|
141
|
+
};
|
|
142
|
+
response.on("data", onData);
|
|
143
|
+
response.on("end", onEnd);
|
|
144
|
+
response.on("error", onError);
|
|
145
|
+
response.on("close", onClose);
|
|
146
|
+
response.resume();
|
|
147
|
+
try {
|
|
148
|
+
for (;;) {
|
|
149
|
+
if (queue.length > 0) {
|
|
150
|
+
const chunk = queue.shift();
|
|
151
|
+
if (chunk !== undefined)
|
|
152
|
+
yield chunk;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (failure !== null)
|
|
156
|
+
throw failure;
|
|
157
|
+
if (ended)
|
|
158
|
+
return;
|
|
159
|
+
await waitForData(idleMs, (resume) => (notify = resume));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
response.off("data", onData);
|
|
164
|
+
response.off("end", onEnd);
|
|
165
|
+
response.off("error", onError);
|
|
166
|
+
response.off("close", onClose);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Wait for the next chunk; rejects with StreamIdleAbort on an idle gap. */
|
|
170
|
+
function waitForData(idleMs, register) {
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
let timer = null;
|
|
173
|
+
register(() => {
|
|
174
|
+
if (timer !== null)
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
resolve();
|
|
177
|
+
});
|
|
178
|
+
if (idleMs !== null) {
|
|
179
|
+
timer = setTimeout(() => {
|
|
180
|
+
register(() => { });
|
|
181
|
+
reject(new StreamIdleAbort(streamIdleTimeoutMessage(idleMs)));
|
|
182
|
+
}, idleMs);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Decode a 2xx SSE response into seam events. The generator always ends with
|
|
188
|
+
* exactly one terminal event (done | failed | interrupted) or an empty stream
|
|
189
|
+
* only when the caller stops iterating early.
|
|
190
|
+
*/
|
|
191
|
+
export async function* streamEvents(response, idleMs) {
|
|
192
|
+
const decoder = new SseDecoder();
|
|
193
|
+
const textDecoder = new StringDecoder("utf8");
|
|
194
|
+
const turn = new TurnAccumulator();
|
|
195
|
+
let sawDone = false;
|
|
196
|
+
let failure = null;
|
|
197
|
+
try {
|
|
198
|
+
for await (const bytes of readBodyChunks(response, idleMs)) {
|
|
199
|
+
const result = processFrames(decoder.push(textDecoder.write(bytes)), turn);
|
|
200
|
+
yield* result.events;
|
|
201
|
+
if (result.failure !== null) {
|
|
202
|
+
failure = result.failure;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
if (result.done) {
|
|
206
|
+
sawDone = true;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
failure = streamFailure(err);
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
response.destroy();
|
|
216
|
+
}
|
|
217
|
+
// Usage rides just before the terminal event, so consumers that
|
|
218
|
+
// treat the terminal event as the end still observe it.
|
|
219
|
+
if (turn.usage !== null)
|
|
220
|
+
yield { kind: "usage", usage: turn.usage };
|
|
221
|
+
if (failure !== null) {
|
|
222
|
+
yield failure;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (!sawDone) {
|
|
226
|
+
// The upstream ended before the [DONE] sentinel: torn stream.
|
|
227
|
+
yield { kind: "interrupted" };
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
yield { kind: "done", message: turn.doneMessage() };
|
|
231
|
+
}
|
|
232
|
+
/** Map a body-read failure onto its terminal event (R1: never a fake done). */
|
|
233
|
+
function streamFailure(err) {
|
|
234
|
+
if (err instanceof StreamIdleAbort) {
|
|
235
|
+
// W266: a stalled stream is a terminal timeout with its own kindOf.
|
|
236
|
+
return { kind: "failed", kindOf: "timeout", message: err.message };
|
|
237
|
+
}
|
|
238
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
239
|
+
return { kind: "failed", kindOf: "stream", message: `sse decode error: ${detail}` };
|
|
240
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three LLM timeout tiers (P2a).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `crates/llm/src/config.rs` +
|
|
5
|
+
* `crates/runtime/src/config.rs::resolve_llm_timeout_ms`:
|
|
6
|
+
*
|
|
7
|
+
* * connect timeout — TCP/TLS handshake only (15s default);
|
|
8
|
+
* * response-header time — send() -> response headers (60s default);
|
|
9
|
+
* * stream idle time — gap between any two SSE data chunks (90s default).
|
|
10
|
+
*
|
|
11
|
+
* There is deliberately NO total-request timeout: a long generation streams
|
|
12
|
+
* tokens continuously and must never be killed by a response-idle guard.
|
|
13
|
+
*
|
|
14
|
+
* Precedence per tier: CELESTEA_LLM_* env var > profile key > built-in default;
|
|
15
|
+
* 0 disables that stage; blank/unparseable env values are ignored (lenient).
|
|
16
|
+
*/
|
|
17
|
+
export type EnvLike = Record<string, string | undefined>;
|
|
18
|
+
/** Env overrides for the three timeout tiers (milliseconds). */
|
|
19
|
+
export declare const CONNECT_TIMEOUT_ENV = "CELESTEA_LLM_CONNECT_TIMEOUT_MS";
|
|
20
|
+
export declare const RESPONSE_TIMEOUT_ENV = "CELESTEA_LLM_RESPONSE_TIMEOUT_MS";
|
|
21
|
+
export declare const STREAM_IDLE_TIMEOUT_ENV = "CELESTEA_LLM_STREAM_IDLE_TIMEOUT_MS";
|
|
22
|
+
/** Default TCP/TLS connect timeout (connects are fast when healthy). */
|
|
23
|
+
export declare const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
|
|
24
|
+
/** Default send() -> response-headers timeout. */
|
|
25
|
+
export declare const DEFAULT_RESPONSE_TIMEOUT_MS = 60000;
|
|
26
|
+
/** Default SSE inter-chunk idle timeout. */
|
|
27
|
+
export declare const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90000;
|
|
28
|
+
/** Profile JSON keys for the three tiers. */
|
|
29
|
+
export declare const PROFILE_TIMEOUT_KEYS: {
|
|
30
|
+
readonly connect: "llm_connect_timeout_ms";
|
|
31
|
+
readonly response: "llm_response_timeout_ms";
|
|
32
|
+
readonly idle: "llm_stream_idle_timeout_ms";
|
|
33
|
+
};
|
|
34
|
+
/** The three timeout tiers as configured in a runtime profile (all optional). */
|
|
35
|
+
export interface TimeoutProfile {
|
|
36
|
+
llm_connect_timeout_ms?: number | null;
|
|
37
|
+
llm_response_timeout_ms?: number | null;
|
|
38
|
+
llm_stream_idle_timeout_ms?: number | null;
|
|
39
|
+
}
|
|
40
|
+
/** Effective timeouts; null means "this stage is disabled" (configured 0). */
|
|
41
|
+
export interface TimeoutTiers {
|
|
42
|
+
connectMs: number | null;
|
|
43
|
+
responseMs: number | null;
|
|
44
|
+
idleMs: number | null;
|
|
45
|
+
}
|
|
46
|
+
/** Built-in defaults (nothing configured). */
|
|
47
|
+
export declare const DEFAULT_TIMEOUTS: TimeoutTiers;
|
|
48
|
+
/**
|
|
49
|
+
* Milliseconds -> tier value; 0 disables (null); absent/invalid -> fallback.
|
|
50
|
+
*
|
|
51
|
+
* W835 (R3 batch D / P2-6): this is the ONE "0 = disabled" mapping, shared by
|
|
52
|
+
* the client constructor (`client.ts`) and by [tiersFromConfig]. It replaced
|
|
53
|
+
* the unused, duplicate legacy helper so a 0/null fix cannot be applied in one
|
|
54
|
+
* place and missed in the other.
|
|
55
|
+
*/
|
|
56
|
+
export declare function timeoutMsOf(ms: number | null | undefined, fallbackMs: number | null): number | null;
|
|
57
|
+
/** Is this a usable profile/env value (non-negative integer of ms)? */
|
|
58
|
+
export declare function isTimeoutMs(value: unknown): value is number;
|
|
59
|
+
/**
|
|
60
|
+
* Resolve one tier: env (set, trimmed, parseable) > profile value > default.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveTimeoutMs(profileValue: number | null | undefined, envValue: string | undefined, defaultMs: number): number;
|
|
63
|
+
/** Resolve all three tiers (profile keys + CELESTEA_LLM_* env overrides). */
|
|
64
|
+
export declare function resolveTimeoutTiers(profile?: TimeoutProfile | null, env?: EnvLike): TimeoutTiers;
|
|
65
|
+
/**
|
|
66
|
+
* Lenient profile reader: non-negative integers pass through, everything else
|
|
67
|
+
* is reported (never fatal) — mirrors the strict parse in
|
|
68
|
+
* `crates/runtime/src/config.rs`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function readTimeoutProfile(raw: unknown): {
|
|
71
|
+
profile: TimeoutProfile;
|
|
72
|
+
errors: string[];
|
|
73
|
+
};
|
package/dist/timeouts.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three LLM timeout tiers (P2a).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `crates/llm/src/config.rs` +
|
|
5
|
+
* `crates/runtime/src/config.rs::resolve_llm_timeout_ms`:
|
|
6
|
+
*
|
|
7
|
+
* * connect timeout — TCP/TLS handshake only (15s default);
|
|
8
|
+
* * response-header time — send() -> response headers (60s default);
|
|
9
|
+
* * stream idle time — gap between any two SSE data chunks (90s default).
|
|
10
|
+
*
|
|
11
|
+
* There is deliberately NO total-request timeout: a long generation streams
|
|
12
|
+
* tokens continuously and must never be killed by a response-idle guard.
|
|
13
|
+
*
|
|
14
|
+
* Precedence per tier: CELESTEA_LLM_* env var > profile key > built-in default;
|
|
15
|
+
* 0 disables that stage; blank/unparseable env values are ignored (lenient).
|
|
16
|
+
*/
|
|
17
|
+
/** Env overrides for the three timeout tiers (milliseconds). */
|
|
18
|
+
export const CONNECT_TIMEOUT_ENV = "CELESTEA_LLM_CONNECT_TIMEOUT_MS";
|
|
19
|
+
export const RESPONSE_TIMEOUT_ENV = "CELESTEA_LLM_RESPONSE_TIMEOUT_MS";
|
|
20
|
+
export const STREAM_IDLE_TIMEOUT_ENV = "CELESTEA_LLM_STREAM_IDLE_TIMEOUT_MS";
|
|
21
|
+
/** Default TCP/TLS connect timeout (connects are fast when healthy). */
|
|
22
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
|
23
|
+
/** Default send() -> response-headers timeout. */
|
|
24
|
+
export const DEFAULT_RESPONSE_TIMEOUT_MS = 60_000;
|
|
25
|
+
/** Default SSE inter-chunk idle timeout. */
|
|
26
|
+
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90_000;
|
|
27
|
+
/** Profile JSON keys for the three tiers. */
|
|
28
|
+
export const PROFILE_TIMEOUT_KEYS = {
|
|
29
|
+
connect: "llm_connect_timeout_ms",
|
|
30
|
+
response: "llm_response_timeout_ms",
|
|
31
|
+
idle: "llm_stream_idle_timeout_ms",
|
|
32
|
+
};
|
|
33
|
+
/** Built-in defaults (nothing configured). */
|
|
34
|
+
export const DEFAULT_TIMEOUTS = {
|
|
35
|
+
connectMs: DEFAULT_CONNECT_TIMEOUT_MS,
|
|
36
|
+
responseMs: DEFAULT_RESPONSE_TIMEOUT_MS,
|
|
37
|
+
idleMs: DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Milliseconds -> tier value; 0 disables (null); absent/invalid -> fallback.
|
|
41
|
+
*
|
|
42
|
+
* W835 (R3 batch D / P2-6): this is the ONE "0 = disabled" mapping, shared by
|
|
43
|
+
* the client constructor (`client.ts`) and by [tiersFromConfig]. It replaced
|
|
44
|
+
* the unused, duplicate legacy helper so a 0/null fix cannot be applied in one
|
|
45
|
+
* place and missed in the other.
|
|
46
|
+
*/
|
|
47
|
+
export function timeoutMsOf(ms, fallbackMs) {
|
|
48
|
+
if (ms === null || ms === undefined)
|
|
49
|
+
return fallbackMs;
|
|
50
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
51
|
+
return fallbackMs;
|
|
52
|
+
return ms === 0 ? null : Math.floor(ms);
|
|
53
|
+
}
|
|
54
|
+
/** Parse an env value as a non-negative integer of milliseconds. */
|
|
55
|
+
function parseEnvMs(envValue) {
|
|
56
|
+
if (envValue === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
const trimmed = envValue.trim();
|
|
59
|
+
if (!/^[0-9]+$/.test(trimmed))
|
|
60
|
+
return undefined;
|
|
61
|
+
const value = Number(trimmed);
|
|
62
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
63
|
+
}
|
|
64
|
+
/** Is this a usable profile/env value (non-negative integer of ms)? */
|
|
65
|
+
export function isTimeoutMs(value) {
|
|
66
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolve one tier: env (set, trimmed, parseable) > profile value > default.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveTimeoutMs(profileValue, envValue, defaultMs) {
|
|
72
|
+
const fromEnv = parseEnvMs(envValue);
|
|
73
|
+
if (fromEnv !== undefined)
|
|
74
|
+
return fromEnv;
|
|
75
|
+
if (isTimeoutMs(profileValue))
|
|
76
|
+
return profileValue;
|
|
77
|
+
return defaultMs;
|
|
78
|
+
}
|
|
79
|
+
/** Resolve all three tiers (profile keys + CELESTEA_LLM_* env overrides). */
|
|
80
|
+
export function resolveTimeoutTiers(profile, env = process.env) {
|
|
81
|
+
return {
|
|
82
|
+
connectMs: resolveTimeoutMs(profile?.llm_connect_timeout_ms, env[CONNECT_TIMEOUT_ENV], DEFAULT_CONNECT_TIMEOUT_MS),
|
|
83
|
+
responseMs: resolveTimeoutMs(profile?.llm_response_timeout_ms, env[RESPONSE_TIMEOUT_ENV], DEFAULT_RESPONSE_TIMEOUT_MS),
|
|
84
|
+
idleMs: resolveTimeoutMs(profile?.llm_stream_idle_timeout_ms, env[STREAM_IDLE_TIMEOUT_ENV], DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Lenient profile reader: non-negative integers pass through, everything else
|
|
89
|
+
* is reported (never fatal) — mirrors the strict parse in
|
|
90
|
+
* `crates/runtime/src/config.rs`.
|
|
91
|
+
*/
|
|
92
|
+
export function readTimeoutProfile(raw) {
|
|
93
|
+
const profile = {};
|
|
94
|
+
const errors = [];
|
|
95
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
96
|
+
return { profile, errors };
|
|
97
|
+
}
|
|
98
|
+
const obj = raw;
|
|
99
|
+
const fields = [
|
|
100
|
+
["llm_connect_timeout_ms", PROFILE_TIMEOUT_KEYS.connect],
|
|
101
|
+
["llm_response_timeout_ms", PROFILE_TIMEOUT_KEYS.response],
|
|
102
|
+
["llm_stream_idle_timeout_ms", PROFILE_TIMEOUT_KEYS.idle],
|
|
103
|
+
];
|
|
104
|
+
for (const [field, key] of fields) {
|
|
105
|
+
const value = obj[key];
|
|
106
|
+
if (value === undefined || value === null)
|
|
107
|
+
continue;
|
|
108
|
+
if (isTimeoutMs(value))
|
|
109
|
+
profile[field] = value;
|
|
110
|
+
else
|
|
111
|
+
errors.push(`profile field '${key}' must be a non-negative integer (ms), got ${jsonKind(value)}`);
|
|
112
|
+
}
|
|
113
|
+
return { profile, errors };
|
|
114
|
+
}
|
|
115
|
+
function jsonKind(value) {
|
|
116
|
+
if (value === null)
|
|
117
|
+
return "null";
|
|
118
|
+
if (Array.isArray(value))
|
|
119
|
+
return "array";
|
|
120
|
+
return typeof value;
|
|
121
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP transport for the streaming request (P2a).
|
|
3
|
+
*
|
|
4
|
+
* Owns the two pre-stream guards of the three-tier timeout contract:
|
|
5
|
+
* * connect timeout — the TCP/TLS handshake must complete in time;
|
|
6
|
+
* * response-header time — send() -> response headers must arrive in time.
|
|
7
|
+
* Beyond the headers the body is guarded per chunk by the stream idle timeout
|
|
8
|
+
* (see stream.ts). There is deliberately NO total-request timeout, so a long
|
|
9
|
+
* generation is never killed by a pre-stream guard.
|
|
10
|
+
*
|
|
11
|
+
* The API key rides only in the request's Authorization header; error messages
|
|
12
|
+
* carry the HTTP status plus a body snippet, never a credential.
|
|
13
|
+
*/
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
/** Max bytes of a non-2xx body echoed in the error message. */
|
|
16
|
+
export declare const ERROR_BODY_SNIPPET_BYTES = 2048;
|
|
17
|
+
export interface SendOptions {
|
|
18
|
+
url: string;
|
|
19
|
+
apiKey: string;
|
|
20
|
+
body: string;
|
|
21
|
+
/** null = connect guard disabled. */
|
|
22
|
+
connectMs: number | null;
|
|
23
|
+
/** null = response-header guard disabled. */
|
|
24
|
+
responseMs: number | null;
|
|
25
|
+
}
|
|
26
|
+
/** POST the request; resolve once the response HEADERS are in. */
|
|
27
|
+
export declare function sendChatRequest(options: SendOptions): Promise<http.IncomingMessage>;
|
|
28
|
+
/** Read at most `limit` bytes of a body for error reporting. */
|
|
29
|
+
export declare function readBodySnippet(response: http.IncomingMessage, limit?: number): Promise<string>;
|
|
30
|
+
/** "500 Internal Server Error" style label for an error message. */
|
|
31
|
+
export declare function httpStatusLabel(status: number, statusText: string | undefined): string;
|
|
32
|
+
/**
|
|
33
|
+
* Belt-and-braces: never let a credential-shaped token ride out in an error.
|
|
34
|
+
*
|
|
35
|
+
* W824 (W811 P0-1 + N2): the shape rule tolerates whitespace after "Bearer"
|
|
36
|
+
* (the standard "Bearer <token>" form) and is case-insensitive. knownSecrets
|
|
37
|
+
* are the client's OWN keys and are replaced literally, because an arbitrary
|
|
38
|
+
* provider key echoed as "invalid api key: 9f8e..." has no recognizable shape;
|
|
39
|
+
* this mirrors core's registered-secret pass.
|
|
40
|
+
*/
|
|
41
|
+
export declare function redact(text: string, knownSecrets?: readonly string[]): string;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP transport for the streaming request (P2a).
|
|
3
|
+
*
|
|
4
|
+
* Owns the two pre-stream guards of the three-tier timeout contract:
|
|
5
|
+
* * connect timeout — the TCP/TLS handshake must complete in time;
|
|
6
|
+
* * response-header time — send() -> response headers must arrive in time.
|
|
7
|
+
* Beyond the headers the body is guarded per chunk by the stream idle timeout
|
|
8
|
+
* (see stream.ts). There is deliberately NO total-request timeout, so a long
|
|
9
|
+
* generation is never killed by a pre-stream guard.
|
|
10
|
+
*
|
|
11
|
+
* The API key rides only in the request's Authorization header; error messages
|
|
12
|
+
* carry the HTTP status plus a body snippet, never a credential.
|
|
13
|
+
*/
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import https from "node:https";
|
|
16
|
+
import { connectTimeoutError, networkError, responseHeaderTimeoutError } from "./errors.js";
|
|
17
|
+
/** Max bytes of a non-2xx body echoed in the error message. */
|
|
18
|
+
export const ERROR_BODY_SNIPPET_BYTES = 2048;
|
|
19
|
+
/** Settle-once state machine owning the pending timers and the request. */
|
|
20
|
+
class StageGuard {
|
|
21
|
+
#settled = false;
|
|
22
|
+
#timers = [];
|
|
23
|
+
#request = null;
|
|
24
|
+
#reject;
|
|
25
|
+
constructor(reject) {
|
|
26
|
+
this.#reject = reject;
|
|
27
|
+
}
|
|
28
|
+
attach(request) {
|
|
29
|
+
this.#request = request;
|
|
30
|
+
}
|
|
31
|
+
settle(fn) {
|
|
32
|
+
if (this.#settled)
|
|
33
|
+
return;
|
|
34
|
+
this.#settled = true;
|
|
35
|
+
for (const timer of this.#timers)
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
this.#timers.length = 0;
|
|
38
|
+
fn();
|
|
39
|
+
}
|
|
40
|
+
/** Reject with a timeout/transport error and tear the request down. */
|
|
41
|
+
abort(err) {
|
|
42
|
+
if (this.#settled)
|
|
43
|
+
return;
|
|
44
|
+
const request = this.#request;
|
|
45
|
+
this.settle(() => this.#reject(err));
|
|
46
|
+
request?.destroy();
|
|
47
|
+
}
|
|
48
|
+
/** Abort when the TCP/TLS handshake has not completed within `ms`. */
|
|
49
|
+
armConnect(ms, url) {
|
|
50
|
+
if (ms === null)
|
|
51
|
+
return;
|
|
52
|
+
let connected = false;
|
|
53
|
+
this.#request?.on("socket", (socket) => {
|
|
54
|
+
if (socket.connecting)
|
|
55
|
+
socket.once("connect", () => (connected = true));
|
|
56
|
+
else
|
|
57
|
+
connected = true;
|
|
58
|
+
});
|
|
59
|
+
this.#timers.push(setTimeout(() => {
|
|
60
|
+
if (!connected)
|
|
61
|
+
this.abort(connectTimeoutError(ms, url));
|
|
62
|
+
}, ms));
|
|
63
|
+
}
|
|
64
|
+
/** Abort when the response headers have not arrived within `ms`. */
|
|
65
|
+
armResponse(ms, url) {
|
|
66
|
+
if (ms === null)
|
|
67
|
+
return;
|
|
68
|
+
this.#timers.push(setTimeout(() => {
|
|
69
|
+
this.abort(responseHeaderTimeoutError(ms, url));
|
|
70
|
+
}, ms));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function requestHeaders(options) {
|
|
74
|
+
return {
|
|
75
|
+
"content-type": "application/json",
|
|
76
|
+
accept: "text/event-stream",
|
|
77
|
+
"content-length": String(Buffer.byteLength(options.body)),
|
|
78
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** POST the request; resolve once the response HEADERS are in. */
|
|
82
|
+
export async function sendChatRequest(options) {
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = new URL(options.url);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// W835 (R3 batch E / P2-4): a bad base_url is a pre-stream config failure
|
|
89
|
+
// and MUST reject as an LlmError (the client.ts contract), not a bare
|
|
90
|
+
// TypeError. It stays retryable so a fallback chain can hand over to a
|
|
91
|
+
// healthy target; the url is redacted so a credential inside it never
|
|
92
|
+
// reaches the message.
|
|
93
|
+
throw networkError("invalid base_url for llm request: " + redact(options.url));
|
|
94
|
+
}
|
|
95
|
+
const transport = parsed.protocol === "https:" ? https : http;
|
|
96
|
+
return await new Promise((resolve, reject) => {
|
|
97
|
+
const guard = new StageGuard(reject);
|
|
98
|
+
const request = transport.request(parsed, { method: "POST", headers: requestHeaders(options), agent: false }, (response) => guard.settle(() => resolve(response)));
|
|
99
|
+
guard.attach(request);
|
|
100
|
+
request.on("error", (err) => {
|
|
101
|
+
guard.settle(() => reject(networkError(`failed to start stream: ${err.message}`)));
|
|
102
|
+
});
|
|
103
|
+
guard.armConnect(options.connectMs, options.url);
|
|
104
|
+
guard.armResponse(options.responseMs, options.url);
|
|
105
|
+
request.end(options.body);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** Read at most `limit` bytes of a body for error reporting. */
|
|
109
|
+
export async function readBodySnippet(response, limit = ERROR_BODY_SNIPPET_BYTES) {
|
|
110
|
+
const parts = [];
|
|
111
|
+
let size = 0;
|
|
112
|
+
try {
|
|
113
|
+
for await (const chunk of response) {
|
|
114
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
115
|
+
parts.push(buf);
|
|
116
|
+
size += buf.byteLength;
|
|
117
|
+
if (size >= limit)
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Best effort: an unreadable body still yields a status-bearing error.
|
|
123
|
+
}
|
|
124
|
+
return Buffer.concat(parts).subarray(0, limit).toString("utf8");
|
|
125
|
+
}
|
|
126
|
+
/** "500 Internal Server Error" style label for an error message. */
|
|
127
|
+
export function httpStatusLabel(status, statusText) {
|
|
128
|
+
return statusText === undefined || statusText === "" ? String(status) : `${status} ${statusText}`;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Belt-and-braces: never let a credential-shaped token ride out in an error.
|
|
132
|
+
*
|
|
133
|
+
* W824 (W811 P0-1 + N2): the shape rule tolerates whitespace after "Bearer"
|
|
134
|
+
* (the standard "Bearer <token>" form) and is case-insensitive. knownSecrets
|
|
135
|
+
* are the client's OWN keys and are replaced literally, because an arbitrary
|
|
136
|
+
* provider key echoed as "invalid api key: 9f8e..." has no recognizable shape;
|
|
137
|
+
* this mirrors core's registered-secret pass.
|
|
138
|
+
*/
|
|
139
|
+
export function redact(text, knownSecrets = []) {
|
|
140
|
+
let out = text;
|
|
141
|
+
const keys = [...new Set(knownSecrets.filter((s) => typeof s === "string" && s.length >= 8))].sort((a, b) => b.length - a.length);
|
|
142
|
+
for (const key of keys) {
|
|
143
|
+
if (out.includes(key))
|
|
144
|
+
out = out.split(key).join("<redacted>");
|
|
145
|
+
}
|
|
146
|
+
return out.replace(/\b(?:sk|bearer)\s*[-_A-Za-z0-9._~+/=]{8,}/gi, "<redacted>");
|
|
147
|
+
}
|