@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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production provider factory (W511).
|
|
3
|
+
*
|
|
4
|
+
* The one place a resolved runtime profile becomes a NETWORK-backed `Llm`:
|
|
5
|
+
*
|
|
6
|
+
* base_url profile.base_url > CELESTEA_BASE_URL > DEEPSEEK_BASE_URL
|
|
7
|
+
* > https://api.deepseek.com
|
|
8
|
+
* api key env[profile.api_key_env] ONLY (default DEEPSEEK_API_KEY):
|
|
9
|
+
* never a file, never written back, never echoed
|
|
10
|
+
* reasoning_effort profile value, verbatim (free string, never folded)
|
|
11
|
+
* max_output_tokens profile value
|
|
12
|
+
* timeouts CELESTEA_LLM_{CONNECT,RESPONSE,STREAM_IDLE}_TIMEOUT_MS
|
|
13
|
+
* > profile key > built-in default (0 disables a stage)
|
|
14
|
+
*
|
|
15
|
+
* `context_window_tokens` is not a request field (the engine trims with it); it
|
|
16
|
+
* is surfaced in `liveLlmView()` so a startup log can report the live window
|
|
17
|
+
* next to the model.
|
|
18
|
+
*
|
|
19
|
+
* `CELESTEA_LLM_MODE` picks live vs offline. This package is network-only, so it
|
|
20
|
+
* only REPORTS the mode (the deterministic offline seam is host-side, an
|
|
21
|
+
* injected test seam in apps/studio).
|
|
22
|
+
*/
|
|
23
|
+
import { OpenAiCompatClient } from "./client.js";
|
|
24
|
+
import { type LlmProfile } from "./profile.js";
|
|
25
|
+
import type { EnvLike, TimeoutTiers } from "./timeouts.js";
|
|
26
|
+
/** `live` = the real provider; `offline` = the host's deterministic seam. */
|
|
27
|
+
export declare const LLM_MODE_ENV = "CELESTEA_LLM_MODE";
|
|
28
|
+
/** Base-URL fallback used by the host (wins over DEEPSEEK_BASE_URL). */
|
|
29
|
+
export declare const LLM_BASE_URL_ENV = "CELESTEA_BASE_URL";
|
|
30
|
+
export type LlmMode = "live" | "offline";
|
|
31
|
+
/** The profile subset the live factory consumes. */
|
|
32
|
+
export interface LiveLlmProfile extends LlmProfile {
|
|
33
|
+
context_window_tokens?: number | null;
|
|
34
|
+
}
|
|
35
|
+
/** Secret-free view of a live adapter (safe to log / serialize). */
|
|
36
|
+
export interface LiveLlmView {
|
|
37
|
+
mode: LlmMode;
|
|
38
|
+
model: string;
|
|
39
|
+
baseUrl: string;
|
|
40
|
+
reasoningEffort: string | null;
|
|
41
|
+
maxOutputTokens: number | null;
|
|
42
|
+
contextWindow: number | null;
|
|
43
|
+
timeouts: TimeoutTiers;
|
|
44
|
+
hasApiKey: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read the mode. Absent/blank or "live" = live (the deployment default); only
|
|
48
|
+
* an explicit "offline" turns the network off. Anything else is a config typo
|
|
49
|
+
* and fails fast instead of silently reaching (or not reaching) a provider.
|
|
50
|
+
*/
|
|
51
|
+
export declare function resolveLlmMode(env?: EnvLike): LlmMode;
|
|
52
|
+
/** Fill `base_url` from CELESTEA_BASE_URL when the profile leaves it empty. */
|
|
53
|
+
export declare function withBaseUrlFallback(profile?: LiveLlmProfile | null, env?: EnvLike): LiveLlmProfile;
|
|
54
|
+
/** Build the live OpenAI-compatible client behind the `Llm` seam. */
|
|
55
|
+
export declare function createLiveLlm(profile?: LiveLlmProfile | null, env?: EnvLike): OpenAiCompatClient;
|
|
56
|
+
/** Secret-free view of the live configuration (never carries the key). */
|
|
57
|
+
export declare function liveLlmView(profile?: LiveLlmProfile | null, env?: EnvLike, mode?: LlmMode): LiveLlmView;
|
package/dist/factory.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production provider factory (W511).
|
|
3
|
+
*
|
|
4
|
+
* The one place a resolved runtime profile becomes a NETWORK-backed `Llm`:
|
|
5
|
+
*
|
|
6
|
+
* base_url profile.base_url > CELESTEA_BASE_URL > DEEPSEEK_BASE_URL
|
|
7
|
+
* > https://api.deepseek.com
|
|
8
|
+
* api key env[profile.api_key_env] ONLY (default DEEPSEEK_API_KEY):
|
|
9
|
+
* never a file, never written back, never echoed
|
|
10
|
+
* reasoning_effort profile value, verbatim (free string, never folded)
|
|
11
|
+
* max_output_tokens profile value
|
|
12
|
+
* timeouts CELESTEA_LLM_{CONNECT,RESPONSE,STREAM_IDLE}_TIMEOUT_MS
|
|
13
|
+
* > profile key > built-in default (0 disables a stage)
|
|
14
|
+
*
|
|
15
|
+
* `context_window_tokens` is not a request field (the engine trims with it); it
|
|
16
|
+
* is surfaced in `liveLlmView()` so a startup log can report the live window
|
|
17
|
+
* next to the model.
|
|
18
|
+
*
|
|
19
|
+
* `CELESTEA_LLM_MODE` picks live vs offline. This package is network-only, so it
|
|
20
|
+
* only REPORTS the mode (the deterministic offline seam is host-side, an
|
|
21
|
+
* injected test seam in apps/studio).
|
|
22
|
+
*/
|
|
23
|
+
import { OpenAiCompatClient } from "./client.js";
|
|
24
|
+
import { LlmError } from "./errors.js";
|
|
25
|
+
import { resolveClientConfig, tiersFromConfig } from "./profile.js";
|
|
26
|
+
/** `live` = the real provider; `offline` = the host's deterministic seam. */
|
|
27
|
+
export const LLM_MODE_ENV = "CELESTEA_LLM_MODE";
|
|
28
|
+
/** Base-URL fallback used by the host (wins over DEEPSEEK_BASE_URL). */
|
|
29
|
+
export const LLM_BASE_URL_ENV = "CELESTEA_BASE_URL";
|
|
30
|
+
function nonEmpty(value) {
|
|
31
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Read the mode. Absent/blank or "live" = live (the deployment default); only
|
|
35
|
+
* an explicit "offline" turns the network off. Anything else is a config typo
|
|
36
|
+
* and fails fast instead of silently reaching (or not reaching) a provider.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveLlmMode(env = process.env) {
|
|
39
|
+
const raw = (env[LLM_MODE_ENV] ?? "").trim().toLowerCase();
|
|
40
|
+
if (raw === "" || raw === "live")
|
|
41
|
+
return "live";
|
|
42
|
+
if (raw === "offline")
|
|
43
|
+
return "offline";
|
|
44
|
+
throw new LlmError(`${LLM_MODE_ENV} must be 'live' or 'offline', got '${raw}'`, "generate");
|
|
45
|
+
}
|
|
46
|
+
/** Fill `base_url` from CELESTEA_BASE_URL when the profile leaves it empty. */
|
|
47
|
+
export function withBaseUrlFallback(profile, env = process.env) {
|
|
48
|
+
const base = profile ?? {};
|
|
49
|
+
if (nonEmpty(base.base_url) !== undefined)
|
|
50
|
+
return base;
|
|
51
|
+
const fromEnv = nonEmpty(env[LLM_BASE_URL_ENV]);
|
|
52
|
+
return fromEnv === undefined ? base : { ...base, base_url: fromEnv };
|
|
53
|
+
}
|
|
54
|
+
/** Build the live OpenAI-compatible client behind the `Llm` seam. */
|
|
55
|
+
export function createLiveLlm(profile, env = process.env) {
|
|
56
|
+
return OpenAiCompatClient.fromProfile(withBaseUrlFallback(profile, env), env);
|
|
57
|
+
}
|
|
58
|
+
/** Secret-free view of the live configuration (never carries the key). */
|
|
59
|
+
export function liveLlmView(profile, env = process.env, mode = resolveLlmMode(env)) {
|
|
60
|
+
const effective = withBaseUrlFallback(profile, env);
|
|
61
|
+
const config = resolveClientConfig(effective, env);
|
|
62
|
+
const window = effective.context_window_tokens;
|
|
63
|
+
return {
|
|
64
|
+
mode,
|
|
65
|
+
model: config.model,
|
|
66
|
+
baseUrl: config.baseUrl,
|
|
67
|
+
reasoningEffort: config.reasoningEffort,
|
|
68
|
+
maxOutputTokens: config.maxOutputTokens,
|
|
69
|
+
contextWindow: typeof window === "number" && window > 0 ? window : null,
|
|
70
|
+
// W835 (R3 batch D / P2-1): the view must use the same null-when-disabled
|
|
71
|
+
// mapping as client.timeouts(), else a disabled stage is printed as "0ms".
|
|
72
|
+
timeouts: tiersFromConfig(config),
|
|
73
|
+
hasApiKey: config.apiKey !== "",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback chain configuration (iteration E §4.2.4) — a sidecar, never a profile.
|
|
3
|
+
*
|
|
4
|
+
* The chain is read from `<data dir>/fallbacks.json` or from `CELESTEA_LLM_FALLBACKS`
|
|
5
|
+
* (the same JSON, inline), and the capability is gated by `CELESTEA_LLM_FALLBACK`
|
|
6
|
+
* (`on|1|true|yes`; **default off** — §4.3 P1, D9). Keeping it here instead of in
|
|
7
|
+
* `Profile` is deliberate: `Profile` is the frozen 12-key contract, so widening it
|
|
8
|
+
* would drag `contracts/` and the legacy side along (§4.2.4 "诚实取舍").
|
|
9
|
+
*
|
|
10
|
+
* Two disciplines are enforced in code, not in prose:
|
|
11
|
+
* - the config records env var NAMES only — a key value can never reach here
|
|
12
|
+
* (§4.5 R4-3), and `available()` answers "is the credential present" without
|
|
13
|
+
* ever reading a value out of the environment;
|
|
14
|
+
* - a chain that is switched on but broken is REPORTED (`problems[]`), never
|
|
15
|
+
* silently downgraded to "no fallback" (U7: `enabled:true` must say that a
|
|
16
|
+
* target is unusable instead of skipping it quietly).
|
|
17
|
+
*/
|
|
18
|
+
import type { FallbackPolicy, LlmTarget } from "./fallback.js";
|
|
19
|
+
/** `<data dir>/fallbacks.json` (§4.2.4). */
|
|
20
|
+
export declare const FALLBACKS_FILE = "fallbacks.json";
|
|
21
|
+
/** `on|1|true|yes` enables the decorator; anything else (and absent) is OFF. */
|
|
22
|
+
export declare const ENV_FALLBACK_SWITCH = "CELESTEA_LLM_FALLBACK";
|
|
23
|
+
/** The same JSON inline (wins over the file). */
|
|
24
|
+
export declare const ENV_FALLBACKS = "CELESTEA_LLM_FALLBACKS";
|
|
25
|
+
/** The parsed sidecar. `version` is the file's own schema version. */
|
|
26
|
+
export interface FallbackConfig {
|
|
27
|
+
version: number;
|
|
28
|
+
/** Config-level switch; the env switch still has to be on as well. */
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
targets: LlmTarget[];
|
|
31
|
+
policy: Partial<FallbackPolicy>;
|
|
32
|
+
/** `env` | `file` — where the chain came from (diagnostics/statusline). */
|
|
33
|
+
source: "env" | "file";
|
|
34
|
+
/** Non-fatal findings (missing key, no targets, …): never silent. */
|
|
35
|
+
problems: string[];
|
|
36
|
+
}
|
|
37
|
+
/** Is the capability switched on? Default OFF (§4.2.4 / D9). */
|
|
38
|
+
export declare function fallbackEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The configured chain, or null when the switch is off / nothing is configured.
|
|
41
|
+
* A malformed JSON, a non-object, or a chain with zero targets is a `problem`,
|
|
42
|
+
* and the caller decides what to report.
|
|
43
|
+
*/
|
|
44
|
+
export declare function loadFallbackConfig(opts: {
|
|
45
|
+
dataDir?: string | null;
|
|
46
|
+
env?: NodeJS.ProcessEnv;
|
|
47
|
+
}): FallbackConfig | null;
|
|
48
|
+
/** Parse + validate one config document; unparsable input is a reported problem. */
|
|
49
|
+
export declare function parseConfig(raw: string, source: "env" | "file"): FallbackConfig;
|
|
50
|
+
/**
|
|
51
|
+
* Credential inventory (U7) — env var NAMES only, never values: a target that
|
|
52
|
+
* names an `apiKeyEnv` which the process does not define is UNAVAILABLE, and the
|
|
53
|
+
* caller must report it instead of dropping the target silently.
|
|
54
|
+
*/
|
|
55
|
+
export declare function targetAvailability(targets: readonly LlmTarget[], env?: NodeJS.ProcessEnv): Array<{
|
|
56
|
+
name: string;
|
|
57
|
+
model: string;
|
|
58
|
+
available: boolean;
|
|
59
|
+
missingEnv: string | null;
|
|
60
|
+
}>;
|
|
61
|
+
/** The `problems[]` of a chain, including the credentials it cannot use (U7). */
|
|
62
|
+
export declare function configProblems(config: FallbackConfig, env?: NodeJS.ProcessEnv): string[];
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback chain configuration (iteration E §4.2.4) — a sidecar, never a profile.
|
|
3
|
+
*
|
|
4
|
+
* The chain is read from `<data dir>/fallbacks.json` or from `CELESTEA_LLM_FALLBACKS`
|
|
5
|
+
* (the same JSON, inline), and the capability is gated by `CELESTEA_LLM_FALLBACK`
|
|
6
|
+
* (`on|1|true|yes`; **default off** — §4.3 P1, D9). Keeping it here instead of in
|
|
7
|
+
* `Profile` is deliberate: `Profile` is the frozen 12-key contract, so widening it
|
|
8
|
+
* would drag `contracts/` and the legacy side along (§4.2.4 "诚实取舍").
|
|
9
|
+
*
|
|
10
|
+
* Two disciplines are enforced in code, not in prose:
|
|
11
|
+
* - the config records env var NAMES only — a key value can never reach here
|
|
12
|
+
* (§4.5 R4-3), and `available()` answers "is the credential present" without
|
|
13
|
+
* ever reading a value out of the environment;
|
|
14
|
+
* - a chain that is switched on but broken is REPORTED (`problems[]`), never
|
|
15
|
+
* silently downgraded to "no fallback" (U7: `enabled:true` must say that a
|
|
16
|
+
* target is unusable instead of skipping it quietly).
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
/** `<data dir>/fallbacks.json` (§4.2.4). */
|
|
21
|
+
export const FALLBACKS_FILE = "fallbacks.json";
|
|
22
|
+
/** `on|1|true|yes` enables the decorator; anything else (and absent) is OFF. */
|
|
23
|
+
export const ENV_FALLBACK_SWITCH = "CELESTEA_LLM_FALLBACK";
|
|
24
|
+
/** The same JSON inline (wins over the file). */
|
|
25
|
+
export const ENV_FALLBACKS = "CELESTEA_LLM_FALLBACKS";
|
|
26
|
+
/** Is the capability switched on? Default OFF (§4.2.4 / D9). */
|
|
27
|
+
export function fallbackEnabled(env = process.env) {
|
|
28
|
+
const raw = (env[ENV_FALLBACK_SWITCH] ?? "").trim().toLowerCase();
|
|
29
|
+
return ["on", "1", "true", "yes"].includes(raw);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The configured chain, or null when the switch is off / nothing is configured.
|
|
33
|
+
* A malformed JSON, a non-object, or a chain with zero targets is a `problem`,
|
|
34
|
+
* and the caller decides what to report.
|
|
35
|
+
*/
|
|
36
|
+
export function loadFallbackConfig(opts) {
|
|
37
|
+
const env = opts.env ?? process.env;
|
|
38
|
+
if (!fallbackEnabled(env))
|
|
39
|
+
return null;
|
|
40
|
+
const inline = (env[ENV_FALLBACKS] ?? "").trim();
|
|
41
|
+
if (inline !== "")
|
|
42
|
+
return parseConfig(inline, "env");
|
|
43
|
+
const dir = opts.dataDir;
|
|
44
|
+
if (dir === null || dir === undefined || dir === "")
|
|
45
|
+
return null;
|
|
46
|
+
let raw;
|
|
47
|
+
try {
|
|
48
|
+
raw = readFileSync(join(dir, FALLBACKS_FILE), "utf8");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return parseConfig(raw, "file");
|
|
54
|
+
}
|
|
55
|
+
/** Parse + validate one config document; unparsable input is a reported problem. */
|
|
56
|
+
export function parseConfig(raw, source) {
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(raw);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
return { version: 1, enabled: false, targets: [], policy: {}, source, problems: [`unparsable JSON (${text(e)})`] };
|
|
63
|
+
}
|
|
64
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
65
|
+
return { version: 1, enabled: false, targets: [], policy: {}, source, problems: ["config must be a JSON object"] };
|
|
66
|
+
}
|
|
67
|
+
const rec = parsed;
|
|
68
|
+
const targets = parseTargets(rec["targets"]);
|
|
69
|
+
const problems = [];
|
|
70
|
+
if (targets.length === 0)
|
|
71
|
+
problems.push("no targets configured");
|
|
72
|
+
return {
|
|
73
|
+
version: typeof rec["version"] === "number" ? rec["version"] : 1,
|
|
74
|
+
enabled: rec["enabled"] !== false,
|
|
75
|
+
targets,
|
|
76
|
+
policy: parsePolicy(rec["policy"]),
|
|
77
|
+
source,
|
|
78
|
+
problems,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function parseTargets(raw) {
|
|
82
|
+
if (!Array.isArray(raw))
|
|
83
|
+
return [];
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const item of raw) {
|
|
86
|
+
if (typeof item !== "object" || item === null)
|
|
87
|
+
continue;
|
|
88
|
+
const rec = item;
|
|
89
|
+
const name = str(rec["name"]);
|
|
90
|
+
const model = str(rec["model"]);
|
|
91
|
+
if (name === null || model === null)
|
|
92
|
+
continue;
|
|
93
|
+
out.push({
|
|
94
|
+
name,
|
|
95
|
+
provider: str(rec["provider"]) ?? name,
|
|
96
|
+
model,
|
|
97
|
+
baseUrl: str(rec["baseUrl"]),
|
|
98
|
+
apiKeyEnv: str(rec["apiKeyEnv"]),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function parsePolicy(raw) {
|
|
104
|
+
if (typeof raw !== "object" || raw === null)
|
|
105
|
+
return {};
|
|
106
|
+
const rec = raw;
|
|
107
|
+
const out = {};
|
|
108
|
+
for (const key of ["maxAttempts", "cooldownMs", "failureThreshold"]) {
|
|
109
|
+
const value = rec[key];
|
|
110
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0)
|
|
111
|
+
out[key] = Math.floor(value);
|
|
112
|
+
}
|
|
113
|
+
for (const key of ["notRetryableStatuses", "retryableStatuses"]) {
|
|
114
|
+
const value = rec[key];
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
const statuses = value.filter((n) => typeof n === "number" && Number.isFinite(n));
|
|
117
|
+
if (statuses.length > 0)
|
|
118
|
+
out[key] = statuses;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (typeof rec["respectRetryAfter"] === "boolean")
|
|
122
|
+
out.respectRetryAfter = rec["respectRetryAfter"];
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Credential inventory (U7) — env var NAMES only, never values: a target that
|
|
127
|
+
* names an `apiKeyEnv` which the process does not define is UNAVAILABLE, and the
|
|
128
|
+
* caller must report it instead of dropping the target silently.
|
|
129
|
+
*/
|
|
130
|
+
export function targetAvailability(targets, env = process.env) {
|
|
131
|
+
return targets.map((target) => {
|
|
132
|
+
const envName = target.apiKeyEnv ?? null;
|
|
133
|
+
const missing = envName !== null && (env[envName] ?? "") === "";
|
|
134
|
+
return { name: target.name, model: target.model, available: !missing, missingEnv: missing ? envName : null };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** The `problems[]` of a chain, including the credentials it cannot use (U7). */
|
|
138
|
+
export function configProblems(config, env = process.env) {
|
|
139
|
+
const out = [...config.problems];
|
|
140
|
+
for (const t of targetAvailability(config.targets, env)) {
|
|
141
|
+
if (!t.available)
|
|
142
|
+
out.push(`target '${t.name}' has no credential (env ${t.missingEnv} is unset)`);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
function str(value) {
|
|
147
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
148
|
+
}
|
|
149
|
+
function text(e) {
|
|
150
|
+
return e instanceof Error ? e.message : String(e);
|
|
151
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
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 type { Usage } from "@celestea/core";
|
|
27
|
+
import { type LlmErrorKind } from "./errors.js";
|
|
28
|
+
import type { Llm, StreamEvent } from "./seam.js";
|
|
29
|
+
/** One fallback target: its own client, hence its own base_url/key/timeouts. */
|
|
30
|
+
export interface LlmTarget {
|
|
31
|
+
/** Stable name used in the ledger (`fallback_from`), the SSE frame and audits. */
|
|
32
|
+
name: string;
|
|
33
|
+
/** `providers.json` row id, for the ledger's `provider` column. */
|
|
34
|
+
provider: string;
|
|
35
|
+
model: string;
|
|
36
|
+
/** Absent = inherit the composed profile's base_url. */
|
|
37
|
+
baseUrl?: string | null;
|
|
38
|
+
/** Env var NAME holding this target's key (never the key itself, §4.5 R4-3). */
|
|
39
|
+
apiKeyEnv?: string | null;
|
|
40
|
+
}
|
|
41
|
+
/** §4.2.1 defaults, verbatim. */
|
|
42
|
+
export interface FallbackPolicy {
|
|
43
|
+
/** 1 primary + 1 fallback by default; never more targets than this. */
|
|
44
|
+
maxAttempts: number;
|
|
45
|
+
/** Target-level cooldown once a target has failed `failureThreshold` times. */
|
|
46
|
+
cooldownMs: number;
|
|
47
|
+
failureThreshold: number;
|
|
48
|
+
notRetryableStatuses: number[];
|
|
49
|
+
retryableStatuses: number[];
|
|
50
|
+
/** Honour `Retry-After`, but never wait longer than `cooldownMs` (§4.5 R4-4). */
|
|
51
|
+
respectRetryAfter: boolean;
|
|
52
|
+
}
|
|
53
|
+
export declare const DEFAULT_FALLBACK_POLICY: FallbackPolicy;
|
|
54
|
+
/** What one CLOSED attempt is booked as (structurally = runtime's `LedgerStepSink`). */
|
|
55
|
+
export interface FallbackStepHandle {
|
|
56
|
+
record(usage: Usage): void;
|
|
57
|
+
close(outcome: {
|
|
58
|
+
kind: "ok" | "error";
|
|
59
|
+
error_kind?: string | null;
|
|
60
|
+
http_status?: number | null;
|
|
61
|
+
retryable?: boolean | null;
|
|
62
|
+
}): void;
|
|
63
|
+
}
|
|
64
|
+
export interface FallbackStepSink {
|
|
65
|
+
beginStep(info: {
|
|
66
|
+
provider: string | null;
|
|
67
|
+
model: string | null;
|
|
68
|
+
base_url_host: string | null;
|
|
69
|
+
attempt: number;
|
|
70
|
+
fallback_from: string | null;
|
|
71
|
+
}): FallbackStepHandle;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* One HAND-OVER, as reported to the host (SSE/audit/statusline). The hook fires
|
|
75
|
+
* when the decorator actually switches — not for the attempt that failed, and
|
|
76
|
+
* never when the failure is terminal (produced-lock / non-retryable status), so
|
|
77
|
+
* "a frame was emitted" always means "another target is being tried".
|
|
78
|
+
*/
|
|
79
|
+
export interface FallbackAttemptInfo {
|
|
80
|
+
/** Index of the attempt that is ABOUT to run (§5.2 numbering). */
|
|
81
|
+
attempt: number;
|
|
82
|
+
/** The target about to run. */
|
|
83
|
+
target: string;
|
|
84
|
+
model: string;
|
|
85
|
+
/** The target that just failed and is being left behind. */
|
|
86
|
+
from: string | null;
|
|
87
|
+
/** `http_503` / `timeout_idle` / `stream` / `network` / `generate`. */
|
|
88
|
+
reason: string;
|
|
89
|
+
httpStatus: number | null;
|
|
90
|
+
/** text/thinking deltas already delivered by the failing attempt. */
|
|
91
|
+
produced: number;
|
|
92
|
+
}
|
|
93
|
+
/** Target health, shared by every session of one process (§4.2.2 cooldown). */
|
|
94
|
+
export declare class FallbackState {
|
|
95
|
+
private readonly failures;
|
|
96
|
+
private readonly benchUntil;
|
|
97
|
+
consecutiveFailures(name: string): number;
|
|
98
|
+
cooldownUntil(name: string): number;
|
|
99
|
+
isCooling(name: string, now: number): boolean;
|
|
100
|
+
/** One failed attempt; `failureThreshold` in a row benches the target. */
|
|
101
|
+
noteFailure(name: string, now: number, policy: FallbackPolicy): void;
|
|
102
|
+
noteSuccess(name: string): void;
|
|
103
|
+
snapshot(now: number): Array<{
|
|
104
|
+
name: string;
|
|
105
|
+
cooling: boolean;
|
|
106
|
+
consecutive_failures: number;
|
|
107
|
+
cooldown_until: number;
|
|
108
|
+
}>;
|
|
109
|
+
}
|
|
110
|
+
export interface FallbackLlmOptions {
|
|
111
|
+
targets: LlmTarget[];
|
|
112
|
+
/** Builds ONE client per target (own base_url/key/timeouts); called lazily. */
|
|
113
|
+
clientFor: (target: LlmTarget) => Llm;
|
|
114
|
+
policy?: Partial<FallbackPolicy>;
|
|
115
|
+
/** Process-shared cooldown/health; a private one is created when absent. */
|
|
116
|
+
state?: FallbackState;
|
|
117
|
+
/** Called once per failed attempt (the visibility hook). */
|
|
118
|
+
onAttempt?: (info: FallbackAttemptInfo) => void;
|
|
119
|
+
/** Per-attempt ledger booking; absent = no ledger hook. */
|
|
120
|
+
steps?: FallbackStepSink | null;
|
|
121
|
+
now?: () => number;
|
|
122
|
+
sleep?: (ms: number) => Promise<void>;
|
|
123
|
+
}
|
|
124
|
+
/** The decorator + the read-only views the statusline needs (§4.2.3 #4). */
|
|
125
|
+
export interface FallbackLlm extends Llm {
|
|
126
|
+
/** Target of the newest attempt (the EFFECTIVE model), null before any. */
|
|
127
|
+
effective(): {
|
|
128
|
+
name: string;
|
|
129
|
+
model: string;
|
|
130
|
+
} | null;
|
|
131
|
+
/** Targets in the order this process would try them right now (cooling last). */
|
|
132
|
+
chain(): string[];
|
|
133
|
+
/** Reason of the newest hand-over (`http_503`, …), null while none happened. */
|
|
134
|
+
lastReason(): string | null;
|
|
135
|
+
/** Failed attempts observed by THIS decorator (diagnostics/tests). */
|
|
136
|
+
failedAttempts(): number;
|
|
137
|
+
}
|
|
138
|
+
/** Cooled-down targets go LAST: a benched target is never the preferred one. */
|
|
139
|
+
export declare function orderTargets(targets: readonly LlmTarget[], state: FallbackState, now: number): LlmTarget[];
|
|
140
|
+
export declare function createFallbackLlm(opts: FallbackLlmOptions): FallbackLlm;
|
|
141
|
+
/** One attempt's failure, in the vocabulary of §4.2.2's table. */
|
|
142
|
+
export interface FailureInfo {
|
|
143
|
+
reason: string;
|
|
144
|
+
kind: LlmErrorKind;
|
|
145
|
+
retryable: boolean;
|
|
146
|
+
httpStatus: number | null;
|
|
147
|
+
retryAfterMs: number | null;
|
|
148
|
+
produced: number;
|
|
149
|
+
message: string;
|
|
150
|
+
}
|
|
151
|
+
/** `text`/`thinking` reaching the consumer = the attempt cannot be redone. */
|
|
152
|
+
export declare function isProducedEvent(event: StreamEvent): boolean;
|
|
153
|
+
/**
|
|
154
|
+
* Classify a thrown failure. `produced > 0` overrides everything: a failure
|
|
155
|
+
* after visible output is terminal, whatever its status said.
|
|
156
|
+
*/
|
|
157
|
+
export declare function describeFailure(error: unknown, produced: number, policy?: FallbackPolicy): FailureInfo;
|
|
158
|
+
/** Classify a terminal stream event (`failed{kindOf}` / `interrupted`). */
|
|
159
|
+
export declare function describeEvent(event: Extract<StreamEvent, {
|
|
160
|
+
kind: "failed";
|
|
161
|
+
}> | {
|
|
162
|
+
kind: "interrupted";
|
|
163
|
+
}, produced: number): FailureInfo;
|