1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121import type { Capability, ProviderBlock } from "../config.ts";
import type { CompletionRequest, CompletionResult, ModelProvider } from "./types.ts";
// Fail a model call that stalls beyond this so it can't hang a session forever.
const REQUEST_TIMEOUT_MS = 120_000;
// Bounded retry for transient failures (connection resets, timeouts, 429/5xx).
// Corporate proxies frequently reset large vision-request bodies mid-flight
// (ECONNRESET); a couple of retries clears those without failing the session.
const MAX_ATTEMPTS = 3;
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// Transient network errors worth retrying. fetch() surfaces the OS/undici code
// on `error.cause.code` (e.g. ECONNRESET) behind a generic "fetch failed".
function isTransientNetworkError(e: unknown): boolean {
const err = e as
| { code?: string; message?: string; name?: string; cause?: { code?: string } }
| null
| undefined;
const code = err?.cause?.code ?? err?.code ?? "";
const transient = new Set([
"ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "EAI_AGAIN", "EPIPE",
"UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT",
]);
if (code && transient.has(code)) return true;
const msg = String(err?.message ?? "");
return /fetch failed|terminated|socket hang up|network|ECONNRESET/i.test(msg);
}
// OpenRouter adapter (PRD §10.3). Speaks the OpenAI-compatible chat
// completions API that OpenRouter exposes, including image content parts.
export class OpenRouterProvider implements ModelProvider {
name = "openrouter";
capabilities: Capability[] = ["text", "vision", "structured_output"];
private apiKey: string;
private baseUrl: string;
constructor(cfg: ProviderBlock) {
if (!cfg.api_key) throw new Error("openrouter: api_key is not configured");
this.apiKey = cfg.api_key;
this.baseUrl = cfg.base_url ?? "https://openrouter.ai/api/v1";
}
async complete(req: CompletionRequest): Promise<CompletionResult> {
const messages = req.messages.map((m) => {
// Attach images to the final user message as OpenAI-style content parts.
if (m.role === "user" && req.images?.length) {
const parts: unknown[] = [{ type: "text", text: m.content }];
for (const img of req.images) {
const b64 = img.data.toString("base64");
parts.push({
type: "image_url",
image_url: { url: `data:${img.media_type};base64,${b64}` },
});
}
return { role: m.role, content: parts };
}
return { role: m.role, content: m.content };
});
const body: Record<string, unknown> = { model: req.model, messages };
if (req.capability === "structured_output" && req.schema) {
body.response_format = {
type: "json_schema",
json_schema: { name: "output", schema: req.schema, strict: true },
};
}
const payload = JSON.stringify(body);
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
// Abort a stalled call so it fails fast instead of hanging the session.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: payload,
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text();
// Retry rate limits and transient server errors; fail fast on 4xx
// (bad key/model/request) where retrying cannot help.
if ([429, 500, 502, 503, 504].includes(res.status) && attempt < MAX_ATTEMPTS) {
lastError = new Error(`openrouter ${res.status}: ${text}`);
await sleep(400 * 2 ** (attempt - 1));
continue;
}
throw new Error(`openrouter ${res.status}: ${text}`);
}
const json = (await res.json()) as {
choices: { message: { content: string } }[];
};
return {
text: json.choices[0]?.message?.content ?? "",
model: req.model,
provider: this.name,
};
} catch (e) {
lastError = e;
if (attempt < MAX_ATTEMPTS && isTransientNetworkError(e)) {
await sleep(400 * 2 ** (attempt - 1));
continue;
}
throw e;
} finally {
clearTimeout(timer);
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
}