@galdor/provider-openai 0.3.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/dist/convert.d.ts +165 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +302 -0
- package/dist/convert.js.map +1 -0
- package/dist/embed.d.ts +72 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +126 -0
- package/dist/embed.js.map +1 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +89 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +97 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +149 -0
- package/dist/index.js.map +1 -0
- package/dist/stream.d.ts +40 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +178 -0
- package/dist/stream.js.map +1 -0
- package/package.json +36 -0
- package/src/convert.ts +429 -0
- package/src/embed.test.ts +89 -0
- package/src/embed.ts +162 -0
- package/src/errors.ts +103 -0
- package/src/index.ts +198 -0
- package/src/openai.test.ts +184 -0
- package/src/stream.ts +245 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the OpenAI embedder, each pointing the embedder at an ephemeral
|
|
3
|
+
* local server that impersonates the `/embeddings` endpoint.
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { newEmbedder } from "./embed.ts";
|
|
7
|
+
|
|
8
|
+
let server: { stop(): void; url: string } | undefined;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
server?.stop();
|
|
12
|
+
server = undefined;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function serve(handler: (req: Request) => Response | Promise<Response>): string {
|
|
16
|
+
const s = Bun.serve({ port: 0, fetch: handler });
|
|
17
|
+
server = { stop: () => s.stop(true), url: `http://localhost:${s.port}` };
|
|
18
|
+
return server.url;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("OpenAIEmbedder", () => {
|
|
22
|
+
test("posts model + input and returns vectors in input order", async () => {
|
|
23
|
+
let received: any;
|
|
24
|
+
let auth = "";
|
|
25
|
+
const url = serve(async (req) => {
|
|
26
|
+
received = await req.json();
|
|
27
|
+
auth = req.headers.get("authorization") ?? "";
|
|
28
|
+
// Return out of order to prove the embedder reorders by `index`.
|
|
29
|
+
return Response.json({
|
|
30
|
+
model: "text-embedding-3-small",
|
|
31
|
+
data: [
|
|
32
|
+
{ index: 1, embedding: [0.3, 0.4] },
|
|
33
|
+
{ index: 0, embedding: [0.1, 0.2] },
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const embedder = newEmbedder({ apiKey: "sk-test", baseURL: url });
|
|
39
|
+
const vecs = await embedder.embed(["first", "second"]);
|
|
40
|
+
|
|
41
|
+
expect(auth).toBe("Bearer sk-test");
|
|
42
|
+
expect(received.model).toBe("text-embedding-3-small");
|
|
43
|
+
expect(received.input).toEqual(["first", "second"]);
|
|
44
|
+
expect(received.dimensions).toBeUndefined(); // not sent unless explicitly configured
|
|
45
|
+
expect(vecs).toEqual([
|
|
46
|
+
[0.1, 0.2],
|
|
47
|
+
[0.3, 0.4],
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("dimensions(): default is 1536; explicit dim is sent and reported", async () => {
|
|
52
|
+
expect(newEmbedder({ apiKey: "k" }).dimensions()).toBe(1536);
|
|
53
|
+
|
|
54
|
+
let received: any;
|
|
55
|
+
const url = serve(async (req) => {
|
|
56
|
+
received = await req.json();
|
|
57
|
+
return Response.json({ data: [{ index: 0, embedding: [1, 2, 3, 4] }] });
|
|
58
|
+
});
|
|
59
|
+
const embedder = newEmbedder({ apiKey: "k", dimensions: 4, baseURL: url });
|
|
60
|
+
expect(embedder.dimensions()).toBe(4);
|
|
61
|
+
await embedder.embed(["x"]);
|
|
62
|
+
expect(received.dimensions).toBe(4);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("empty input is a no-op (no request)", async () => {
|
|
66
|
+
let called = false;
|
|
67
|
+
const url = serve(() => {
|
|
68
|
+
called = true;
|
|
69
|
+
return Response.json({ data: [] });
|
|
70
|
+
});
|
|
71
|
+
const out = await newEmbedder({ apiKey: "k", baseURL: url }).embed([]);
|
|
72
|
+
expect(out).toEqual([]);
|
|
73
|
+
expect(called).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("throws on a non-2xx response", async () => {
|
|
77
|
+
const url = serve(() => new Response(JSON.stringify({ error: { message: "bad" } }), { status: 401 }));
|
|
78
|
+
await expect(newEmbedder({ apiKey: "k", baseURL: url }).embed(["x"])).rejects.toThrow();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("throws when the count of returned vectors is wrong", async () => {
|
|
82
|
+
const url = serve(() => Response.json({ data: [{ index: 0, embedding: [1] }] }));
|
|
83
|
+
await expect(newEmbedder({ apiKey: "k", baseURL: url }).embed(["a", "b"])).rejects.toThrow(/1 vectors for 2/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("requires an apiKey", () => {
|
|
87
|
+
expect(() => newEmbedder({ apiKey: "" })).toThrow(/apiKey/);
|
|
88
|
+
});
|
|
89
|
+
});
|
package/src/embed.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI embeddings — a galdor {@link Embedder} over the `/embeddings` endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Works against the OpenAI API and any OpenAI-compatible endpoint (Mistral,
|
|
5
|
+
* Together, MiniMax, vLLM, Ollama, …) by pointing {@link EmbedderConfig.baseURL}
|
|
6
|
+
* at it. Construct one with {@link newEmbedder}.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Embedder } from "@galdor/core/memory";
|
|
10
|
+
import { normalizeHTTPError } from "./errors.ts";
|
|
11
|
+
|
|
12
|
+
/** OpenAI's default embedding model and its native dimensionality. */
|
|
13
|
+
const DEFAULT_MODEL = "text-embedding-3-small";
|
|
14
|
+
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
15
|
+
|
|
16
|
+
/** Native vector size for a model when no explicit `dimensions` is configured. */
|
|
17
|
+
function nativeDim(model: string): number {
|
|
18
|
+
return model === "text-embedding-3-large" ? 3072 : 1536;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Configuration for an {@link OpenAIEmbedder}. */
|
|
22
|
+
export interface EmbedderConfig {
|
|
23
|
+
/** Authenticates against the API. Required. */
|
|
24
|
+
apiKey: string;
|
|
25
|
+
/** Embedding model id. Defaults to `"text-embedding-3-small"`. */
|
|
26
|
+
model?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Vector size. Defaults to the model's native size (1536 for
|
|
29
|
+
* `text-embedding-3-small`). When set and the model supports it (the
|
|
30
|
+
* `text-embedding-3-*` family), it is sent as `dimensions` to truncate output.
|
|
31
|
+
*/
|
|
32
|
+
dimensions?: number;
|
|
33
|
+
/** Overrides the API endpoint. Default `https://api.openai.com/v1`; the `/v1` segment is part of it. */
|
|
34
|
+
baseURL?: string;
|
|
35
|
+
/** Sent as `openai-organization` when non-empty. */
|
|
36
|
+
organization?: string;
|
|
37
|
+
/** Sent as `openai-project` when non-empty. */
|
|
38
|
+
project?: string;
|
|
39
|
+
/** Sent as the `User-Agent` header when non-empty. */
|
|
40
|
+
userAgent?: string;
|
|
41
|
+
/** Per-request timeout in milliseconds. Defaults to 60000; `0` disables it. */
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Wire shape of an `/embeddings` response. */
|
|
46
|
+
interface EmbeddingResponse {
|
|
47
|
+
data?: { index: number; embedding: number[] }[];
|
|
48
|
+
model?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A galdor {@link Embedder} backed by the OpenAI embeddings API.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* const embedder = newEmbedder({ apiKey: process.env.OPENAI_API_KEY! });
|
|
57
|
+
* const [vec] = await embedder.embed(["quito ecuador capital"]);
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export class OpenAIEmbedder implements Embedder {
|
|
61
|
+
readonly #apiKey: string;
|
|
62
|
+
readonly #model: string;
|
|
63
|
+
/** Configured size, or learned from the first response when not configured. 0 = unknown. */
|
|
64
|
+
#dim: number;
|
|
65
|
+
readonly #explicitDim: boolean;
|
|
66
|
+
readonly #baseURL: string;
|
|
67
|
+
readonly #organization: string;
|
|
68
|
+
readonly #project: string;
|
|
69
|
+
readonly #userAgent: string;
|
|
70
|
+
readonly #timeoutMs: number;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param cfg - Embedder configuration; `apiKey` is required.
|
|
74
|
+
* @throws {Error} When `apiKey` is missing or blank.
|
|
75
|
+
*/
|
|
76
|
+
constructor(cfg: EmbedderConfig) {
|
|
77
|
+
if (!cfg.apiKey || cfg.apiKey.trim() === "") throw new Error("openai: apiKey is required");
|
|
78
|
+
this.#apiKey = cfg.apiKey;
|
|
79
|
+
this.#model = cfg.model && cfg.model.trim() !== "" ? cfg.model : DEFAULT_MODEL;
|
|
80
|
+
this.#explicitDim = typeof cfg.dimensions === "number" && cfg.dimensions > 0;
|
|
81
|
+
// Leave 0 (unknown) when not configured; the first response fills it in.
|
|
82
|
+
this.#dim = this.#explicitDim ? cfg.dimensions! : 0;
|
|
83
|
+
this.#baseURL = (cfg.baseURL || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
84
|
+
this.#organization = cfg.organization ?? "";
|
|
85
|
+
this.#project = cfg.project ?? "";
|
|
86
|
+
this.#userAgent = cfg.userAgent ?? "";
|
|
87
|
+
this.#timeoutMs = cfg.timeoutMs ?? 60_000;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @returns The embedding model id. */
|
|
91
|
+
model(): string {
|
|
92
|
+
return this.#model;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @returns The embedding vector size: the configured/learned size when known,
|
|
97
|
+
* otherwise the model's native default until the first response sets it.
|
|
98
|
+
*/
|
|
99
|
+
dimensions(): number {
|
|
100
|
+
return this.#dim > 0 ? this.#dim : nativeDim(this.#model);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Embed each input text, preserving order: `out[i]` is the vector for `texts[i]`.
|
|
105
|
+
*
|
|
106
|
+
* @param texts - Inputs to embed; an empty array returns `[]`.
|
|
107
|
+
* @returns One vector per input, ordered to match `texts`.
|
|
108
|
+
* @throws {Error} On a non-2xx response, or when the API returns the wrong number of vectors.
|
|
109
|
+
*/
|
|
110
|
+
async embed(texts: string[], signal?: AbortSignal): Promise<number[][]> {
|
|
111
|
+
if (texts.length === 0) return [];
|
|
112
|
+
const body: Record<string, unknown> = { model: this.#model, input: texts, encoding_format: "float" };
|
|
113
|
+
// Only the text-embedding-3-* family accepts an explicit `dimensions`.
|
|
114
|
+
if (this.#explicitDim) body.dimensions = this.#dim;
|
|
115
|
+
|
|
116
|
+
// Bound the whole request; a caller signal aborts alongside the timeout.
|
|
117
|
+
const timeout = this.#timeoutMs > 0 ? AbortSignal.timeout(this.#timeoutMs) : undefined;
|
|
118
|
+
const sig = signal && timeout ? AbortSignal.any([signal, timeout]) : (signal ?? timeout);
|
|
119
|
+
const res = await fetch(`${this.#baseURL}/embeddings`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: {
|
|
122
|
+
authorization: `Bearer ${this.#apiKey}`,
|
|
123
|
+
"content-type": "application/json",
|
|
124
|
+
...(this.#organization ? { "openai-organization": this.#organization } : {}),
|
|
125
|
+
...(this.#project ? { "openai-project": this.#project } : {}),
|
|
126
|
+
...(this.#userAgent ? { "user-agent": this.#userAgent } : {}),
|
|
127
|
+
},
|
|
128
|
+
body: JSON.stringify(body),
|
|
129
|
+
...(sig ? { signal: sig } : {}),
|
|
130
|
+
});
|
|
131
|
+
if (Math.floor(res.status / 100) !== 2) throw await normalizeHTTPError(res);
|
|
132
|
+
|
|
133
|
+
const parsed = (await res.json()) as EmbeddingResponse;
|
|
134
|
+
const data = parsed.data ?? [];
|
|
135
|
+
if (data.length !== texts.length) {
|
|
136
|
+
throw new Error(`openai: embeddings returned ${data.length} vectors for ${texts.length} inputs`);
|
|
137
|
+
}
|
|
138
|
+
// The API may return out-of-order; place each vector at its reported index.
|
|
139
|
+
const out = new Array<number[]>(texts.length);
|
|
140
|
+
for (const item of data) {
|
|
141
|
+
if (item.index < 0 || item.index >= texts.length) {
|
|
142
|
+
throw new Error(`openai: embedding index ${item.index} out of range`);
|
|
143
|
+
}
|
|
144
|
+
out[item.index] = item.embedding;
|
|
145
|
+
}
|
|
146
|
+
// Cache the native dimensionality from the first successful response so
|
|
147
|
+
// dimensions() doesn't lie; a configured Dim stays authoritative.
|
|
148
|
+
if (this.#dim === 0 && out[0]) this.#dim = out[0].length;
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Construct an {@link OpenAIEmbedder}.
|
|
155
|
+
*
|
|
156
|
+
* @param cfg - Embedder configuration; `apiKey` is required.
|
|
157
|
+
* @returns A ready embedder.
|
|
158
|
+
* @throws {Error} When `apiKey` is missing or blank.
|
|
159
|
+
*/
|
|
160
|
+
export function newEmbedder(cfg: EmbedderConfig): OpenAIEmbedder {
|
|
161
|
+
return new OpenAIEmbedder(cfg);
|
|
162
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes failed OpenAI HTTP responses into galdor's typed {@link APIError}
|
|
3
|
+
* hierarchy. Maps the raw status code to an {@link ErrorKind}, then refines that
|
|
4
|
+
* kind from OpenAI's structured `error.type` / `error.code` body when present,
|
|
5
|
+
* and surfaces any `Retry-After` hint.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { APIError, classify, type ErrorKind, parseRetryAfter } from "@galdor/core/provider";
|
|
9
|
+
|
|
10
|
+
const PROVIDER_NAME = "openai";
|
|
11
|
+
|
|
12
|
+
interface OpenAIErrorBody {
|
|
13
|
+
error?: { type?: string; code?: string; param?: string; message?: string };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Map a bare HTTP status code to a coarse {@link ErrorKind}. */
|
|
17
|
+
function kindForStatus(code: number): ErrorKind {
|
|
18
|
+
if (code === 401 || code === 403) return "auth";
|
|
19
|
+
if (code === 429) return "rate_limited";
|
|
20
|
+
if (code >= 500) return "server";
|
|
21
|
+
if (code >= 400) return "invalid_request";
|
|
22
|
+
return "server";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Refine an error classification from OpenAI's structured `error.type` and
|
|
27
|
+
* `error.code` fields, used when the bare status code is too ambiguous to
|
|
28
|
+
* classify on its own — for example, some OpenAI-compatible backends report a
|
|
29
|
+
* blown context window as a generic 400.
|
|
30
|
+
*
|
|
31
|
+
* @param t - The OpenAI `error.type` discriminator, if any.
|
|
32
|
+
* @param code - The OpenAI `error.code` discriminator, if any.
|
|
33
|
+
* @returns The refined {@link ErrorKind}, or `undefined` when neither field is
|
|
34
|
+
* recognized and the caller should fall back to the status-based kind.
|
|
35
|
+
*/
|
|
36
|
+
export function kindForType(t: string | undefined, code: string | undefined): ErrorKind | undefined {
|
|
37
|
+
switch (t) {
|
|
38
|
+
case "invalid_request_error":
|
|
39
|
+
return code === "context_length_exceeded" ? "context_window" : "invalid_request";
|
|
40
|
+
case "authentication_error":
|
|
41
|
+
case "permission_error":
|
|
42
|
+
return "auth";
|
|
43
|
+
case "rate_limit_error":
|
|
44
|
+
case "tokens_exceeded":
|
|
45
|
+
return "rate_limited";
|
|
46
|
+
case "server_error":
|
|
47
|
+
case "internal_server_error":
|
|
48
|
+
return "server";
|
|
49
|
+
}
|
|
50
|
+
switch (code) {
|
|
51
|
+
case "context_length_exceeded":
|
|
52
|
+
return "context_window";
|
|
53
|
+
case "rate_limit_exceeded":
|
|
54
|
+
return "rate_limited";
|
|
55
|
+
case "invalid_api_key":
|
|
56
|
+
return "auth";
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Minimal structural view of an HTTP response that {@link normalizeHTTPError}
|
|
63
|
+
* needs: the status code, a header accessor, and a text body reader. Any Fetch
|
|
64
|
+
* `Response` satisfies this shape.
|
|
65
|
+
*/
|
|
66
|
+
export interface ResponseLike {
|
|
67
|
+
status: number;
|
|
68
|
+
headers: { get(name: string): string | null };
|
|
69
|
+
text(): Promise<string>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Convert a non-2xx OpenAI response into a typed, classified galdor
|
|
74
|
+
* {@link APIError}.
|
|
75
|
+
*
|
|
76
|
+
* @param res - The failed HTTP response (status, headers, body).
|
|
77
|
+
* @returns A classified {@link APIError} carrying the provider name, status
|
|
78
|
+
* code, best-effort message, and any parsed `Retry-After` delay.
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* const res = await fetch(url, opts);
|
|
82
|
+
* if (Math.floor(res.status / 100) !== 2) throw await normalizeHTTPError(res);
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export async function normalizeHTTPError(res: ResponseLike): Promise<APIError> {
|
|
86
|
+
const text = await res.text().catch(() => "");
|
|
87
|
+
let kind = kindForStatus(res.status);
|
|
88
|
+
let message = `openai: HTTP ${res.status}`;
|
|
89
|
+
if (text) {
|
|
90
|
+
try {
|
|
91
|
+
const body = JSON.parse(text) as OpenAIErrorBody;
|
|
92
|
+
if (body.error?.message) message = body.error.message;
|
|
93
|
+
const k = kindForType(body.error?.type, body.error?.code);
|
|
94
|
+
if (k) kind = k;
|
|
95
|
+
} catch {
|
|
96
|
+
/* non-JSON body: keep the status-based kind */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const retryAfter = parseRetryAfter(res.headers.get("retry-after") ?? "", new Date());
|
|
100
|
+
return classify(
|
|
101
|
+
new APIError({ kind, provider: PROVIDER_NAME, statusCode: res.status, message, ...(retryAfter !== null ? { retryAfter } : {}) }),
|
|
102
|
+
);
|
|
103
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @galdor/provider-openai — OpenAI (Chat Completions API) adapter.
|
|
3
|
+
*
|
|
4
|
+
* Implements the galdor {@link Provider} interface over /chat/completions, with
|
|
5
|
+
* tool calling, vision input, structured output (`response_format` json_schema)
|
|
6
|
+
* and SSE streaming.
|
|
7
|
+
*
|
|
8
|
+
* Because the OpenAI Chat Completions surface is the de facto wire standard, the
|
|
9
|
+
* same adapter targets any OpenAI-compatible provider (Groq, Together, MiniMax,
|
|
10
|
+
* Mistral, DeepSeek, vLLM, Ollama, ...) by pointing the `baseURL` config field at
|
|
11
|
+
* their endpoint. The primary entry point is {@link newOpenAI}.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
APIError,
|
|
16
|
+
type Capabilities,
|
|
17
|
+
classify,
|
|
18
|
+
type Event,
|
|
19
|
+
fetchWithHeaderTimeout,
|
|
20
|
+
type Provider,
|
|
21
|
+
type Request,
|
|
22
|
+
type Response,
|
|
23
|
+
type RunContext,
|
|
24
|
+
validateRequest,
|
|
25
|
+
} from "@galdor/core/provider";
|
|
26
|
+
import { buildRequest, type ChatResponse, responseFromWire } from "./convert.ts";
|
|
27
|
+
import { normalizeHTTPError } from "./errors.ts";
|
|
28
|
+
import { streamChat } from "./stream.ts";
|
|
29
|
+
|
|
30
|
+
const PROVIDER_NAME = "openai";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Default production API endpoint. It already includes the `/v1` path segment,
|
|
34
|
+
* so the adapter only appends `/chat/completions` — the convention used by the
|
|
35
|
+
* official OpenAI client libraries and by every OpenAI-compatible provider's
|
|
36
|
+
* documentation.
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
39
|
+
|
|
40
|
+
/** Configuration for an {@link OpenAIProvider}. */
|
|
41
|
+
export interface Config {
|
|
42
|
+
/** Authenticates against the OpenAI API. Required. */
|
|
43
|
+
apiKey: string;
|
|
44
|
+
/**
|
|
45
|
+
* Overrides the API endpoint. Default https://api.openai.com/v1. Set this to
|
|
46
|
+
* point at an OpenAI-compatible provider (Groq, Together, MiniMax, Mistral,
|
|
47
|
+
* DeepSeek, vLLM, Ollama, ...). The /v1 segment is part of the baseURL.
|
|
48
|
+
*/
|
|
49
|
+
baseURL?: string;
|
|
50
|
+
/** Sent as openai-organization when non-empty. */
|
|
51
|
+
organization?: string;
|
|
52
|
+
/** Sent as openai-project when non-empty. */
|
|
53
|
+
project?: string;
|
|
54
|
+
/** Appended to the default user-agent when non-empty. */
|
|
55
|
+
userAgent?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Response-header timeout in milliseconds: abort if the server doesn't return
|
|
58
|
+
* headers in time. Streaming bodies are NOT cut off once headers arrive.
|
|
59
|
+
* Defaults to 60000 (60 s); `0` disables it. Also settable via
|
|
60
|
+
* `LLM_HTTP_TIMEOUT` through providerset.
|
|
61
|
+
*/
|
|
62
|
+
timeoutMs?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* galdor {@link Provider} backed by the OpenAI Chat Completions API (or any
|
|
67
|
+
* OpenAI-compatible endpoint selected via {@link Config.baseURL}).
|
|
68
|
+
*
|
|
69
|
+
* Use {@link newOpenAI} to construct one, or instantiate directly.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* const provider = new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY! });
|
|
74
|
+
* const res = await provider.generate({ model: "gpt-4o-mini", messages });
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export class OpenAIProvider implements Provider {
|
|
78
|
+
readonly #apiKey: string;
|
|
79
|
+
readonly #baseURL: string;
|
|
80
|
+
readonly #organization: string;
|
|
81
|
+
readonly #project: string;
|
|
82
|
+
readonly #userAgent: string;
|
|
83
|
+
readonly #timeoutMs: number;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param cfg - Provider configuration; `apiKey` is required.
|
|
87
|
+
* @throws {Error} When `apiKey` is missing or blank.
|
|
88
|
+
*/
|
|
89
|
+
constructor(cfg: Config) {
|
|
90
|
+
if (!cfg.apiKey || cfg.apiKey.trim() === "") throw new Error("openai: apiKey is required");
|
|
91
|
+
this.#apiKey = cfg.apiKey;
|
|
92
|
+
this.#baseURL = (cfg.baseURL || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
93
|
+
this.#organization = cfg.organization ?? "";
|
|
94
|
+
this.#project = cfg.project ?? "";
|
|
95
|
+
this.#userAgent = cfg.userAgent ?? "";
|
|
96
|
+
this.#timeoutMs = cfg.timeoutMs ?? 60_000;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @returns The provider's stable identifier, `"openai"`. */
|
|
100
|
+
name(): string {
|
|
101
|
+
return PROVIDER_NAME;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @returns The feature set this adapter supports. */
|
|
105
|
+
capabilities(): Capabilities {
|
|
106
|
+
// promptCaching is false: OpenAI's caching is automatic and ignores
|
|
107
|
+
// CacheControl hints. maxContextTokens reflects the gpt-4o long-context tier.
|
|
108
|
+
return {
|
|
109
|
+
streaming: true,
|
|
110
|
+
toolCalling: true,
|
|
111
|
+
structuredOutput: true,
|
|
112
|
+
promptCaching: false,
|
|
113
|
+
visionInput: true,
|
|
114
|
+
reasoning: true,
|
|
115
|
+
maxContextTokens: 128_000,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#headers(): Record<string, string> {
|
|
120
|
+
let ua = "galdor-openai/0.1";
|
|
121
|
+
if (this.#userAgent) ua += ` ${this.#userAgent}`;
|
|
122
|
+
return {
|
|
123
|
+
authorization: `Bearer ${this.#apiKey}`,
|
|
124
|
+
"content-type": "application/json",
|
|
125
|
+
"user-agent": ua,
|
|
126
|
+
...(this.#organization ? { "openai-organization": this.#organization } : {}),
|
|
127
|
+
...(this.#project ? { "openai-project": this.#project } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Run a single non-streaming completion.
|
|
133
|
+
*
|
|
134
|
+
* @param req - The galdor request to send.
|
|
135
|
+
* @param ctx - Optional run context; its `signal` cancels the request.
|
|
136
|
+
* @returns The decoded galdor {@link Response}.
|
|
137
|
+
* @throws {APIError} When the API returns a non-2xx status, or when the body
|
|
138
|
+
* cannot be decoded as JSON.
|
|
139
|
+
*/
|
|
140
|
+
async generate(req: Request, ctx?: RunContext): Promise<Response> {
|
|
141
|
+
const capErr = validateRequest(this.capabilities(), req);
|
|
142
|
+
if (capErr) throw capErr;
|
|
143
|
+
const wire = buildRequest(req, false);
|
|
144
|
+
const res = await fetchWithHeaderTimeout(
|
|
145
|
+
`${this.#baseURL}/chat/completions`,
|
|
146
|
+
{ method: "POST", headers: this.#headers(), body: JSON.stringify(wire) },
|
|
147
|
+
this.#timeoutMs,
|
|
148
|
+
ctx?.signal,
|
|
149
|
+
);
|
|
150
|
+
if (Math.floor(res.status / 100) !== 2) throw await normalizeHTTPError(res);
|
|
151
|
+
|
|
152
|
+
const raw = new Uint8Array(await res.arrayBuffer());
|
|
153
|
+
let body: ChatResponse;
|
|
154
|
+
try {
|
|
155
|
+
body = JSON.parse(new TextDecoder().decode(raw)) as ChatResponse;
|
|
156
|
+
} catch (e) {
|
|
157
|
+
throw classify(
|
|
158
|
+
new APIError({ kind: "server", provider: PROVIDER_NAME, statusCode: res.status, message: `decode response: ${(e as Error).message}` }),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return responseFromWire(body, raw);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Run a streaming completion, yielding provider {@link Event}s as they arrive.
|
|
166
|
+
*
|
|
167
|
+
* @param req - The galdor request to send.
|
|
168
|
+
* @param ctx - Optional run context; its `signal` cancels the stream.
|
|
169
|
+
* @returns An async iterable of provider events ending in MessageStop.
|
|
170
|
+
* @throws {APIError} When the initial response is non-2xx or an in-stream
|
|
171
|
+
* error frame is received (surfaced while iterating).
|
|
172
|
+
*/
|
|
173
|
+
stream(req: Request, ctx?: RunContext): AsyncIterable<Event> {
|
|
174
|
+
const capErr = validateRequest(this.capabilities(), req);
|
|
175
|
+
if (capErr) throw capErr;
|
|
176
|
+
const wire = buildRequest(req, true);
|
|
177
|
+
return streamChat(`${this.#baseURL}/chat/completions`, this.#headers(), wire, ctx?.signal, this.#timeoutMs);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Construct an {@link OpenAIProvider}.
|
|
183
|
+
*
|
|
184
|
+
* @param cfg - Provider configuration; `apiKey` is required.
|
|
185
|
+
* @returns A ready-to-use provider instance.
|
|
186
|
+
* @throws {Error} When `apiKey` is missing or blank.
|
|
187
|
+
* @example
|
|
188
|
+
* ```ts
|
|
189
|
+
* const provider = newOpenAI({ apiKey: process.env.OPENAI_API_KEY! });
|
|
190
|
+
* const res = await provider.generate({ model: "gpt-4o-mini", messages });
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
export function newOpenAI(cfg: Config): OpenAIProvider {
|
|
194
|
+
return new OpenAIProvider(cfg);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export { normalizeHTTPError } from "./errors.ts";
|
|
198
|
+
export { type EmbedderConfig, newEmbedder, OpenAIEmbedder } from "./embed.ts";
|