1
15
16import type {
17 ChatChunk,
18 ChatMessage,
19 ChatRequest,
20 ChatResult,
21 EmbedRequest,
22 EmbedResult,
23 ModelInfo,
24 ProviderAdapter,
25 ProviderId,
26 ToolCall,
27 ToolSpec,
28} from "../types.js";
29import { httpJson, ProviderHttpError, sseData, type HttpOptions } from "./http.js";
30
31export interface OpenAICompatibleOptions extends HttpOptions {
32 id: ProviderId;
33
34 baseUrl: string;
35
36 apiKey?: string;
37 extraHeaders?: Record<string, string>;
38
39 assumeDimension?: number;
40 now?: () => number;
41}
42
43const TEN_MIN = 10 * 60 * 1000;
44
45interface OpenAIChatChunkRaw {
46 choices?: Array<{
47 delta?: {
48 content?: string | null;
49 role?: string;
50 tool_calls?: Array<{
51 index: number;
52 id?: string;
53 type?: string;
54 function?: { name?: string; arguments?: string };
55 }>;
56 };
57 finish_reason?: string | null;
58 }>;
59 usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
60}
61
62interface OpenAIEmbedResponse {
63 data: Array<{ index: number; embedding: number[] }>;
64 model: string;
65 usage?: { prompt_tokens: number; total_tokens: number };
66}
67
68interface OpenAIModelRaw {
69 id: string;
70 created?: number;
71 object?: string;
72}
73
74export class OpenAICompatibleAdapter implements ProviderAdapter {
75 readonly id: ProviderId;
76 protected readonly baseUrl: string;
77 protected readonly apiKey: string | undefined;
78 protected readonly extraHeaders: Record<string, string>;
79 protected readonly http: HttpOptions;
80 protected readonly now: () => number;
81 private modelsCache: { at: number; models: ModelInfo[] } | null = null;
82 private discoveredDimension: number | undefined;
83
84 constructor(options: OpenAICompatibleOptions) {
85 this.id = options.id;
86 this.baseUrl = options.baseUrl.replace(/\/+$/, "");
87 this.apiKey = options.apiKey;
88 this.extraHeaders = options.extraHeaders ?? {};
89 this.http = {
90 ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
91 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
92 ...(options.idleMs !== undefined ? { idleMs: options.idleMs } : {}),
93 };
94 this.now = options.now ?? Date.now;
95 if (options.assumeDimension !== undefined) this.discoveredDimension = options.assumeDimension;
96 }
97
98 protected headers(): Record<string, string> {
99 const h: Record<string, string> = { "content-type": "application/json", ...this.extraHeaders };
100 if (this.apiKey) h.authorization = `Bearer ${this.apiKey}`;
101 return h;
102 }
103
104 async models(): Promise<ModelInfo[]> {
105 if (this.modelsCache && this.now() - this.modelsCache.at < TEN_MIN) return this.modelsCache.models;
106 try {
107 const res = await httpJson<{ data?: OpenAIModelRaw[] }>(`${this.baseUrl}/models`, { method: "GET", headers: this.headers() }, this.http);
108 const data = res.data ?? [];
109 const models: ModelInfo[] = data.map((m) => toModelInfo(m.id, this.id));
110 this.modelsCache = { at: this.now(), models };
111 return models;
112 } catch {
113
114 return this.modelsCache?.models ?? [];
115 }
116 }
117
118
122 async *chat(req: ChatRequest): AsyncIterable<ChatChunk> {
123 const body: Record<string, unknown> = {
124 model: req.model,
125 messages: req.messages.map(mapMessage),
126 stream: true,
127 stream_options: { include_usage: true },
128 };
129 if (req.tools && req.tools.length > 0) {
130 body.tools = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
131 body.tool_choice = mapToolChoice(req.toolChoice);
132 }
133 if (req.temperature !== undefined) body.temperature = req.temperature;
134 if (req.maxTokens !== undefined) body.max_tokens = req.maxTokens;
135
136 const controller = new AbortController();
137 const timer = setTimeout(() => controller.abort(), this.http.timeoutMs ?? 60_000);
138
139 const onCallerAbort = () => controller.abort();
140 if (req.signal) {
141 if (req.signal.aborted) controller.abort();
142 else req.signal.addEventListener("abort", onCallerAbort, { once: true });
143 }
144 let response: Response;
145 try {
146 const fetchImpl = this.http.fetchImpl ?? fetch;
147 response = await fetchImpl(`${this.baseUrl}/chat/completions`, {
148 method: "POST",
149 headers: this.headers(),
150 body: JSON.stringify(body),
151 signal: controller.signal,
152 });
153 } finally {
154 clearTimeout(timer);
155 if (req.signal) req.signal.removeEventListener("abort", onCallerAbort);
156 }
157 if (!response.ok) {
158 const text = await response.text().catch(() => "");
159 throw new ProviderHttpError(response.status, `HTTP ${response.status}: ${text.slice(0, 200)}`, response.status === 429 || response.status >= 500);
160 }
161
162
163 const toolFrags = new Map<number, { id: string; name: string; args: string }>();
164 let finishReason: ChatResult["finishReason"] = "stop";
165 let usage = { in: 0, out: 0, total: 0 };
166
167 try {
168 for await (const event of sseData<OpenAIChatChunkRaw>(response, this.http, req.signal)) {
169 const choice = event.choices?.[0];
170 if (choice) {
171 const delta = choice.delta;
172 if (delta?.content) yield { delta: delta.content, done: false };
173 if (delta?.tool_calls) {
174 for (const tc of delta.tool_calls) {
175 const frag = toolFrags.get(tc.index) ?? { id: "", name: "", args: "" };
176 if (tc.id) frag.id = tc.id;
177 if (tc.function?.name) frag.name += tc.function.name;
178 if (tc.function?.arguments) frag.args += tc.function.arguments;
179 toolFrags.set(tc.index, frag);
180 }
181 }
182 if (choice.finish_reason && choice.finish_reason !== "stop") {
183 finishReason = mapFinishReason(choice.finish_reason);
184 }
185 }
186 if (event.usage) {
187 usage = {
188 in: event.usage.prompt_tokens ?? 0,
189 out: event.usage.completion_tokens ?? 0,
190 total: event.usage.total_tokens ?? 0,
191 };
192 }
193 }
194 } catch (err) {
195
196
197
198 if (err instanceof ProviderHttpError || (err instanceof Error && err.name === "AbortError")) throw err;
199 throw new ProviderHttpError(502, err instanceof Error ? err.message : "stream failed", true);
200 }
201
202
203 for (const [index, frag] of [...toolFrags.entries()].sort((a, b) => a[0] - b[0])) {
204 const toolCall: ToolCall = {
205 id: frag.id || `call_${index}`,
206 name: frag.name,
207 arguments: parseToolArguments(frag.args),
208 };
209 yield { toolCallDelta: toolCall, done: false };
210 }
211 yield { usage, finishReason, done: true };
212 }
213
214
215 async embed(req: EmbedRequest): Promise<EmbedResult> {
216 const started = this.now();
217 const res = await httpJson<OpenAIEmbedResponse>(
218 `${this.baseUrl}/embeddings`,
219 {
220 method: "POST",
221 headers: this.headers(),
222 body: JSON.stringify({ model: req.model, input: req.texts }),
223 },
224 { ...this.http, timeoutMs: this.http.timeoutMs ?? 10_000 },
225 );
226 const sorted = [...res.data].sort((a, b) => a.index - b.index);
227 const vectors = sorted.map((d) => d.embedding);
228 const dimension = vectors[0]?.length ?? 0;
229 if (dimension > 0) this.discoveredDimension = dimension;
230 return {
231 model: req.model,
232 dimension,
233 vectors,
234 usage: { tokens: res.usage?.total_tokens ?? 0 },
235 latencyMs: this.now() - started,
236 provider: this.id,
237 };
238 }
239
240
241 get dimension(): number | undefined {
242 return this.discoveredDimension;
243 }
244
245 async health(): Promise<{ ok: boolean; latencyMs: number }> {
246 const started = this.now();
247 try {
248 await httpJson(`${this.baseUrl}/models`, { method: "GET", headers: this.headers() }, { ...this.http, timeoutMs: 5_000 });
249 return { ok: true, latencyMs: this.now() - started };
250 } catch {
251 return { ok: false, latencyMs: this.now() - started };
252 }
253 }
254}
255
256
257export class OpenAIAdapter extends OpenAICompatibleAdapter {
258 constructor(options: Omit<OpenAICompatibleOptions, "id" | "baseUrl"> & { baseUrl?: string } = {}) {
259 super({
260 ...options,
261 id: "openai",
262 baseUrl: options.baseUrl ?? "https://api.openai.com/v1",
263 });
264 }
265}
266
267
268export class OssAdapter extends OpenAICompatibleAdapter {
269 constructor(options: Omit<OpenAICompatibleOptions, "id">) {
270 super({ ...options, id: "oss" });
271 }
272}
273
274
275export class CustomAdapter extends OpenAICompatibleAdapter {
276 constructor(options: Omit<OpenAICompatibleOptions, "id">) {
277 super({ ...options, id: "custom" });
278 }
279}
280
281
282
283function mapMessage(msg: ChatMessage): Record<string, unknown> {
284 const out: Record<string, unknown> = { role: msg.role, content: msg.content };
285 if (msg.toolCalls && msg.toolCalls.length > 0) {
286 out.tool_calls = msg.toolCalls.map((tc) => ({
287 id: tc.id,
288 type: "function",
289 function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
290 }));
291 }
292 if (msg.toolCallId) out.tool_call_id = msg.toolCallId;
293 if (msg.name) out.name = msg.name;
294 return out;
295}
296
297function mapToolChoice(tc: ChatRequest["toolChoice"]): unknown {
298 if (tc === undefined) return undefined;
299 if (tc === "auto") return "auto";
300 if (tc === "none") return "none";
301 return { type: "function", function: { name: tc.name } };
302}
303
304function mapFinishReason(reason: string): ChatResult["finishReason"] {
305 if (reason === "tool_calls" || reason === "function_call") return "tool_calls";
306 if (reason === "length") return "length";
307 if (reason === "error") return "error";
308 return "stop";
309}
310
311function parseToolArguments(args: string): Record<string, unknown> {
312 if (!args.trim()) return {};
313 try {
314 const parsed: unknown = JSON.parse(args);
315 return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
316 } catch {
317 return {};
318 }
319}
320
321
322function toModelInfo(id: string, provider: ProviderId): ModelInfo {
323 const embed = /embed/i.test(id);
324 const known: Partial<Record<string, { dim: number; in: number; out: number; ctx: number; maxOut: number; tools: boolean; vision: boolean }>> = {
325 "gpt-4o": { dim: 0, in: 2.5, out: 10, ctx: 128_000, maxOut: 16_384, tools: true, vision: true },
326 "gpt-4o-mini": { dim: 0, in: 0.15, out: 0.6, ctx: 128_000, maxOut: 16_384, tools: true, vision: true },
327 "text-embedding-3-small": { dim: 1536, in: 0.02, out: 0, ctx: 8_191, maxOut: 0, tools: false, vision: false },
328 "text-embedding-3-large": { dim: 3072, in: 0.13, out: 0, ctx: 8_191, maxOut: 0, tools: false, vision: false },
329 };
330 const k = known[id];
331 if (k) {
332 return {
333 id,
334 provider,
335 kind: embed ? "embed" : "chat",
336 contextWindow: k.ctx ?? 128_000,
337 maxOutput: k.maxOut ?? 4_096,
338 toolCalls: k.tools ?? false,
339 vision: k.vision ?? false,
340 pricePer1kIn: k.in ?? 0,
341 pricePer1kOut: k.out ?? 0,
342 dimension: k.dim ?? 0,
343 };
344 }
345
346 return {
347 id,
348 provider,
349 kind: embed ? "embed" : "chat",
350 contextWindow: 32_000,
351 maxOutput: 4_096,
352 toolCalls: !embed,
353 vision: false,
354 pricePer1kIn: 0,
355 pricePer1kOut: 0,
356 dimension: 0,
357 };
358}
359