@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,304 @@
1
+ /**
2
+ * Model fallback decorator (iteration E §4.2, P1) — a `Llm`, not a new seam.
3
+ *
4
+ * `createFallbackLlm({ targets, clientFor, policy, onAttempt, steps })` returns
5
+ * the SAME `Llm` interface the rest of the engine already consumes, which is why
6
+ * `agent-loop`'s `loop.ts` does not change one line (§4.6): the switch between
7
+ * targets happens entirely inside `generate`/the returned stream.
8
+ *
9
+ * The decorator owns exactly four things (§4.2.2, each mechanically testable):
10
+ * 1. the TRIGGER TABLE — which failures hand over to the next target and which
11
+ * ones terminate (a 401/403/400 is a configuration problem: another model
12
+ * cannot fix it). The table itself is data ([DEFAULT_FALLBACK_POLICY]);
13
+ * 2. the `produced` LOCK — once a text/thinking delta reached the consumer the
14
+ * attempt is NEVER redone: redoing it would drop text the user already saw
15
+ * and double-bill / double-write side effects (§4.5 R4-2);
16
+ * 3. target-level COOLDOWN — `failureThreshold` consecutive failures bench a
17
+ * target for `cooldownMs`; a benched target is tried last, never first;
18
+ * 4. VISIBILITY — every hand-over is reported through `onAttempt` (the host
19
+ * turns that into an SSE `status` frame, a local audit line and the
20
+ * statusline's `effective_model`), and through the optional step sink, so
21
+ * one user intent that cost N attempts is visible as N ledger rows.
22
+ *
23
+ * Honest boundaries (§4.5): availability fallback ONLY (no quality judgement),
24
+ * no key rotation, no persistent cooldown (that is P2).
25
+ */
26
+ import { LlmError, retryAfterMsOf, TIMEOUT_ERROR_PREFIX } from "./errors.js";
27
+ import { hostOf } from "./host.js";
28
+ export const DEFAULT_FALLBACK_POLICY = {
29
+ maxAttempts: 2,
30
+ cooldownMs: 60_000,
31
+ failureThreshold: 3,
32
+ notRetryableStatuses: [400, 401, 403, 404, 422],
33
+ retryableStatuses: [408, 425, 429, 500, 502, 503, 504],
34
+ respectRetryAfter: true,
35
+ };
36
+ /** Target health, shared by every session of one process (§4.2.2 cooldown). */
37
+ export class FallbackState {
38
+ failures = new Map();
39
+ benchUntil = new Map();
40
+ consecutiveFailures(name) {
41
+ return this.failures.get(name) ?? 0;
42
+ }
43
+ cooldownUntil(name) {
44
+ return this.benchUntil.get(name) ?? 0;
45
+ }
46
+ isCooling(name, now) {
47
+ return this.cooldownUntil(name) > now;
48
+ }
49
+ /** One failed attempt; `failureThreshold` in a row benches the target. */
50
+ noteFailure(name, now, policy) {
51
+ const failures = this.consecutiveFailures(name) + 1;
52
+ this.failures.set(name, failures);
53
+ if (failures >= policy.failureThreshold)
54
+ this.benchUntil.set(name, now + policy.cooldownMs);
55
+ }
56
+ noteSuccess(name) {
57
+ this.failures.set(name, 0);
58
+ this.benchUntil.set(name, 0);
59
+ }
60
+ snapshot(now) {
61
+ const names = new Set([...this.failures.keys(), ...this.benchUntil.keys()]);
62
+ return [...names].sort().map((name) => ({
63
+ name,
64
+ cooling: this.isCooling(name, now),
65
+ consecutive_failures: this.consecutiveFailures(name),
66
+ cooldown_until: this.cooldownUntil(name),
67
+ }));
68
+ }
69
+ }
70
+ const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
71
+ /** Cooled-down targets go LAST: a benched target is never the preferred one. */
72
+ export function orderTargets(targets, state, now) {
73
+ const warm = targets.filter((t) => !state.isCooling(t.name, now));
74
+ const cold = targets.filter((t) => state.isCooling(t.name, now));
75
+ return [...warm, ...cold];
76
+ }
77
+ export function createFallbackLlm(opts) {
78
+ const targets = opts.targets.filter((t) => t.name.trim() !== "");
79
+ if (targets.length === 0)
80
+ throw new Error("fallback: at least one target is required");
81
+ const rt = {
82
+ opts,
83
+ targets,
84
+ policy: { ...DEFAULT_FALLBACK_POLICY, ...(opts.policy ?? {}) },
85
+ state: opts.state ?? new FallbackState(),
86
+ now: opts.now ?? Date.now,
87
+ sleep: opts.sleep ?? sleepMs,
88
+ clients: new Map(),
89
+ failed: 0,
90
+ lastReason: null,
91
+ effective: null,
92
+ };
93
+ return {
94
+ generate: (req) => Promise.resolve(attemptLoop(rt, req)),
95
+ effective: () => rt.effective,
96
+ chain: () => orderTargets(targets, rt.state, rt.now()).map((t) => t.name),
97
+ lastReason: () => rt.lastReason,
98
+ failedAttempts: () => rt.failed,
99
+ };
100
+ }
101
+ /** One client per target, built on first use (its own base_url/key/timeouts). */
102
+ function clientOf(rt, target) {
103
+ const known = rt.clients.get(target.name);
104
+ if (known !== undefined)
105
+ return known;
106
+ const built = rt.opts.clientFor(target);
107
+ rt.clients.set(target.name, built);
108
+ return built;
109
+ }
110
+ /** One failed attempt: count it, and bench the target if it keeps failing. */
111
+ function noteFailure(rt, target, info) {
112
+ rt.failed += 1;
113
+ rt.lastReason = info.reason;
114
+ rt.state.noteFailure(target.name, rt.now(), rt.policy);
115
+ }
116
+ /** The switch itself is the event the host publishes (§4.2.3). */
117
+ function announce(rt, next, attempt, from, info) {
118
+ rt.opts.onAttempt?.({
119
+ attempt,
120
+ target: next.name,
121
+ model: next.model,
122
+ from,
123
+ reason: info.reason,
124
+ httpStatus: info.httpStatus,
125
+ produced: info.produced,
126
+ });
127
+ }
128
+ /** `Retry-After` is honoured up to `cooldownMs`; beyond it we move on. */
129
+ async function honourRetryAfter(rt, info) {
130
+ if (!rt.policy.respectRetryAfter || info.retryAfterMs === null)
131
+ return;
132
+ if (info.retryAfterMs > rt.policy.cooldownMs)
133
+ return;
134
+ if (info.retryAfterMs > 0)
135
+ await rt.sleep(info.retryAfterMs);
136
+ }
137
+ /** The whole attempt loop, lazily: the first events are pulled by the loop. */
138
+ async function* attemptLoop(rt, req) {
139
+ const plan = orderTargets(rt.targets, rt.state, rt.now()).slice(0, Math.max(1, rt.policy.maxAttempts));
140
+ let from = null;
141
+ let lastError = null;
142
+ for (let attempt = 0; attempt < plan.length; attempt++) {
143
+ const target = plan[attempt];
144
+ const step = rt.opts.steps?.beginStep({
145
+ provider: target.provider,
146
+ model: target.model,
147
+ base_url_host: hostOf(target.baseUrl ?? null),
148
+ attempt,
149
+ fallback_from: from,
150
+ });
151
+ let stream;
152
+ try {
153
+ stream = await clientOf(rt, target).generate(req);
154
+ }
155
+ catch (error) {
156
+ const info = describeFailure(error, 0, rt.policy);
157
+ step?.close({ kind: "error", error_kind: info.kind, http_status: info.httpStatus, retryable: info.retryable });
158
+ noteFailure(rt, target, info);
159
+ if (!info.retryable)
160
+ throw error;
161
+ lastError = error;
162
+ // W835 (P1-2): only wait for Retry-After when another target will run.
163
+ // An exhausted chain must fail fast instead of sleeping up to cooldownMs.
164
+ const next = plan[attempt + 1];
165
+ if (next !== undefined) {
166
+ await honourRetryAfter(rt, info);
167
+ announce(rt, next, attempt + 1, target.name, info);
168
+ }
169
+ from = target.name;
170
+ continue;
171
+ }
172
+ rt.effective = { name: target.name, model: target.model };
173
+ const outcome = yield* consume(stream, step, rt.policy);
174
+ if (outcome.kind === "done") {
175
+ rt.state.noteSuccess(target.name);
176
+ return;
177
+ }
178
+ noteFailure(rt, target, outcome.info);
179
+ // produced > 0 = the lock: the attempt is never redone (§4.2.2).
180
+ if (outcome.info.produced > 0 || !outcome.info.retryable) {
181
+ yield outcome.terminal;
182
+ return;
183
+ }
184
+ lastError = outcome.error;
185
+ // W835 (P1-2): no last-target Retry-After sleep (see the catch branch above).
186
+ const next = plan[attempt + 1];
187
+ if (next !== undefined) {
188
+ await honourRetryAfter(rt, outcome.info);
189
+ announce(rt, next, attempt + 1, target.name, outcome.info);
190
+ }
191
+ from = target.name;
192
+ }
193
+ throw lastError ?? new LlmError("llm fallback: every target failed", "generate", { retryable: false });
194
+ }
195
+ /** Forward one attempt's events, counting what the consumer has already seen. */
196
+ async function* consume(stream, step, policy) {
197
+ let produced = 0;
198
+ let closed = false;
199
+ // W835 (P1-3): close the step EXACTLY once. The `finally` covers the case the
200
+ // old code missed: the consumer abandoned the attempt (break / cancel), the
201
+ // generator is returned, and the usage already recorded must still be booked
202
+ // instead of silently dropped.
203
+ const closeStep = (outcome) => {
204
+ if (closed)
205
+ return;
206
+ closed = true;
207
+ step?.close(outcome);
208
+ };
209
+ try {
210
+ for await (const event of stream) {
211
+ if (isProducedEvent(event))
212
+ produced += 1;
213
+ if (event.kind === "done") {
214
+ closeStep({ kind: "ok" });
215
+ // The terminal event is FORWARDED, not swallowed: the loop derives the
216
+ // turn's `assistant_message` from it (loop.ts:246-253).
217
+ yield event;
218
+ return { kind: "done" };
219
+ }
220
+ // The step buffer is fed HERE, exactly like the W728 observer does: a
221
+ // usage frame belongs to the attempt that produced it (§3.2.3).
222
+ if (event.kind === "usage")
223
+ step?.record(event.usage);
224
+ if (event.kind === "failed" || event.kind === "interrupted") {
225
+ const info = describeEvent(event, produced);
226
+ closeStep({ kind: "error", error_kind: info.kind, http_status: null, retryable: info.retryable });
227
+ return { kind: "failed", info, terminal: event, error: errorOfEvent(event, info) };
228
+ }
229
+ yield event;
230
+ }
231
+ const info = describeEvent({ kind: "interrupted" }, produced);
232
+ closeStep({ kind: "error", error_kind: info.kind, http_status: null, retryable: info.retryable });
233
+ return { kind: "failed", info, terminal: { kind: "interrupted" }, error: errorOfEvent({ kind: "interrupted" }, info) };
234
+ }
235
+ catch (error) {
236
+ const info = describeFailure(error, produced, policy);
237
+ closeStep({ kind: "error", error_kind: info.kind, http_status: info.httpStatus, retryable: info.retryable });
238
+ return { kind: "failed", info, terminal: { kind: "interrupted" }, error };
239
+ }
240
+ finally {
241
+ // Early `return()` from the consumer (break / cancel): book the attempt as
242
+ // ok so its already-recorded usage keeps its row. A no-op on every normal
243
+ // exit because [closeStep] is idempotent.
244
+ closeStep({ kind: "ok" });
245
+ }
246
+ }
247
+ /** `text`/`thinking` reaching the consumer = the attempt cannot be redone. */
248
+ export function isProducedEvent(event) {
249
+ return event.kind === "text" || event.kind === "thinking";
250
+ }
251
+ /**
252
+ * Classify a thrown failure. `produced > 0` overrides everything: a failure
253
+ * after visible output is terminal, whatever its status said.
254
+ */
255
+ export function describeFailure(error, produced, policy = DEFAULT_FALLBACK_POLICY) {
256
+ const rec = typeof error === "object" && error !== null ? error : {};
257
+ const message = typeof rec["message"] === "string" ? rec["message"] : String(error);
258
+ const status = typeof rec["httpStatus"] === "number" ? rec["httpStatus"] : null;
259
+ const stage = typeof rec["timeoutStage"] === "string" ? rec["timeoutStage"] : null;
260
+ const isTimeout = rec["isTimeout"] === true || rec["kind"] === "timeout" || message.startsWith(TIMEOUT_ERROR_PREFIX);
261
+ const kind = typeof rec["kind"] === "string" ? rec["kind"] : "generate";
262
+ const base = { produced, httpStatus: status, message, retryAfterMs: retryAfterMsOf(error) };
263
+ if (produced > 0)
264
+ return { ...base, reason: reasonOf(status, isTimeout, stage, kind), kind, retryable: false };
265
+ if (status !== null) {
266
+ return { ...base, reason: `http_${status}`, kind, retryable: statusRetryable(status, policy) };
267
+ }
268
+ if (isTimeout)
269
+ return { ...base, reason: `timeout_${stage ?? "idle"}`, kind: "timeout", retryable: true };
270
+ if (rec["retryable"] === true)
271
+ return { ...base, reason: "network", kind, retryable: true };
272
+ if (kind === "stream")
273
+ return { ...base, reason: "stream", kind, retryable: true };
274
+ return { ...base, reason: "generate", kind, retryable: false };
275
+ }
276
+ /** Classify a terminal stream event (`failed{kindOf}` / `interrupted`). */
277
+ export function describeEvent(event, produced) {
278
+ const base = { produced, retryable: produced === 0, httpStatus: null, retryAfterMs: null };
279
+ if (event.kind === "interrupted") {
280
+ return { ...base, reason: "interrupted", kind: "stream", message: "stream interrupted" };
281
+ }
282
+ const kindOf = event.kindOf;
283
+ const kind = kindOf === "timeout" ? "timeout" : kindOf;
284
+ const reason = kindOf === "timeout" ? "timeout_idle" : kindOf;
285
+ return { ...base, reason, kind, message: event.message };
286
+ }
287
+ function reasonOf(status, isTimeout, stage, kind) {
288
+ if (status !== null)
289
+ return `http_${status}`;
290
+ if (isTimeout)
291
+ return `timeout_${stage ?? "idle"}`;
292
+ return kind === "stream" ? "stream" : "generate";
293
+ }
294
+ /** A status is worth another target unless it is a request/credential problem. */
295
+ function statusRetryable(status, policy) {
296
+ if (policy.notRetryableStatuses.includes(status))
297
+ return false;
298
+ return status >= 500 || policy.retryableStatuses.includes(status);
299
+ }
300
+ /** The error a terminal event maps to when the whole chain is exhausted. */
301
+ function errorOfEvent(event, info) {
302
+ const kind = event.kind === "interrupted" ? "stream" : event.kindOf === "generate" ? "generate" : "stream";
303
+ return new LlmError(info.message, kind, { retryable: info.retryable });
304
+ }
package/dist/host.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Host extraction for the fallback ledger columns (`base_url_host`).
3
+ *
4
+ * Kept in its own module so the fallback decorator can name the host of a
5
+ * target's base_url without importing the HTTP transport, and so the rule lives
6
+ * in exactly one place (the runtime's `ledger-llm.ts` mirrors it for the same
7
+ * column — both are "the host, or null when it is not a URL").
8
+ */
9
+ /** Host of a base_url (`api.deepseek.com`), or null when it is not a URL. */
10
+ export declare function hostOf(baseUrl: string | null): string | null;
package/dist/host.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Host extraction for the fallback ledger columns (`base_url_host`).
3
+ *
4
+ * Kept in its own module so the fallback decorator can name the host of a
5
+ * target's base_url without importing the HTTP transport, and so the rule lives
6
+ * in exactly one place (the runtime's `ledger-llm.ts` mirrors it for the same
7
+ * column — both are "the host, or null when it is not a URL").
8
+ */
9
+ /** Host of a base_url (`api.deepseek.com`), or null when it is not a URL. */
10
+ export function hostOf(baseUrl) {
11
+ if (baseUrl === null || baseUrl === "")
12
+ return null;
13
+ try {
14
+ return new URL(baseUrl).host;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * W804 (multimodal P0 section 7.6): the ONE automatic downgrade for the
3
+ * optimistic-default bet.
4
+ *
5
+ * Default `input_modalities = ["text","image"]` means we ASSUME every model
6
+ * accepts images. When that assumption is wrong the upstream answers 4xx with a
7
+ * known "image unsupported" report; the client classifies it as an
8
+ * [ImageUnsupportedError]. This decorator then:
9
+ * 1. replaces every ImageContent block with a visible placeholder text,
10
+ * 2. retries the SAME request ONCE,
11
+ * 3. reports the downgrade through `onDowngrade` (the host's three visible
12
+ * channels: info block / statusline / audit).
13
+ *
14
+ * It never swallows the failure silently: the placeholder is in the retried
15
+ * request, and an unrecognised failure propagates unchanged. If the retry fails
16
+ * too, that failure propagates as an ordinary turn failure.
17
+ */
18
+ import type { ImageRef, Llm, ModelRequestDraft } from "./seam.js";
19
+ /**
20
+ * W855: WHY an image downgrade fired. The three causes reuse the ONE downgrade
21
+ * path (placeholder rewrite -> same-request retry -> onDowngrade); only the
22
+ * trigger and the user-facing copy differ.
23
+ * - upstream_rejected the upstream answered 4xx "image unsupported" (W804);
24
+ * - timeout response headers never arrived (suspected image
25
+ * rejection: the upstream may be slow to REJECT);
26
+ * - configured_text_only input_modalities explicitly excludes "image", so no
27
+ * image-bearing request is ever sent.
28
+ */
29
+ export type ImageDowngradeCause = "upstream_rejected" | "timeout" | "configured_text_only";
30
+ /** What the host renders/persists when a downgrade happens. */
31
+ export interface ImageDowngradeInfo {
32
+ /** Effective model that triggered the downgrade (the model selector's value). */
33
+ model: string;
34
+ /** Machine-readable classification. NEVER changes: the contract/consumers key on this. */
35
+ reason: "IMAGE_UNSUPPORTED";
36
+ /**
37
+ * W855: the trigger discriminator. OPTIONAL on purpose - an older producer
38
+ * (or an embedded host) that predates this field stays valid and every
39
+ * consumer tolerates its absence (defaults to upstream_rejected).
40
+ */
41
+ cause?: ImageDowngradeCause;
42
+ /** The upstream HTTP status (400 on the reject path); null on the other two. */
43
+ httpStatus: number | null;
44
+ /**
45
+ * The error text, verbatim: the upstream body on the reject path, the timeout
46
+ * message ("llm timeout: response headers ...", threshold included) on the
47
+ * timeout path, and the configured-exclusion fact locally.
48
+ */
49
+ message: string;
50
+ /** The placeholder that replaced the images in the retried request. */
51
+ placeholder: string;
52
+ }
53
+ export interface ImageDowngradeLlmOptions {
54
+ inner: Llm;
55
+ /** Called exactly once per downgraded request, before the retry. */
56
+ onDowngrade?: (info: ImageDowngradeInfo) => void;
57
+ /**
58
+ * W855: true = the target model is EXPLICITLY configured as text-only
59
+ * (input_modalities without "image"). Evaluated against the request's OWN
60
+ * req.model at call time, so a model switch is never stale. Absent = the
61
+ * optimistic default (images allowed), i.e. the pre-W855 behaviour.
62
+ */
63
+ isTextOnly?: (model: string) => boolean;
64
+ }
65
+ /** The placeholder text (section 7.6 wording, one per image block). */
66
+ export declare function imagePlaceholderText(ref: ImageRef, model: string): string;
67
+ /**
68
+ * A copy of the request with every image block replaced by its placeholder and
69
+ * the request-scoped image table removed. Non-image content, tool calls and text
70
+ * are byte-for-byte unchanged.
71
+ */
72
+ export declare function withImagePlaceholders(req: ModelRequestDraft, model: string): ModelRequestDraft;
73
+ /**
74
+ * Wrap an `Llm` with the one-shot image downgrade. Text-only requests pass
75
+ * through untouched (no image scan cost beyond the check).
76
+ */
77
+ export declare function createImageDowngradeLlm(options: ImageDowngradeLlmOptions): Llm;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * W804 (multimodal P0 section 7.6): the ONE automatic downgrade for the
3
+ * optimistic-default bet.
4
+ *
5
+ * Default `input_modalities = ["text","image"]` means we ASSUME every model
6
+ * accepts images. When that assumption is wrong the upstream answers 4xx with a
7
+ * known "image unsupported" report; the client classifies it as an
8
+ * [ImageUnsupportedError]. This decorator then:
9
+ * 1. replaces every ImageContent block with a visible placeholder text,
10
+ * 2. retries the SAME request ONCE,
11
+ * 3. reports the downgrade through `onDowngrade` (the host's three visible
12
+ * channels: info block / statusline / audit).
13
+ *
14
+ * It never swallows the failure silently: the placeholder is in the retried
15
+ * request, and an unrecognised failure propagates unchanged. If the retry fails
16
+ * too, that failure propagates as an ordinary turn failure.
17
+ */
18
+ import { isImageUnsupportedError, isTimeoutError } from "./errors.js";
19
+ import { messagesHaveImages } from "./wire.js";
20
+ /**
21
+ * W855: ONLY a RESPONSE-HEADER timeout - not one byte ever arrived, so the
22
+ * request may simply be slow to be rejected - is treated as a suspected image
23
+ * rejection. A connect timeout, a mid-stream stall, a 401, etc. are rethrown
24
+ * unchanged: resending text cannot save them, and a second attempt would only
25
+ * waste a round-trip while swallowing the user's image.
26
+ */
27
+ function isResponseHeaderTimeout(e) {
28
+ return isTimeoutError(e) && e.timeoutStage === "response";
29
+ }
30
+ /** The placeholder text (section 7.6 wording, one per image block). */
31
+ export function imagePlaceholderText(ref, model) {
32
+ const label = ref.name !== undefined ? `${ref.attachment_id}(${ref.name})` : ref.attachment_id;
33
+ return `[图片已省略:模型 "${model}" 未接受图像输入(上游 400);attachment ${label}]`;
34
+ }
35
+ /**
36
+ * A copy of the request with every image block replaced by its placeholder and
37
+ * the request-scoped image table removed. Non-image content, tool calls and text
38
+ * are byte-for-byte unchanged.
39
+ */
40
+ export function withImagePlaceholders(req, model) {
41
+ const messages = [];
42
+ for (const msg of req.messages ?? []) {
43
+ let changed = false;
44
+ const content = [];
45
+ for (const part of msg.content) {
46
+ if (part.type === "image") {
47
+ content.push({ type: "text", content: imagePlaceholderText(part.content, model) });
48
+ changed = true;
49
+ }
50
+ else {
51
+ content.push(part);
52
+ }
53
+ }
54
+ messages.push(changed ? { ...msg, content } : msg);
55
+ }
56
+ const { images: _images, ...rest } = req;
57
+ void _images;
58
+ return { ...rest, messages };
59
+ }
60
+ /** True when the request carries at least one image content block. */
61
+ function hasImages(req) {
62
+ return messagesHaveImages(req.messages ?? []);
63
+ }
64
+ function firstPlaceholder(messages, model) {
65
+ for (const msg of messages) {
66
+ for (const part of msg.content) {
67
+ if (part.type === "image")
68
+ return imagePlaceholderText(part.content, model);
69
+ }
70
+ }
71
+ return "";
72
+ }
73
+ /**
74
+ * Wrap an `Llm` with the one-shot image downgrade. Text-only requests pass
75
+ * through untouched (no image scan cost beyond the check).
76
+ */
77
+ export function createImageDowngradeLlm(options) {
78
+ return {
79
+ async generate(req) {
80
+ const model = req.model ?? "";
81
+ // Text-only requests pass through untouched: no rewrite, no extra call.
82
+ if (!hasImages(req))
83
+ return options.inner.generate(req);
84
+ const placeholder = firstPlaceholder(req.messages ?? [], model);
85
+ // W855 path 1: explicitly configured text-only -> never send the image
86
+ // request; go straight to the placeholder request. No upstream call
87
+ // carries the image; exactly one upstream call happens in total.
88
+ if (options.isTextOnly?.(model) === true) {
89
+ options.onDowngrade?.({
90
+ model,
91
+ reason: "IMAGE_UNSUPPORTED",
92
+ cause: "configured_text_only",
93
+ httpStatus: null,
94
+ message: '模型 "' + model + '" 的 input_modalities 不含 "image"(按配置显式排除),本次带图请求未发送。',
95
+ placeholder,
96
+ });
97
+ return await options.inner.generate(withImagePlaceholders(req, model));
98
+ }
99
+ try {
100
+ return await options.inner.generate(req);
101
+ }
102
+ catch (error) {
103
+ // W855 paths 2/3: a classified upstream 4xx image report, OR a
104
+ // response-header timeout (the suspected-slow-rejection case). Both
105
+ // reuse the SAME downgrade; every other failure propagates unchanged.
106
+ let cause = null;
107
+ let httpStatus = null;
108
+ if (isImageUnsupportedError(error)) {
109
+ cause = "upstream_rejected";
110
+ httpStatus = error.httpStatus;
111
+ }
112
+ else if (isResponseHeaderTimeout(error)) {
113
+ cause = "timeout";
114
+ }
115
+ if (cause === null)
116
+ throw error;
117
+ options.onDowngrade?.({
118
+ model,
119
+ reason: "IMAGE_UNSUPPORTED",
120
+ cause,
121
+ httpStatus,
122
+ message: error instanceof Error ? error.message : String(error),
123
+ placeholder,
124
+ });
125
+ return await options.inner.generate(withImagePlaceholders(req, model));
126
+ }
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @celestea/llm — OpenAI-compatible LLM provider (P2a public API).
3
+ *
4
+ * Parity target: `celestea_harness/crates/llm` — raw SSE transport, usage /
5
+ * cache-hit parsing, three timeout tiers, free-form reasoning_effort.
6
+ *
7
+ * Only this barrel is the package's public surface: provider internals
8
+ * (SSE framing, wire mapping, HTTP transport) stay private so callers depend on
9
+ * the `Llm` seam, not on the provider.
10
+ */
11
+ export type { Content, ImageContent, ImageRef, Llm, LlmStream, Message, ModelRequest, ModelRequestDraft, ResolvedImages, Role, StreamEvent, TextContent, ToolCall, ToolCallContent, ToolSpec, } from "./seam.js";
12
+ export { assistantText, assistantToolCall, collectMessageText, collectStream, messageToolCalls, ROLES, systemMessage, toolResultMessage, userMessage, } from "./seam.js";
13
+ export type { LlmUsageFrame, Usage } from "./usage.js";
14
+ export { cacheHitRatio, CACHE_READ_FLAT_KEYS, CACHE_READ_NESTED, parseUsage, REASONING_TOKENS_NESTED, USAGE_REQUIRED_KEYS, usageFromObject, usageIsEmpty, ZERO_USAGE, zeroUsage, } from "./usage.js";
15
+ export type { LlmErrorKind, LlmErrorOptions, TimeoutStage } from "./errors.js";
16
+ export { cancelledError, parseRetryAfterHeader, retryAfterMsOf, setRetryAfterMs, connectTimeoutError, errorKind, ImageUnsupportedError, IMAGE_UNSUPPORTED_MARKERS, isImageUnsupportedBody, isImageUnsupportedError, isRetryableStatus, isTimeoutError, LlmError, networkError, responseHeaderTimeoutError, RETRYABLE_HTTP_STATUSES, statusError, streamIdleTimeoutMessage, TIMEOUT_ERROR_PREFIX, timeoutError, } from "./errors.js";
17
+ export type { EnvLike, TimeoutProfile, TimeoutTiers } from "./timeouts.js";
18
+ export { CONNECT_TIMEOUT_ENV, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_RESPONSE_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_TIMEOUTS, isTimeoutMs, PROFILE_TIMEOUT_KEYS, readTimeoutProfile, RESPONSE_TIMEOUT_ENV, resolveTimeoutMs, resolveTimeoutTiers, STREAM_IDLE_TIMEOUT_ENV, } from "./timeouts.js";
19
+ export type { LlmProfile, ResolvedClientConfig } from "./profile.js";
20
+ export { API_KEY_ENV, BASE_URL_ENV, DEFAULT_BASE_URL, DEFAULT_MODEL, normalizeReasoningEffort, resolveApiKey, resolveClientConfig, tiersFromConfig, validateModel, } from "./profile.js";
21
+ export type { LiveLlmProfile, LiveLlmView, LlmMode } from "./factory.js";
22
+ export { createLiveLlm, liveLlmView, LLM_BASE_URL_ENV, LLM_MODE_ENV, resolveLlmMode, withBaseUrlFallback, } from "./factory.js";
23
+ export type { FallbackAttemptInfo, FallbackLlm, FallbackLlmOptions, FallbackPolicy, FallbackStepHandle, FallbackStepSink, FailureInfo, LlmTarget, } from "./fallback.js";
24
+ export { createFallbackLlm, DEFAULT_FALLBACK_POLICY, describeEvent, describeFailure, FallbackState, isProducedEvent, orderTargets, } from "./fallback.js";
25
+ export type { FallbackConfig } from "./fallback-config.js";
26
+ export { configProblems, ENV_FALLBACK_SWITCH, ENV_FALLBACKS, FALLBACKS_FILE, fallbackEnabled, loadFallbackConfig, parseConfig, targetAvailability, } from "./fallback-config.js";
27
+ export type { OpenAiCompatOptions } from "./client.js";
28
+ export { OpenAiCompatClient } from "./client.js";
29
+ export { createDeepSeekLlm, createDeepSeekRegistry, DEEPSEEK_PROVIDER_NAME, LlmRegistry, } from "./provider.js";
30
+ export type { WireContentPart, WireImagePart, WireTextPart } from "./wire.js";
31
+ export { collectMessageParts, dataUrlFor, messageImageRefs, messagesHaveImages, resolvedImagesOf, wireMessagesFor, } from "./wire.js";
32
+ export type { ImageDowngradeCause, ImageDowngradeInfo, ImageDowngradeLlmOptions } from "./image-fallback.js";
33
+ export { createImageDowngradeLlm, imagePlaceholderText, withImagePlaceholders } from "./image-fallback.js";
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @celestea/llm — OpenAI-compatible LLM provider (P2a public API).
3
+ *
4
+ * Parity target: `celestea_harness/crates/llm` — raw SSE transport, usage /
5
+ * cache-hit parsing, three timeout tiers, free-form reasoning_effort.
6
+ *
7
+ * Only this barrel is the package's public surface: provider internals
8
+ * (SSE framing, wire mapping, HTTP transport) stay private so callers depend on
9
+ * the `Llm` seam, not on the provider.
10
+ */
11
+ export { assistantText, assistantToolCall, collectMessageText, collectStream, messageToolCalls, ROLES, systemMessage, toolResultMessage, userMessage, } from "./seam.js";
12
+ export { cacheHitRatio, CACHE_READ_FLAT_KEYS, CACHE_READ_NESTED, parseUsage, REASONING_TOKENS_NESTED, USAGE_REQUIRED_KEYS, usageFromObject, usageIsEmpty, ZERO_USAGE, zeroUsage, } from "./usage.js";
13
+ export { cancelledError, parseRetryAfterHeader, retryAfterMsOf, setRetryAfterMs, connectTimeoutError, errorKind, ImageUnsupportedError, IMAGE_UNSUPPORTED_MARKERS, isImageUnsupportedBody, isImageUnsupportedError, isRetryableStatus, isTimeoutError, LlmError, networkError, responseHeaderTimeoutError, RETRYABLE_HTTP_STATUSES, statusError, streamIdleTimeoutMessage, TIMEOUT_ERROR_PREFIX, timeoutError, } from "./errors.js";
14
+ export { CONNECT_TIMEOUT_ENV, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_RESPONSE_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_TIMEOUTS, isTimeoutMs, PROFILE_TIMEOUT_KEYS, readTimeoutProfile, RESPONSE_TIMEOUT_ENV, resolveTimeoutMs, resolveTimeoutTiers, STREAM_IDLE_TIMEOUT_ENV, } from "./timeouts.js";
15
+ export { API_KEY_ENV, BASE_URL_ENV, DEFAULT_BASE_URL, DEFAULT_MODEL, normalizeReasoningEffort, resolveApiKey, resolveClientConfig, tiersFromConfig, validateModel, } from "./profile.js";
16
+ export { createLiveLlm, liveLlmView, LLM_BASE_URL_ENV, LLM_MODE_ENV, resolveLlmMode, withBaseUrlFallback, } from "./factory.js";
17
+ export { createFallbackLlm, DEFAULT_FALLBACK_POLICY, describeEvent, describeFailure, FallbackState, isProducedEvent, orderTargets, } from "./fallback.js";
18
+ export { configProblems, ENV_FALLBACK_SWITCH, ENV_FALLBACKS, FALLBACKS_FILE, fallbackEnabled, loadFallbackConfig, parseConfig, targetAvailability, } from "./fallback-config.js";
19
+ export { OpenAiCompatClient } from "./client.js";
20
+ export { createDeepSeekLlm, createDeepSeekRegistry, DEEPSEEK_PROVIDER_NAME, LlmRegistry, } from "./provider.js";
21
+ export { collectMessageParts, dataUrlFor, messageImageRefs, messagesHaveImages, resolvedImagesOf, wireMessagesFor, } from "./wire.js";
22
+ export { createImageDowngradeLlm, imagePlaceholderText, withImagePlaceholders } from "./image-fallback.js";
@@ -0,0 +1,65 @@
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 { type EnvLike, type TimeoutProfile, type TimeoutTiers } from "./timeouts.js";
14
+ /** Environment variable holding the provider API key. */
15
+ export declare const API_KEY_ENV = "DEEPSEEK_API_KEY";
16
+ /** Environment variable overriding the provider base URL. */
17
+ export declare const BASE_URL_ENV = "DEEPSEEK_BASE_URL";
18
+ export declare const DEFAULT_BASE_URL = "https://api.deepseek.com";
19
+ export declare const DEFAULT_MODEL = "deepseek-chat";
20
+ /** The runtime profile subset this package consumes. */
21
+ export interface LlmProfile extends TimeoutProfile {
22
+ model?: string | null;
23
+ base_url?: string | null;
24
+ /** Free-form tier string, injected into the request body verbatim. */
25
+ reasoning_effort?: string | null;
26
+ max_output_tokens?: number | null;
27
+ /** Name of the env var holding the API key (default DEEPSEEK_API_KEY). */
28
+ api_key_env?: string | null;
29
+ }
30
+ /** Fully resolved client configuration (carries the key; never logged). */
31
+ export interface ResolvedClientConfig {
32
+ baseUrl: string;
33
+ apiKey: string;
34
+ model: string;
35
+ /** Free-form tier string, passed through verbatim (never folded/renamed). */
36
+ reasoningEffort: string | null;
37
+ maxOutputTokens: number | null;
38
+ /** 0 = that stage is disabled (same convention as the profile/env keys). */
39
+ connectTimeoutMs: number;
40
+ responseTimeoutMs: number;
41
+ streamIdleTimeoutMs: number;
42
+ }
43
+ /** The tiers as configured (null = disabled), derived from resolved ms values. */
44
+ export declare function tiersFromConfig(config: ResolvedClientConfig): TimeoutTiers;
45
+ /**
46
+ * Resolve the API key from the environment ONLY (`api_key_env` names the var).
47
+ * Returns null when unset/blank. This package never reads key files: that is
48
+ * the runtime's job (`resolve_api_key` in crates/runtime).
49
+ */
50
+ export declare function resolveApiKey(profile?: LlmProfile | null, env?: EnvLike): string | null;
51
+ /** Compose the effective client config from a runtime profile + environment. */
52
+ export declare function resolveClientConfig(profile?: LlmProfile | null, env?: EnvLike): ResolvedClientConfig;
53
+ /**
54
+ * reasoning_effort is a FREE STRING: user-defined tiers ("max",
55
+ * "xhigh-custom", provider-specific labels) reach the upstream exactly as
56
+ * written. Only null/undefined means "not configured" — no trimming, no
57
+ * folding onto an enum, no renaming.
58
+ */
59
+ export declare function normalizeReasoningEffort(v: string | null | undefined): string | null;
60
+ /**
61
+ * Model names are free-form: the OpenAI-compatible endpoint decides its own
62
+ * catalog (a local shim may expose deepseek-v4-flash), so the only hard rule is
63
+ * that a model must be supplied.
64
+ */
65
+ export declare function validateModel(model: string): void;