@codefionn/llmleaf-client 0.1.2
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 +173 -0
- package/dist/client.d.ts +73 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +259 -0
- package/dist/client.js.map +1 -0
- package/dist/enums.d.ts +21 -0
- package/dist/enums.d.ts.map +1 -0
- package/dist/enums.js +56 -0
- package/dist/enums.js.map +1 -0
- package/dist/error.d.ts +23 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +59 -0
- package/dist/error.js.map +1 -0
- package/dist/gen/llmleaf/v1/llmleaf_pb.d.ts +1374 -0
- package/dist/gen/llmleaf/v1/llmleaf_pb.d.ts.map +1 -0
- package/dist/gen/llmleaf/v1/llmleaf_pb.js +361 -0
- package/dist/gen/llmleaf/v1/llmleaf_pb.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/stream.d.ts +14 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +85 -0
- package/dist/stream.js.map +1 -0
- package/dist/types.d.ts +301 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/dist/wire.d.ts +19 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +569 -0
- package/dist/wire.js.map +1 -0
- package/package.json +50 -0
- package/scripts/gen.sh +42 -0
- package/src/client.ts +353 -0
- package/src/enums.ts +73 -0
- package/src/error.ts +71 -0
- package/src/gen/llmleaf/v1/llmleaf_pb.ts +1675 -0
- package/src/index.ts +77 -0
- package/src/stream.ts +82 -0
- package/src/types.ts +384 -0
- package/src/wire.ts +605 -0
package/scripts/gen.sh
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regenerate the TypeScript typed model from the single proto source of truth.
|
|
3
|
+
#
|
|
4
|
+
# clients/typescript/scripts/gen.sh (or: npm run gen)
|
|
5
|
+
#
|
|
6
|
+
# Toolchain:
|
|
7
|
+
# - protoc (libprotoc 35; the schema compiler, must be on PATH)
|
|
8
|
+
# - protoc-gen-es (the protobuf-es plugin; provided by the npm devDependency
|
|
9
|
+
# @bufbuild/protoc-gen-es — run `npm install` first so it lands
|
|
10
|
+
# in node_modules/.bin)
|
|
11
|
+
#
|
|
12
|
+
# This emits clients/typescript/src/gen/llmleaf/v1/llmleaf_pb.ts — the protobuf-es
|
|
13
|
+
# codegen artifact (committed). The hand-written transport in src/ maps those typed
|
|
14
|
+
# messages to / from the OpenAI/OpenRouter-shaped JSON the llmleaf core speaks.
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
# Run from the typescript client root regardless of the caller's cwd.
|
|
18
|
+
cd "$(dirname "$0")/.."
|
|
19
|
+
|
|
20
|
+
PLUGIN="./node_modules/.bin/protoc-gen-es"
|
|
21
|
+
|
|
22
|
+
if [ ! -x "$PLUGIN" ]; then
|
|
23
|
+
echo "error: $PLUGIN not found." >&2
|
|
24
|
+
echo " run 'npm install' first to fetch @bufbuild/protoc-gen-es." >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
if ! command -v protoc >/dev/null 2>&1; then
|
|
29
|
+
echo "error: protoc not found on PATH (need libprotoc 35)." >&2
|
|
30
|
+
exit 1
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
mkdir -p src/gen
|
|
34
|
+
|
|
35
|
+
protoc \
|
|
36
|
+
--plugin=protoc-gen-es="$PLUGIN" \
|
|
37
|
+
--es_out=src/gen \
|
|
38
|
+
--es_opt=target=ts \
|
|
39
|
+
--proto_path=../proto \
|
|
40
|
+
../proto/llmleaf/v1/llmleaf.proto
|
|
41
|
+
|
|
42
|
+
echo "generated: src/gen/llmleaf/v1/llmleaf_pb.ts"
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// LlmleafClient — the fetch-based HTTP transport. One method per SPEC.md endpoint.
|
|
2
|
+
//
|
|
3
|
+
// Runtime-agnostic: relies only on global fetch / Request / Response / FormData /
|
|
4
|
+
// Blob / AbortController, all present in Node 20+, Deno, Bun and browsers. A custom
|
|
5
|
+
// fetch can be injected (testing, proxies, custom agents).
|
|
6
|
+
|
|
7
|
+
import { ApiError, apiErrorFromResponse } from "./error.js";
|
|
8
|
+
import { parseSseData, parseNdjson } from "./stream.js";
|
|
9
|
+
import {
|
|
10
|
+
encodeChatRequest,
|
|
11
|
+
decodeChatResponse,
|
|
12
|
+
decodeChatCompletionChunk,
|
|
13
|
+
encodeEmbeddingRequest,
|
|
14
|
+
decodeEmbeddingResponse,
|
|
15
|
+
encodeSpeechRequest,
|
|
16
|
+
decodeVoicesResponse,
|
|
17
|
+
decodeTranscriptionResponse,
|
|
18
|
+
decodeListModelsResponse,
|
|
19
|
+
encodeBatchCreateRequest,
|
|
20
|
+
decodeBatchHandle,
|
|
21
|
+
decodeBatchResultLine,
|
|
22
|
+
} from "./wire.js";
|
|
23
|
+
import type {
|
|
24
|
+
ChatRequest,
|
|
25
|
+
ChatResponse,
|
|
26
|
+
ChatCompletionChunk,
|
|
27
|
+
EmbeddingRequest,
|
|
28
|
+
EmbeddingResponse,
|
|
29
|
+
SpeechRequest,
|
|
30
|
+
SpeechResult,
|
|
31
|
+
VoicesResponse,
|
|
32
|
+
TranscriptionRequest,
|
|
33
|
+
TranscriptionResponse,
|
|
34
|
+
ListModelsResponse,
|
|
35
|
+
ListModelsOptions,
|
|
36
|
+
BatchCreateRequest,
|
|
37
|
+
BatchHandle,
|
|
38
|
+
BatchResultLine,
|
|
39
|
+
} from "./types.js";
|
|
40
|
+
|
|
41
|
+
/** The subset of the WHATWG `fetch` signature this client uses. */
|
|
42
|
+
export type FetchLike = (
|
|
43
|
+
input: string | URL,
|
|
44
|
+
init?: RequestInit,
|
|
45
|
+
) => Promise<Response>;
|
|
46
|
+
|
|
47
|
+
export interface LlmleafClientOptions {
|
|
48
|
+
/** Gateway base URL, e.g. "https://gateway.example.com". */
|
|
49
|
+
baseUrl: string;
|
|
50
|
+
/** API key sent as `Authorization: Bearer <apiKey>`. */
|
|
51
|
+
apiKey: string;
|
|
52
|
+
/** Per-request timeout in milliseconds. 0 / omitted disables the timeout. */
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
/** Optional admin token; sent as `x-admin-token` when an endpoint opts in. */
|
|
55
|
+
adminToken?: string;
|
|
56
|
+
/** Inject a custom fetch (defaults to the global `fetch`). */
|
|
57
|
+
fetch?: FetchLike;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Audio file input for transcriptions: bytes + a filename. */
|
|
61
|
+
export interface TranscriptionFile {
|
|
62
|
+
/** A Blob/File, or raw bytes. */
|
|
63
|
+
data: Blob | Uint8Array | ArrayBuffer;
|
|
64
|
+
/** Filename for the multipart part (e.g. "audio.mp3"). */
|
|
65
|
+
filename: string;
|
|
66
|
+
/** Optional content type when `data` is not already a Blob. */
|
|
67
|
+
contentType?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const SPEECH_CONTENT_TYPES: Record<string, string> = {
|
|
71
|
+
mp3: "audio/mpeg",
|
|
72
|
+
wav: "audio/wav",
|
|
73
|
+
opus: "audio/ogg",
|
|
74
|
+
aac: "audio/aac",
|
|
75
|
+
flac: "audio/flac",
|
|
76
|
+
pcm: "audio/pcm",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export class LlmleafClient {
|
|
80
|
+
private readonly baseUrl: string;
|
|
81
|
+
private readonly apiKey: string;
|
|
82
|
+
private readonly timeoutMs: number;
|
|
83
|
+
private readonly adminToken?: string;
|
|
84
|
+
private readonly fetchImpl: FetchLike;
|
|
85
|
+
|
|
86
|
+
constructor(options: LlmleafClientOptions) {
|
|
87
|
+
if (!options.baseUrl) throw new TypeError("LlmleafClient: baseUrl is required");
|
|
88
|
+
if (!options.apiKey) throw new TypeError("LlmleafClient: apiKey is required");
|
|
89
|
+
// Strip a single trailing slash so path joins are predictable.
|
|
90
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
91
|
+
this.apiKey = options.apiKey;
|
|
92
|
+
this.timeoutMs = options.timeoutMs ?? 0;
|
|
93
|
+
this.adminToken = options.adminToken;
|
|
94
|
+
const f = options.fetch ?? (globalThis.fetch as FetchLike | undefined);
|
|
95
|
+
if (!f) {
|
|
96
|
+
throw new TypeError(
|
|
97
|
+
"LlmleafClient: no global fetch available; pass `fetch` in the options (Node <18, or a non-fetch runtime).",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
this.fetchImpl = f;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// -------------------------------------------------------------------------
|
|
104
|
+
// Internal request plumbing
|
|
105
|
+
// -------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
private url(path: string, query?: Record<string, string | undefined>): string {
|
|
108
|
+
let u = this.baseUrl + path;
|
|
109
|
+
if (query) {
|
|
110
|
+
const params = new URLSearchParams();
|
|
111
|
+
for (const [k, v] of Object.entries(query)) {
|
|
112
|
+
if (v !== undefined) params.set(k, v);
|
|
113
|
+
}
|
|
114
|
+
const qs = params.toString();
|
|
115
|
+
if (qs) u += "?" + qs;
|
|
116
|
+
}
|
|
117
|
+
return u;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private headers(extra?: Record<string, string>): Headers {
|
|
121
|
+
const h = new Headers({ authorization: `Bearer ${this.apiKey}` });
|
|
122
|
+
if (extra) for (const [k, v] of Object.entries(extra)) h.set(k, v);
|
|
123
|
+
return h;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Issue a request with the configured timeout; returns the raw Response. */
|
|
127
|
+
private async send(url: string, init: RequestInit): Promise<Response> {
|
|
128
|
+
if (this.timeoutMs <= 0) {
|
|
129
|
+
return this.fetchImpl(url, init);
|
|
130
|
+
}
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
133
|
+
try {
|
|
134
|
+
return await this.fetchImpl(url, { ...init, signal: controller.signal });
|
|
135
|
+
} finally {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Send a JSON request and return the parsed JSON body, or throw ApiError. */
|
|
141
|
+
private async sendJson(
|
|
142
|
+
url: string,
|
|
143
|
+
body: unknown,
|
|
144
|
+
headers?: Record<string, string>,
|
|
145
|
+
): Promise<unknown> {
|
|
146
|
+
const res = await this.send(url, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: this.headers({ "content-type": "application/json", ...headers }),
|
|
149
|
+
body: JSON.stringify(body),
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) throw await apiErrorFromResponse(res);
|
|
152
|
+
return res.json();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// -------------------------------------------------------------------------
|
|
156
|
+
// Chat completions
|
|
157
|
+
// -------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/** POST /v1/chat/completions (non-streaming). */
|
|
160
|
+
async chat(req: ChatRequest): Promise<ChatResponse> {
|
|
161
|
+
const body = encodeChatRequest(req, false);
|
|
162
|
+
const json = await this.sendJson(this.url("/v1/chat/completions"), body);
|
|
163
|
+
return decodeChatResponse(json);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* POST /v1/chat/completions with `stream:true`. Returns an async iterable of
|
|
168
|
+
* {@link ChatCompletionChunk}; stops at the `data: [DONE]` sentinel.
|
|
169
|
+
*/
|
|
170
|
+
async *chatStream(
|
|
171
|
+
req: ChatRequest,
|
|
172
|
+
): AsyncGenerator<ChatCompletionChunk, void, unknown> {
|
|
173
|
+
const body = encodeChatRequest(req, true);
|
|
174
|
+
const res = await this.send(this.url("/v1/chat/completions"), {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: this.headers({
|
|
177
|
+
"content-type": "application/json",
|
|
178
|
+
accept: "text/event-stream",
|
|
179
|
+
}),
|
|
180
|
+
body: JSON.stringify(body),
|
|
181
|
+
});
|
|
182
|
+
if (!res.ok) throw await apiErrorFromResponse(res);
|
|
183
|
+
if (!res.body) {
|
|
184
|
+
throw new ApiError(res.status, "streaming response had no body");
|
|
185
|
+
}
|
|
186
|
+
for await (const payload of parseSseData(res.body)) {
|
|
187
|
+
yield decodeChatCompletionChunk(JSON.parse(payload));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// -------------------------------------------------------------------------
|
|
192
|
+
// Embeddings
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/** POST /v1/embeddings. Base64 embeddings are decoded to float arrays. */
|
|
196
|
+
async embeddings(req: EmbeddingRequest): Promise<EmbeddingResponse> {
|
|
197
|
+
const body = encodeEmbeddingRequest(req);
|
|
198
|
+
const json = await this.sendJson(this.url("/v1/embeddings"), body);
|
|
199
|
+
return decodeEmbeddingResponse(json);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// -------------------------------------------------------------------------
|
|
203
|
+
// Model catalog
|
|
204
|
+
// -------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/** GET /v1/models. Pass `admin:true` to include per-model `endpoints`. */
|
|
207
|
+
async listModels(opts: ListModelsOptions = {}): Promise<ListModelsResponse> {
|
|
208
|
+
const headers: Record<string, string> = {};
|
|
209
|
+
if (opts.admin) {
|
|
210
|
+
if (!this.adminToken) {
|
|
211
|
+
throw new TypeError(
|
|
212
|
+
"listModels({ admin:true }) requires an adminToken in the client options",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
headers["x-admin-token"] = this.adminToken;
|
|
216
|
+
}
|
|
217
|
+
const url = this.url("/v1/models", { type: opts.type, search: opts.search });
|
|
218
|
+
const res = await this.send(url, {
|
|
219
|
+
method: "GET",
|
|
220
|
+
headers: this.headers(headers),
|
|
221
|
+
});
|
|
222
|
+
if (!res.ok) throw await apiErrorFromResponse(res);
|
|
223
|
+
return decodeListModelsResponse(await res.json());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// -------------------------------------------------------------------------
|
|
227
|
+
// Audio
|
|
228
|
+
// -------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/** POST /v1/audio/speech. Returns the raw audio bytes + Content-Type. */
|
|
231
|
+
async speech(req: SpeechRequest): Promise<SpeechResult> {
|
|
232
|
+
const body = encodeSpeechRequest(req);
|
|
233
|
+
const res = await this.send(this.url("/v1/audio/speech"), {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
236
|
+
body: JSON.stringify(body),
|
|
237
|
+
});
|
|
238
|
+
if (!res.ok) throw await apiErrorFromResponse(res);
|
|
239
|
+
const buf = await res.arrayBuffer();
|
|
240
|
+
const contentType =
|
|
241
|
+
res.headers.get("content-type") ??
|
|
242
|
+
(req.responseFormat ? SPEECH_CONTENT_TYPES[req.responseFormat] : undefined) ??
|
|
243
|
+
"application/octet-stream";
|
|
244
|
+
return { bytes: new Uint8Array(buf), contentType };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** GET /v1/audio/voices?model=<id>. */
|
|
248
|
+
async voices(model: string): Promise<VoicesResponse> {
|
|
249
|
+
const url = this.url("/v1/audio/voices", { model });
|
|
250
|
+
const res = await this.send(url, { method: "GET", headers: this.headers() });
|
|
251
|
+
if (!res.ok) throw await apiErrorFromResponse(res);
|
|
252
|
+
return decodeVoicesResponse(await res.json());
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* POST /v1/audio/transcriptions (multipart/form-data).
|
|
257
|
+
*
|
|
258
|
+
* For `responseFormat` json/verbose_json the structured
|
|
259
|
+
* {@link TranscriptionResponse} is returned; for text/srt/vtt the response is a
|
|
260
|
+
* plain-text body and the returned object carries it in `.text` (other fields empty).
|
|
261
|
+
*/
|
|
262
|
+
async transcribe(
|
|
263
|
+
file: TranscriptionFile,
|
|
264
|
+
req: TranscriptionRequest,
|
|
265
|
+
): Promise<TranscriptionResponse> {
|
|
266
|
+
const form = new FormData();
|
|
267
|
+
const blob =
|
|
268
|
+
file.data instanceof Blob
|
|
269
|
+
? file.data
|
|
270
|
+
: new Blob(
|
|
271
|
+
[file.data instanceof Uint8Array ? bytesToArrayBuffer(file.data) : file.data],
|
|
272
|
+
file.contentType ? { type: file.contentType } : undefined,
|
|
273
|
+
);
|
|
274
|
+
form.set("file", blob, file.filename);
|
|
275
|
+
form.set("model", req.model);
|
|
276
|
+
if (req.language !== undefined) form.set("language", req.language);
|
|
277
|
+
if (req.prompt !== undefined) form.set("prompt", req.prompt);
|
|
278
|
+
if (req.responseFormat !== undefined) form.set("response_format", req.responseFormat);
|
|
279
|
+
if (req.temperature !== undefined) form.set("temperature", String(req.temperature));
|
|
280
|
+
|
|
281
|
+
// Do NOT set content-type: the runtime sets it with the multipart boundary.
|
|
282
|
+
const res = await this.send(this.url("/v1/audio/transcriptions"), {
|
|
283
|
+
method: "POST",
|
|
284
|
+
headers: this.headers(),
|
|
285
|
+
body: form,
|
|
286
|
+
});
|
|
287
|
+
if (!res.ok) throw await apiErrorFromResponse(res);
|
|
288
|
+
|
|
289
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
290
|
+
if (ct.includes("application/json")) {
|
|
291
|
+
return decodeTranscriptionResponse(await res.json());
|
|
292
|
+
}
|
|
293
|
+
// text / srt / vtt: a plain-text body. Surface it directly in `.text`.
|
|
294
|
+
return { text: await res.text() };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// -------------------------------------------------------------------------
|
|
298
|
+
// Batches
|
|
299
|
+
// -------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
/** POST /v1/batches. */
|
|
302
|
+
async createBatch(req: BatchCreateRequest): Promise<BatchHandle> {
|
|
303
|
+
const body = encodeBatchCreateRequest(req);
|
|
304
|
+
const json = await this.sendJson(this.url("/v1/batches"), body);
|
|
305
|
+
return decodeBatchHandle(json);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** GET /v1/batches/{id}. */
|
|
309
|
+
async getBatch(id: string): Promise<BatchHandle> {
|
|
310
|
+
const res = await this.send(this.url(`/v1/batches/${encodeURIComponent(id)}`), {
|
|
311
|
+
method: "GET",
|
|
312
|
+
headers: this.headers(),
|
|
313
|
+
});
|
|
314
|
+
if (!res.ok) throw await apiErrorFromResponse(res);
|
|
315
|
+
return decodeBatchHandle(await res.json());
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** POST /v1/batches/{id}/cancel. */
|
|
319
|
+
async cancelBatch(id: string): Promise<BatchHandle> {
|
|
320
|
+
const res = await this.send(
|
|
321
|
+
this.url(`/v1/batches/${encodeURIComponent(id)}/cancel`),
|
|
322
|
+
{ method: "POST", headers: this.headers() },
|
|
323
|
+
);
|
|
324
|
+
if (!res.ok) throw await apiErrorFromResponse(res);
|
|
325
|
+
return decodeBatchHandle(await res.json());
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* GET /v1/batches/{id}/results — an async iterable of {@link BatchResultLine}
|
|
330
|
+
* (one per NDJSON line).
|
|
331
|
+
*/
|
|
332
|
+
async *batchResults(
|
|
333
|
+
id: string,
|
|
334
|
+
): AsyncGenerator<BatchResultLine, void, unknown> {
|
|
335
|
+
const res = await this.send(
|
|
336
|
+
this.url(`/v1/batches/${encodeURIComponent(id)}/results`),
|
|
337
|
+
{ method: "GET", headers: this.headers({ accept: "application/x-ndjson" }) },
|
|
338
|
+
);
|
|
339
|
+
if (!res.ok) throw await apiErrorFromResponse(res);
|
|
340
|
+
if (!res.body) throw new ApiError(res.status, "batch results had no body");
|
|
341
|
+
for await (const line of parseNdjson(res.body)) {
|
|
342
|
+
yield decodeBatchResultLine(line);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Copy a Uint8Array's bytes into a fresh ArrayBuffer (handles non-zero byteOffset). */
|
|
348
|
+
function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
349
|
+
return bytes.buffer.slice(
|
|
350
|
+
bytes.byteOffset,
|
|
351
|
+
bytes.byteOffset + bytes.byteLength,
|
|
352
|
+
) as ArrayBuffer;
|
|
353
|
+
}
|
package/src/enums.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Enum <-> wire-token mapping (SPEC.md "Enum ⇄ wire mapping").
|
|
2
|
+
//
|
|
3
|
+
// Every closed-set enum maps to its wire token by LOWERCASING the value name:
|
|
4
|
+
// TOOL_CALLS -> "tool_calls", ASSISTANT -> "assistant", IN_PROGRESS -> "in_progress".
|
|
5
|
+
// The `*_UNSPECIFIED` zero value <-> field absent on the wire (undefined here).
|
|
6
|
+
//
|
|
7
|
+
// The generated protobuf-es file emits these as TypeScript string-keyed numeric enums
|
|
8
|
+
// (e.g. `Role.ASSISTANT`). A TS numeric enum is bidirectional at runtime: indexing by
|
|
9
|
+
// the numeric value yields the member NAME, which is exactly the proto value name. We
|
|
10
|
+
// reuse that to derive the wire token mechanically — one helper pair for every enum,
|
|
11
|
+
// no per-enum hand mapping (SPEC.md).
|
|
12
|
+
|
|
13
|
+
import { Role, FinishReason, BatchStatus } from "./gen/llmleaf/v1/llmleaf_pb.js";
|
|
14
|
+
|
|
15
|
+
export { Role, FinishReason, BatchStatus };
|
|
16
|
+
|
|
17
|
+
/** A TS numeric enum object: name<->value reverse-mappable at runtime. */
|
|
18
|
+
type NumericEnum = Record<string, string | number>;
|
|
19
|
+
|
|
20
|
+
function unspecifiedName(e: NumericEnum): string | undefined {
|
|
21
|
+
// The zero value is the `*_UNSPECIFIED` member; its name is what 0 maps to.
|
|
22
|
+
const name = e[0];
|
|
23
|
+
return typeof name === "string" ? name : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Encode an enum value to its wire token, or `undefined` for the unspecified zero
|
|
28
|
+
* value (which means "field absent on the wire").
|
|
29
|
+
*/
|
|
30
|
+
export function enumToWire<E extends number>(
|
|
31
|
+
enumObj: NumericEnum,
|
|
32
|
+
value: E | undefined,
|
|
33
|
+
): string | undefined {
|
|
34
|
+
if (value === undefined) return undefined;
|
|
35
|
+
const name = enumObj[value as number];
|
|
36
|
+
if (typeof name !== "string") return undefined;
|
|
37
|
+
if (name === unspecifiedName(enumObj)) return undefined;
|
|
38
|
+
return name.toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decode a wire token back into the enum value. An absent/empty token, or a token
|
|
43
|
+
* that matches no member, maps to the unspecified zero value (0).
|
|
44
|
+
*/
|
|
45
|
+
export function enumFromWire<E extends number>(
|
|
46
|
+
enumObj: NumericEnum,
|
|
47
|
+
token: string | null | undefined,
|
|
48
|
+
): E {
|
|
49
|
+
if (token === null || token === undefined || token === "") return 0 as E;
|
|
50
|
+
const want = token.toLowerCase();
|
|
51
|
+
for (const [name, value] of Object.entries(enumObj)) {
|
|
52
|
+
if (typeof value !== "number") continue;
|
|
53
|
+
if (name.toLowerCase() === want) return value as E;
|
|
54
|
+
}
|
|
55
|
+
return 0 as E;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Convenience typed wrappers for the three enums on the surface.
|
|
59
|
+
|
|
60
|
+
export const roleToWire = (v: Role | undefined): string | undefined =>
|
|
61
|
+
enumToWire<Role>(Role as unknown as NumericEnum, v);
|
|
62
|
+
export const roleFromWire = (t: string | null | undefined): Role =>
|
|
63
|
+
enumFromWire<Role>(Role as unknown as NumericEnum, t);
|
|
64
|
+
|
|
65
|
+
export const finishReasonToWire = (v: FinishReason | undefined): string | undefined =>
|
|
66
|
+
enumToWire<FinishReason>(FinishReason as unknown as NumericEnum, v);
|
|
67
|
+
export const finishReasonFromWire = (t: string | null | undefined): FinishReason =>
|
|
68
|
+
enumFromWire<FinishReason>(FinishReason as unknown as NumericEnum, t);
|
|
69
|
+
|
|
70
|
+
export const batchStatusToWire = (v: BatchStatus | undefined): string | undefined =>
|
|
71
|
+
enumToWire<BatchStatus>(BatchStatus as unknown as NumericEnum, v);
|
|
72
|
+
export const batchStatusFromWire = (t: string | null | undefined): BatchStatus =>
|
|
73
|
+
enumFromWire<BatchStatus>(BatchStatus as unknown as NumericEnum, t);
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Typed error surface. Any non-2xx response carries the envelope
|
|
2
|
+
// {"error":{"message":"...", "type"?:"...", "code"?:"..."}}
|
|
3
|
+
// (SPEC.md "Errors"). We parse it into ApiError and throw.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Thrown for any non-2xx HTTP response from the gateway.
|
|
7
|
+
*
|
|
8
|
+
* Status codes (SPEC.md): 400 bad request · 401 missing/invalid key ·
|
|
9
|
+
* 403 blocked or model-not-allowed · 404 no route for model ·
|
|
10
|
+
* 429 key suspended (limiter) · 502 all upstreams failed.
|
|
11
|
+
*/
|
|
12
|
+
export class ApiError extends Error {
|
|
13
|
+
readonly status: number;
|
|
14
|
+
/** present on some dialects; absent on the llmleaf core envelope. */
|
|
15
|
+
readonly type?: string;
|
|
16
|
+
readonly code?: string;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
status: number,
|
|
20
|
+
message: string,
|
|
21
|
+
opts?: { type?: string; code?: string },
|
|
22
|
+
) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "ApiError";
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.type = opts?.type;
|
|
27
|
+
this.code = opts?.code;
|
|
28
|
+
// Restore prototype chain for instanceof across transpile targets.
|
|
29
|
+
Object.setPrototypeOf(this, ApiError.prototype);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface WireErrorBody {
|
|
34
|
+
message?: unknown;
|
|
35
|
+
type?: unknown;
|
|
36
|
+
code?: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build an {@link ApiError} from a non-2xx response. Reads the body once. Falls back
|
|
41
|
+
* to the HTTP status text when the body is missing or not the expected envelope.
|
|
42
|
+
*/
|
|
43
|
+
export async function apiErrorFromResponse(res: Response): Promise<ApiError> {
|
|
44
|
+
const fallback = res.statusText || `HTTP ${res.status}`;
|
|
45
|
+
let text: string;
|
|
46
|
+
try {
|
|
47
|
+
text = await res.text();
|
|
48
|
+
} catch {
|
|
49
|
+
return new ApiError(res.status, fallback);
|
|
50
|
+
}
|
|
51
|
+
if (!text) return new ApiError(res.status, fallback);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(text) as { error?: WireErrorBody } | WireErrorBody;
|
|
55
|
+
const body: WireErrorBody | undefined =
|
|
56
|
+
parsed && typeof parsed === "object" && "error" in parsed
|
|
57
|
+
? (parsed as { error?: WireErrorBody }).error
|
|
58
|
+
: (parsed as WireErrorBody);
|
|
59
|
+
if (body && typeof body === "object") {
|
|
60
|
+
const message =
|
|
61
|
+
typeof body.message === "string" && body.message ? body.message : fallback;
|
|
62
|
+
return new ApiError(res.status, message, {
|
|
63
|
+
type: typeof body.type === "string" ? body.type : undefined,
|
|
64
|
+
code: typeof body.code === "string" ? body.code : undefined,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Not JSON — fall through and surface the raw text.
|
|
69
|
+
}
|
|
70
|
+
return new ApiError(res.status, text.slice(0, 2048));
|
|
71
|
+
}
|