ex-brain 0.1.0 → 0.2.0
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/README.md +87 -37
- package/package.json +6 -5
- package/src/ai/compiler.ts +494 -0
- package/src/ai/embed-factory.ts +116 -0
- package/src/ai/entity-link.ts +195 -0
- package/src/ai/hash-embed.ts +30 -0
- package/src/ai/llm-client.ts +291 -0
- package/src/ai/timeline-extractor.ts +403 -0
- package/src/cli.ts +16 -0
- package/src/commands/compile-cmd.ts +208 -0
- package/src/commands/graph-cmd.ts +1070 -0
- package/src/commands/index.ts +1973 -0
- package/src/config.ts +80 -0
- package/src/db/client.ts +207 -0
- package/src/db/errors.ts +178 -0
- package/src/db/schema.ts +50 -0
- package/src/markdown/io.ts +61 -0
- package/src/markdown/parser.ts +72 -0
- package/src/mcp/server.ts +703 -0
- package/src/repositories/brain-repo.ts +990 -0
- package/src/settings.ts +235 -0
- package/src/types/index.ts +56 -0
- package/src/utils/cli-output.ts +569 -0
- package/src/utils/progress.ts +171 -0
- package/src/utils/query-sanitizer.ts +63 -0
- package/dist/cli.js +0 -93543
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { ResolvedLLM } from "../settings";
|
|
2
|
+
import { callLLM, resolveApiKey, isLLMConfigured } from "./llm-client";
|
|
3
|
+
import { jsonrepair } from "jsonrepair";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Types
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export type EntityType = "person" | "company" | "project" | "organization" | "event" | "other";
|
|
10
|
+
|
|
11
|
+
export type RelationType =
|
|
12
|
+
| "founder_of"
|
|
13
|
+
| "works_at"
|
|
14
|
+
| "leader_of"
|
|
15
|
+
| "collaborates_with"
|
|
16
|
+
| "competes_with"
|
|
17
|
+
| "acquired"
|
|
18
|
+
| "part_of"
|
|
19
|
+
| "invested_in"
|
|
20
|
+
| "mentioned_in"
|
|
21
|
+
| "related_to";
|
|
22
|
+
|
|
23
|
+
export interface EntityRef {
|
|
24
|
+
name: string;
|
|
25
|
+
type: EntityType;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EntityRelation {
|
|
29
|
+
type: "relation";
|
|
30
|
+
from: EntityRef;
|
|
31
|
+
to: EntityRef;
|
|
32
|
+
/** Semantic relation type. */
|
|
33
|
+
relation: RelationType;
|
|
34
|
+
/** The original sentence mentioning this relationship. */
|
|
35
|
+
context: string;
|
|
36
|
+
/** Confidence score 0.0 - 1.0. */
|
|
37
|
+
confidence: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ExtractionResult = EntityRelation[];
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Entity type mapping for slug prefix
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const TYPE_PREFIX: Record<EntityType, string> = {
|
|
47
|
+
person: "people",
|
|
48
|
+
company: "companies",
|
|
49
|
+
project: "projects",
|
|
50
|
+
organization: "organizations",
|
|
51
|
+
event: "events",
|
|
52
|
+
other: "entities",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Convert an entity name to a slug: "Ali Partovi" → "ali-partovi"
|
|
57
|
+
*/
|
|
58
|
+
export function entityToSlug(name: string, type: EntityType): string {
|
|
59
|
+
const prefix = TYPE_PREFIX[type] ?? "entities";
|
|
60
|
+
const slugPart = name
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
|
|
63
|
+
.replace(/^-+|-+$/g, "");
|
|
64
|
+
return `${prefix}/${slugPart || "untitled"}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// LLM extraction
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const RELATION_TYPES = [
|
|
72
|
+
"founder_of", "works_at", "leader_of",
|
|
73
|
+
"collaborates_with", "competes_with", "acquired",
|
|
74
|
+
"part_of", "invested_in", "mentioned_in", "related_to"
|
|
75
|
+
].join(", ");
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Use the configured LLM to extract entity relationships from text.
|
|
79
|
+
* Returns a list of relations with relation type, confidence, and context.
|
|
80
|
+
* Filters out relations with confidence below the threshold (default: 0.7).
|
|
81
|
+
*/
|
|
82
|
+
export async function extractRelations(
|
|
83
|
+
content: string,
|
|
84
|
+
llm: ResolvedLLM,
|
|
85
|
+
options?: {
|
|
86
|
+
/** Minimum confidence threshold (0-1). Relations below this are filtered out. Default: 0.7 */
|
|
87
|
+
confidenceThreshold?: number;
|
|
88
|
+
},
|
|
89
|
+
): Promise<ExtractionResult> {
|
|
90
|
+
const trimmed = content.trim();
|
|
91
|
+
if (!trimmed) return [];
|
|
92
|
+
|
|
93
|
+
// Truncate for API efficiency: first 4000 + last 1000 chars
|
|
94
|
+
let context: string;
|
|
95
|
+
if (trimmed.length <= 5000) {
|
|
96
|
+
context = trimmed;
|
|
97
|
+
} else {
|
|
98
|
+
context = trimmed.slice(0, 4000) + "\n\n...\n\n" + trimmed.slice(-1000);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!isLLMConfigured(llm)) return [];
|
|
102
|
+
|
|
103
|
+
const systemPrompt =
|
|
104
|
+
"You are a knowledge graph extraction assistant. " +
|
|
105
|
+
"Identify relationships between named entities. " +
|
|
106
|
+
"For each relationship, provide: from entity, to entity, relation type, confidence score, and exact context sentence. " +
|
|
107
|
+
`Allowed relation types: ${RELATION_TYPES}. ` +
|
|
108
|
+
"Output ONLY a JSON array. Schema: " +
|
|
109
|
+
'{ "type": "relation", "from": {"name": "...", "type": "..."}, ' +
|
|
110
|
+
'"to": {"name": "...", "type": "..."}, "relation": "...", "context": "...", "confidence": 0.9 }. ' +
|
|
111
|
+
"Output ONLY the JSON array. /no_think";
|
|
112
|
+
|
|
113
|
+
const resp = await callLLM(llm, `Extract relationships from:\n\n${context}`, 1024, systemPrompt);
|
|
114
|
+
if (!resp) return [];
|
|
115
|
+
|
|
116
|
+
// Extract JSON array from response
|
|
117
|
+
const match = resp.match(/\[[\s\S]*\]/);
|
|
118
|
+
if (!match) return [];
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
// Use jsonrepair to fix common LLM JSON issues (unterminated strings, etc.)
|
|
122
|
+
const repaired = jsonrepair(match[0]);
|
|
123
|
+
const parsed = JSON.parse(repaired) as unknown[];
|
|
124
|
+
const relations: ExtractionResult = [];
|
|
125
|
+
|
|
126
|
+
for (const item of parsed) {
|
|
127
|
+
if (typeof item !== "object" || item === null) continue;
|
|
128
|
+
const r = item as Record<string, unknown>;
|
|
129
|
+
if (r.type !== "relation") continue;
|
|
130
|
+
|
|
131
|
+
const fromRef = parseEntityRef(r.from);
|
|
132
|
+
const toRef = parseEntityRef(r.to);
|
|
133
|
+
const relation = String(r.relation || "related_to");
|
|
134
|
+
const contextStr = typeof r.context === "string" ? r.context.trim() : "";
|
|
135
|
+
const confidence = typeof r.confidence === "number" ? r.confidence : 0.8;
|
|
136
|
+
|
|
137
|
+
if (!fromRef || !toRef || !contextStr) continue;
|
|
138
|
+
|
|
139
|
+
relations.push({
|
|
140
|
+
type: "relation",
|
|
141
|
+
from: fromRef,
|
|
142
|
+
to: toRef,
|
|
143
|
+
relation: normalizeRelationType(relation),
|
|
144
|
+
context: contextStr,
|
|
145
|
+
confidence,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Filter by confidence threshold (default 0.7)
|
|
150
|
+
const threshold = options?.confidenceThreshold ?? 0.7;
|
|
151
|
+
return relations.filter((r) => r.confidence >= threshold);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
154
|
+
console.warn(`[ebrain] Entity extraction error: ${msg}`);
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseEntityRef(val: unknown): EntityRef | null {
|
|
160
|
+
if (typeof val !== "object" || val === null) return null;
|
|
161
|
+
const obj = val as Record<string, unknown>;
|
|
162
|
+
const name = typeof obj.name === "string" ? obj.name.trim() : "";
|
|
163
|
+
const rawType = typeof obj.type === "string" ? obj.type : "other";
|
|
164
|
+
if (!name) return null;
|
|
165
|
+
return { name, type: normalizeEntityType(rawType) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeEntityType(raw: string): EntityType {
|
|
169
|
+
const lower = raw.toLowerCase().trim();
|
|
170
|
+
if (lower.includes("person") || lower.includes("people")) return "person";
|
|
171
|
+
if (lower.includes("company") || lower.includes("corp") || lower.includes("business")) return "company";
|
|
172
|
+
if (lower.includes("project")) return "project";
|
|
173
|
+
if (lower.includes("organization") || lower.includes("org") || lower.includes("ngo")) return "organization";
|
|
174
|
+
if (lower.includes("event")) return "event";
|
|
175
|
+
return "other";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function normalizeRelationType(raw: string): RelationType {
|
|
179
|
+
const lower = raw.toLowerCase().trim().replace(/-/g, "_");
|
|
180
|
+
const validTypes = RELATION_TYPES.split(", ");
|
|
181
|
+
if (validTypes.includes(lower as RelationType)) return lower as RelationType;
|
|
182
|
+
// Fallbacks
|
|
183
|
+
if (lower.includes("founder") || lower.includes("create")) return "founder_of";
|
|
184
|
+
if (lower.includes("work") || lower.includes("join")) return "works_at";
|
|
185
|
+
if (lower.includes("lead") || lower.includes("head") || lower.includes("manage")) return "leader_of";
|
|
186
|
+
if (lower.includes("collabor") || lower.includes("partner")) return "collaborates_with";
|
|
187
|
+
if (lower.includes("compet")) return "competes_with";
|
|
188
|
+
if (lower.includes("acquir") || lower.includes("buy")) return "acquired";
|
|
189
|
+
if (lower.includes("invest")) return "invested_in";
|
|
190
|
+
if (lower.includes("part") || lower.includes("belong")) return "part_of";
|
|
191
|
+
if (lower.includes("mention") || lower.includes("refer")) return "mentioned_in";
|
|
192
|
+
return "related_to";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { EmbeddingConfig, EmbeddingFunction } from "seekdb";
|
|
2
|
+
|
|
3
|
+
const DIM = 384;
|
|
4
|
+
|
|
5
|
+
function hashToVector(text: string): number[] {
|
|
6
|
+
const v = new Array<number>(DIM).fill(0);
|
|
7
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
8
|
+
const j = i % DIM;
|
|
9
|
+
v[j] = (v[j]! + text.charCodeAt(i) * (i + 1)) / 1e6;
|
|
10
|
+
}
|
|
11
|
+
const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
|
|
12
|
+
return v.map((x) => x / norm);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 零依赖、确定性的伪向量,满足 seekdb 集合对「文档 → 向量」的硬性要求;
|
|
17
|
+
* 不替代真实语义模型,仅用于嵌入模式本地可跑通全文 + 近似检索管线。
|
|
18
|
+
*/
|
|
19
|
+
export class LocalHashEmbeddingFunction implements EmbeddingFunction {
|
|
20
|
+
readonly name = "ebrain-local-hash";
|
|
21
|
+
readonly dimension = DIM;
|
|
22
|
+
|
|
23
|
+
async generate(texts: string[]): Promise<number[][]> {
|
|
24
|
+
return texts.map((t) => hashToVector(t));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getConfig(): EmbeddingConfig {
|
|
28
|
+
return { dimension: DIM };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified LLM Client Module
|
|
3
|
+
*
|
|
4
|
+
* Provides centralized LLM calling functionality with:
|
|
5
|
+
* - Retry mechanism (exponential backoff, max 3 retries)
|
|
6
|
+
* - Error classification (APIError, TimeoutError, RateLimitError)
|
|
7
|
+
* - Timeout control
|
|
8
|
+
* - Unified API key resolution
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ResolvedLLM } from "../settings";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Error Classes
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export class LLMError extends Error {
|
|
18
|
+
constructor(
|
|
19
|
+
message: string,
|
|
20
|
+
public readonly code: string,
|
|
21
|
+
public readonly statusCode?: number,
|
|
22
|
+
public readonly retryable: boolean = false,
|
|
23
|
+
) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "LLMError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class APIError extends LLMError {
|
|
30
|
+
constructor(message: string, statusCode?: number) {
|
|
31
|
+
super(message, "API_ERROR", statusCode, false);
|
|
32
|
+
this.name = "APIError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TimeoutError extends LLMError {
|
|
37
|
+
constructor(message: string = "LLM request timed out") {
|
|
38
|
+
super(message, "TIMEOUT_ERROR", undefined, true);
|
|
39
|
+
this.name = "TimeoutError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class RateLimitError extends LLMError {
|
|
44
|
+
constructor(message: string = "Rate limit exceeded", retryAfter?: number) {
|
|
45
|
+
super(message, "RATE_LIMIT_ERROR", 429, true);
|
|
46
|
+
this.name = "RateLimitError";
|
|
47
|
+
this.retryAfter = retryAfter;
|
|
48
|
+
}
|
|
49
|
+
readonly retryAfter?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Configuration
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export interface LLMClientConfig {
|
|
57
|
+
/** Maximum number of retry attempts (default: 3) */
|
|
58
|
+
maxRetries?: number;
|
|
59
|
+
/** Base delay for exponential backoff in ms (default: 1000) */
|
|
60
|
+
baseDelay?: number;
|
|
61
|
+
/** Maximum delay cap in ms (default: 10000) */
|
|
62
|
+
maxDelay?: number;
|
|
63
|
+
/** Request timeout in ms (default: 60000) */
|
|
64
|
+
timeout?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const DEFAULT_CONFIG: Required<LLMClientConfig> = {
|
|
68
|
+
maxRetries: 3,
|
|
69
|
+
baseDelay: 1000,
|
|
70
|
+
maxDelay: 10000,
|
|
71
|
+
timeout: 60000,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// API Key Resolution
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve API key from LLM configuration.
|
|
80
|
+
* Checks direct apiKey first, then falls back to environment variable.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveApiKey(llm: ResolvedLLM): string {
|
|
83
|
+
if (llm.apiKey) return llm.apiKey;
|
|
84
|
+
if (llm.apiKeyEnv) return process.env[llm.apiKeyEnv] ?? "";
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if LLM is properly configured with an API key.
|
|
90
|
+
*/
|
|
91
|
+
export function isLLMConfigured(llm: ResolvedLLM): boolean {
|
|
92
|
+
return !!resolveApiKey(llm);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// LLM Call with Retry
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Call LLM with unified fetch, retry mechanism, error handling, and timeout.
|
|
101
|
+
*
|
|
102
|
+
* @param llm - Resolved LLM configuration
|
|
103
|
+
* @param prompt - Prompt to send to the LLM
|
|
104
|
+
* @param maxTokens - Maximum tokens in response
|
|
105
|
+
* @param systemPrompt - Optional system prompt (default provided)
|
|
106
|
+
* @param config - Optional client configuration
|
|
107
|
+
* @returns Raw response text from LLM, or empty string on failure
|
|
108
|
+
*/
|
|
109
|
+
export async function callLLM(
|
|
110
|
+
llm: ResolvedLLM,
|
|
111
|
+
prompt: string,
|
|
112
|
+
maxTokens: number,
|
|
113
|
+
systemPrompt: string = "You are a helpful assistant. Always output valid JSON.",
|
|
114
|
+
config: LLMClientConfig = {},
|
|
115
|
+
): Promise<string> {
|
|
116
|
+
const apiKey = resolveApiKey(llm);
|
|
117
|
+
if (!apiKey) {
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
122
|
+
const url = llm.baseURL.endsWith("/")
|
|
123
|
+
? llm.baseURL + "chat/completions"
|
|
124
|
+
: llm.baseURL + "/chat/completions";
|
|
125
|
+
|
|
126
|
+
const body = {
|
|
127
|
+
model: llm.model,
|
|
128
|
+
messages: [
|
|
129
|
+
{ role: "system", content: systemPrompt },
|
|
130
|
+
{ role: "user", content: prompt },
|
|
131
|
+
],
|
|
132
|
+
temperature: 0.1,
|
|
133
|
+
max_tokens: maxTokens,
|
|
134
|
+
enable_thinking: false,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
let lastError: LLMError | null = null;
|
|
138
|
+
|
|
139
|
+
for (let attempt = 0; attempt <= cfg.maxRetries; attempt++) {
|
|
140
|
+
try {
|
|
141
|
+
const response = await callWithTimeout(
|
|
142
|
+
fetch(url, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
"Content-Type": "application/json",
|
|
146
|
+
Authorization: `Bearer ${apiKey}`,
|
|
147
|
+
},
|
|
148
|
+
body: JSON.stringify(body),
|
|
149
|
+
}),
|
|
150
|
+
cfg.timeout,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
const text = await response.text().catch(() => "");
|
|
155
|
+
lastError = classifyError(response.status, text, response.statusText);
|
|
156
|
+
|
|
157
|
+
// Don't retry for non-retryable errors
|
|
158
|
+
if (!lastError.retryable || attempt === cfg.maxRetries) {
|
|
159
|
+
console.warn(`[llm-client] LLM call failed after ${attempt + 1} attempt(s): ${lastError.message}`);
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const delay = calculateBackoff(attempt, cfg.baseDelay, cfg.maxDelay, (lastError as RateLimitError).retryAfter);
|
|
164
|
+
console.warn(`[llm-client] Retrying after ${delay}ms (attempt ${attempt + 1}/${cfg.maxRetries})`);
|
|
165
|
+
await sleep(delay);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
|
|
170
|
+
return data.choices?.[0]?.message?.content?.trim() ?? "";
|
|
171
|
+
|
|
172
|
+
} catch (error) {
|
|
173
|
+
// Classify the error
|
|
174
|
+
if (error instanceof TimeoutError) {
|
|
175
|
+
lastError = error;
|
|
176
|
+
} else if (error instanceof LLMError) {
|
|
177
|
+
lastError = error;
|
|
178
|
+
} else {
|
|
179
|
+
// Unknown error - wrap it
|
|
180
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
181
|
+
lastError = new APIError(`Unexpected error: ${msg}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Don't retry if we've exhausted attempts
|
|
185
|
+
if (attempt === cfg.maxRetries) {
|
|
186
|
+
console.warn(`[llm-client] LLM call failed after ${attempt + 1} attempt(s): ${lastError.message}`);
|
|
187
|
+
return "";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check if error is retryable
|
|
191
|
+
if (!lastError.retryable) {
|
|
192
|
+
console.warn(`[llm-client] Non-retryable error: ${lastError.message}`);
|
|
193
|
+
return "";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const delay = calculateBackoff(attempt, cfg.baseDelay, cfg.maxDelay);
|
|
197
|
+
console.warn(`[llm-client] Retrying after ${delay}ms (attempt ${attempt + 1}/${cfg.maxRetries}): ${lastError.message}`);
|
|
198
|
+
await sleep(delay);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return "";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Classify HTTP error into appropriate error type.
|
|
207
|
+
*/
|
|
208
|
+
function classifyError(status: number, responseText: string, statusText: string): LLMError {
|
|
209
|
+
const truncatedText = responseText.slice(0, 200);
|
|
210
|
+
|
|
211
|
+
switch (status) {
|
|
212
|
+
case 429:
|
|
213
|
+
// Try to extract retry-after from response
|
|
214
|
+
const retryAfterMatch = responseText.match(/retry[- ]?after["']?\s*[:=]\s*(\d+)/i);
|
|
215
|
+
const retryAfter = retryAfterMatch?.[1] ? parseInt(retryAfterMatch[1], 10) : undefined;
|
|
216
|
+
return new RateLimitError(`Rate limited: ${statusText} - ${truncatedText}`, retryAfter);
|
|
217
|
+
|
|
218
|
+
case 408:
|
|
219
|
+
case 504:
|
|
220
|
+
return new TimeoutError(`Request timeout: ${statusText}`);
|
|
221
|
+
|
|
222
|
+
case 500:
|
|
223
|
+
case 502:
|
|
224
|
+
case 503:
|
|
225
|
+
return new APIError(`Server error (${status}): ${truncatedText}`, status);
|
|
226
|
+
|
|
227
|
+
default:
|
|
228
|
+
if (status >= 500) {
|
|
229
|
+
return new APIError(`Server error (${status}): ${truncatedText}`, status);
|
|
230
|
+
}
|
|
231
|
+
if (status >= 400) {
|
|
232
|
+
return new APIError(`Client error (${status}): ${truncatedText}`, status);
|
|
233
|
+
}
|
|
234
|
+
return new APIError(`HTTP error (${status}): ${truncatedText}`, status);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Calculate exponential backoff delay with jitter.
|
|
240
|
+
*/
|
|
241
|
+
function calculateBackoff(
|
|
242
|
+
attempt: number,
|
|
243
|
+
baseDelay: number,
|
|
244
|
+
maxDelay: number,
|
|
245
|
+
retryAfter?: number,
|
|
246
|
+
): number {
|
|
247
|
+
// If server specified retry-after, use that
|
|
248
|
+
if (retryAfter && retryAfter > 0) {
|
|
249
|
+
return Math.min(retryAfter * 1000, maxDelay);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Exponential backoff: baseDelay * 2^attempt
|
|
253
|
+
const exponentialDelay = baseDelay * Math.pow(2, attempt);
|
|
254
|
+
|
|
255
|
+
// Add jitter (±25%)
|
|
256
|
+
const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
|
|
257
|
+
|
|
258
|
+
return Math.min(Math.round(exponentialDelay + jitter), maxDelay);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Sleep for specified milliseconds.
|
|
263
|
+
*/
|
|
264
|
+
function sleep(ms: number): Promise<void> {
|
|
265
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Wrap fetch with timeout using Promise.race.
|
|
270
|
+
*/
|
|
271
|
+
async function callWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
272
|
+
let timeoutId: NodeJS.Timeout;
|
|
273
|
+
|
|
274
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
275
|
+
timeoutId = setTimeout(() => {
|
|
276
|
+
reject(new TimeoutError(`Request timed out after ${timeoutMs}ms`));
|
|
277
|
+
}, timeoutMs);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
282
|
+
} finally {
|
|
283
|
+
clearTimeout(timeoutId!);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// Re-export settings type for convenience
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
export type { ResolvedLLM } from "../settings";
|