@zenera/neo 1.1.9 → 1.1.11

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.
@@ -1,5 +1,6 @@
1
- import { embeddingResponse } from "../embedding.js";
2
- import { zeroUsage } from "../types.js";
1
+ import { callSites, called } from "../failure.js";
2
+ import { fanout } from "./fanout.js";
3
+ import { RateLimiter } from "./limiter.js";
3
4
  // ---------------------------------------------------------------------------
4
5
  // OpenRouter embeddings adapter
5
6
  //
@@ -9,36 +10,65 @@ import { zeroUsage } from "../types.js";
9
10
  // ---------------------------------------------------------------------------
10
11
  /** OpenRouter's word for the retrieval side, which it takes as a free string. */
11
12
  const INPUT_TYPES = { query: 'search_query', document: 'search_document' };
13
+ /**
14
+ * Conservative on purpose. What a batch may hold is whatever the upstream
15
+ * provider routing picked accepts, which is not knowable from here and can
16
+ * differ between two requests for the same model.
17
+ */
18
+ const MAX_BATCH = 96;
19
+ const MAX_BATCH_TOKENS = 100_000;
12
20
  export class OpenRouterEmbedder {
13
21
  id;
14
22
  #client;
15
23
  #options;
24
+ #limiter;
25
+ #site;
16
26
  constructor(id, client, options = {}) {
17
27
  this.id = id;
18
28
  this.#client = client;
19
29
  this.#options = options;
30
+ this.#limiter = options.limiter ?? new RateLimiter();
31
+ this.#site = callSites(id, options.provider);
20
32
  }
21
33
  async embed(req) {
22
- const requestBody = {
23
- model: this.id,
24
- input: req.input,
25
- dimensions: req.dimensions ?? this.#options.dimensions,
26
- inputType: req.taskType ? INPUT_TYPES[req.taskType] : undefined,
27
- provider: this.#options.routing,
28
- // `encodingFormat` is deliberately not sent. Its type is an open
29
- // enum, whose values are branded and so unreachable without a value
30
- // import of the SDK — and float is the default anyway. The string
31
- // branch of `embedding` below is what catches a gateway that
32
- // decides otherwise.
33
- };
34
- const res = await this.#client.embeddings.generate({ requestBody }, { fetchOptions: { signal: req.signal } });
35
- // The response is declared as the body *or* a bare string, so the
36
- // parsed shape has to be established before anything is read off it.
37
- if (typeof res === 'string') {
38
- throw new Error(`embedder "${this.id}": expected an embeddings response, got text`);
39
- }
40
- const vectors = [...res.data].sort(byIndex).map((d) => vectorOf(d, this.id));
41
- return embeddingResponse(vectors, req, res.usage ? { ...zeroUsage(), inputTokens: res.usage.promptTokens } : undefined);
34
+ return await fanout(req, {
35
+ id: this.id,
36
+ limiter: this.#limiter,
37
+ maxBatch: this.#options.maxBatch ?? MAX_BATCH,
38
+ maxBatchTokens: this.#options.maxBatchTokens ?? MAX_BATCH_TOKENS,
39
+ send: async (input) => {
40
+ const requestBody = {
41
+ model: this.id,
42
+ input,
43
+ dimensions: req.dimensions ?? this.#options.dimensions,
44
+ inputType: req.taskType ? INPUT_TYPES[req.taskType] : undefined,
45
+ provider: this.#options.routing,
46
+ // `encodingFormat` is deliberately not sent. Its type is an
47
+ // open enum, whose values are branded and so unreachable
48
+ // without a value import of the SDK — and float is the
49
+ // default anyway. The string branch of `embedding` below is
50
+ // what catches a gateway that decides otherwise.
51
+ };
52
+ const res = await called(this.#site('embedding', 'embeddings.generate'), () => this.#client.embeddings.generate({ requestBody }, {
53
+ fetchOptions: { signal: req.signal },
54
+ // No retrying here: a 429 the SDK swallows is a 429
55
+ // the limiter never hears about, and it is the only
56
+ // thing that can slow the whole run down rather than
57
+ // this one request.
58
+ retries: { strategy: 'none' },
59
+ }));
60
+ // The response is declared as the body *or* a bare string, so
61
+ // the parsed shape has to be established before anything is
62
+ // read off it.
63
+ if (typeof res === 'string') {
64
+ throw new Error(`embedder "${this.id}": expected an embeddings response, got text`);
65
+ }
66
+ return {
67
+ vectors: [...res.data].sort(byIndex).map((d) => vectorOf(d, this.id)),
68
+ inputTokens: res.usage?.promptTokens,
69
+ };
70
+ },
71
+ });
42
72
  }
43
73
  }
44
74
  /** `index` is optional here, so its absence has to mean "keep the order given". */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Three answers, because a limiter can only do three things: send it again
3
+ * later and slow down, send it again later, or give up.
4
+ */
5
+ export type Failure = 'rate-limit' | 'transient' | 'fatal';
6
+ /** The HTTP status, wherever this vendor's SDK decided to keep it. */
7
+ export declare function statusOf(err: unknown): number | undefined;
8
+ export declare function classify(err: unknown): Failure;
9
+ export declare function isAbort(err: unknown): boolean;
10
+ /**
11
+ * How long the provider asked to be left alone. Absent far more often than
12
+ * not — only some vendors send it, and only on some of their limits — so the
13
+ * limiter has to have a schedule of its own regardless.
14
+ */
15
+ export declare function retryAfterMs(err: unknown): number | undefined;
16
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1,131 @@
1
+ // ---------------------------------------------------------------------------
2
+ // What a failed request means
3
+ //
4
+ // Four SDKs, four error shapes, one question: may this be sent again, and does
5
+ // the provider want to be called less often. Everything below is structural —
6
+ // no SDK type is imported, because three of the four are optional peers and an
7
+ // error that arrives from a gateway is not an instance of anything anyway.
8
+ //
9
+ // The one difference worth naming out loud is the status field. OpenAI,
10
+ // Anthropic and @google/genai put it on `status`; the OpenRouter client, being
11
+ // generated, calls it `statusCode`. Reading only one of them reports a revoked
12
+ // key or an exhausted quota as an unknown failure, which is the same trap the
13
+ // CLI's liveness probe already documents.
14
+ // ---------------------------------------------------------------------------
15
+ /** The provider is asking to be called less often, not saying the call was wrong. */
16
+ const RATE_LIMITED = new Set([429, 503]);
17
+ /** Worth another attempt, but says nothing about the rate. */
18
+ const TRANSIENT = new Set([408, 409, 500, 502, 504]);
19
+ /** Undici's words for a connection that never produced a response. */
20
+ const NETWORK = new Set([
21
+ 'ECONNRESET',
22
+ 'ECONNREFUSED',
23
+ 'ETIMEDOUT',
24
+ 'EPIPE',
25
+ 'EAI_AGAIN',
26
+ 'ENOTFOUND',
27
+ 'UND_ERR_SOCKET',
28
+ 'UND_ERR_CONNECT_TIMEOUT',
29
+ 'UND_ERR_HEADERS_TIMEOUT',
30
+ 'UND_ERR_BODY_TIMEOUT',
31
+ 'ERR_STREAM_PREMATURE_CLOSE',
32
+ ]);
33
+ /** The error and everything it blames, guarded against a self-referential cause. */
34
+ function* chain(err) {
35
+ const seen = new Set();
36
+ for (let at = err; at && typeof at === 'object' && !seen.has(at); at = at.cause) {
37
+ seen.add(at);
38
+ yield at;
39
+ }
40
+ }
41
+ /** The HTTP status, wherever this vendor's SDK decided to keep it. */
42
+ export function statusOf(err) {
43
+ for (const link of chain(err)) {
44
+ const raw = link.status ?? link.statusCode;
45
+ const status = typeof raw === 'string' ? Number(raw) : raw;
46
+ if (typeof status === 'number' && Number.isFinite(status)) {
47
+ return status;
48
+ }
49
+ }
50
+ return undefined;
51
+ }
52
+ export function classify(err) {
53
+ // An abort is the caller changing its mind, so it is never retried.
54
+ if (isAbort(err)) {
55
+ return 'fatal';
56
+ }
57
+ const status = statusOf(err);
58
+ if (status !== undefined) {
59
+ if (RATE_LIMITED.has(status)) {
60
+ return 'rate-limit';
61
+ }
62
+ return TRANSIENT.has(status) || status >= 500 ? 'transient' : 'fatal';
63
+ }
64
+ for (const link of chain(err)) {
65
+ if (typeof link.code === 'string' && NETWORK.has(link.code)) {
66
+ return 'transient';
67
+ }
68
+ }
69
+ return 'fatal';
70
+ }
71
+ export function isAbort(err) {
72
+ for (const link of chain(err)) {
73
+ if (link.name === 'AbortError' || link.code === 'ABORT_ERR') {
74
+ return true;
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+ /**
80
+ * How long the provider asked to be left alone. Absent far more often than
81
+ * not — only some vendors send it, and only on some of their limits — so the
82
+ * limiter has to have a schedule of its own regardless.
83
+ */
84
+ export function retryAfterMs(err) {
85
+ for (const link of chain(err)) {
86
+ const headers = link.headers;
87
+ if (!headers) {
88
+ continue;
89
+ }
90
+ // OpenAI's own, and the more precise of the two when both are sent.
91
+ const ms = header(headers, 'retry-after-ms');
92
+ if (ms !== undefined) {
93
+ const value = Number(ms);
94
+ if (Number.isFinite(value) && value >= 0) {
95
+ return value;
96
+ }
97
+ }
98
+ const after = header(headers, 'retry-after');
99
+ if (after === undefined) {
100
+ continue;
101
+ }
102
+ // Seconds, or an HTTP date. The date form is rare but legal.
103
+ const seconds = Number(after);
104
+ if (Number.isFinite(seconds) && seconds >= 0) {
105
+ return seconds * 1000;
106
+ }
107
+ const at = Date.parse(after);
108
+ if (Number.isFinite(at)) {
109
+ return Math.max(0, at - Date.now());
110
+ }
111
+ }
112
+ return undefined;
113
+ }
114
+ /** Headers arrive as a `Headers` instance from one SDK and a plain object from another. */
115
+ function header(headers, name) {
116
+ if (typeof headers !== 'object' || headers === null) {
117
+ return undefined;
118
+ }
119
+ const get = headers.get;
120
+ if (typeof get === 'function') {
121
+ const value = get.call(headers, name);
122
+ return typeof value === 'string' ? value : undefined;
123
+ }
124
+ for (const [key, value] of Object.entries(headers)) {
125
+ if (key.toLowerCase() === name && typeof value === 'string') {
126
+ return value;
127
+ }
128
+ }
129
+ return undefined;
130
+ }
131
+ //# sourceMappingURL=rate-limit.js.map
@@ -0,0 +1,82 @@
1
+ /** What was being asked of a provider, in the runtime's own words. */
2
+ export type Doing = 'llm generation' | 'llm streaming' | 'embedding';
3
+ /** Which call, on whose connection, for which model. */
4
+ export interface CallSite {
5
+ doing: Doing;
6
+ /** the SDK call actually made, spelled the way the vendor spells it */
7
+ api: string;
8
+ /** the model or embedding id it was spent on */
9
+ model: string;
10
+ /** the declared provider name, when a registry built the adapter */
11
+ provider?: string;
12
+ }
13
+ /**
14
+ * The half of a call site an adapter knows before it is called, bound once so
15
+ * that each call names only itself.
16
+ */
17
+ export type CallSites = (doing: Doing, api: string) => CallSite;
18
+ export declare const callSites: (model: string, provider?: string) => CallSites;
19
+ /**
20
+ * Carried by every adapter's options so a failure can name the connection.
21
+ * Reporting only: the client is already built by the time an adapter sees it,
22
+ * so the name is here to be printed, not to be resolved.
23
+ */
24
+ export interface ProviderNamed {
25
+ /** the registered provider name this adapter speaks through */
26
+ provider?: string;
27
+ }
28
+ /** How a call went before it failed, which is most of what makes a failure readable. */
29
+ export interface Attempted {
30
+ /**
31
+ * How many times THIS runtime opened the call. Usually one: every SDK here
32
+ * is configured to retry the request itself, and all of that happens inside
33
+ * a single attempt — which is why the elapsed time matters more than the
34
+ * count. Above one only where a broken stream was reopened.
35
+ */
36
+ attempts: number;
37
+ elapsedMs: number;
38
+ /**
39
+ * Chunks delivered before the stream broke. Any at all means the answer had
40
+ * already started, and a stream that has emitted cannot be replayed: the
41
+ * deltas are on screen.
42
+ */
43
+ chunks?: number;
44
+ }
45
+ /** A provider call that failed, with the call it was. */
46
+ export declare class ProviderError extends Error {
47
+ readonly site: CallSite;
48
+ /** the vendor's own sentence, unwrapped out of whatever it arrived in */
49
+ readonly detail: string;
50
+ /** the HTTP status, wherever the SDK or the body left it */
51
+ readonly status?: number;
52
+ readonly attempts: number;
53
+ readonly elapsedMs: number;
54
+ readonly chunks?: number;
55
+ constructor(site: CallSite, detail: string, status: number | undefined, cause: unknown, tried?: Attempted);
56
+ }
57
+ /** Runs one provider call, and makes sure a refusal says what it refused. */
58
+ export declare function called<T>(site: CallSite, run: () => Promise<T>): Promise<T>;
59
+ /**
60
+ * The same for a response body: an HTTP error, and every mid-stream failure,
61
+ * arrives while the stream is being read — long after the call that opened it
62
+ * returned successfully. None of the SDKs retry that: their backoff wraps the
63
+ * request, and by the time a body is being consumed the request has succeeded.
64
+ *
65
+ * So this does, but only while nothing has come out of it. A connection reset
66
+ * before the first chunk is indistinguishable from one that never opened, and
67
+ * reopening is invisible to the caller. One chunk later it is not: the deltas
68
+ * have been delivered, a second stream would repeat them, and the failure has
69
+ * to be reported instead.
70
+ */
71
+ export declare function reading<T>(site: CallSite, source: AsyncIterable<T>, reopen?: () => Promise<AsyncIterable<T>>): AsyncIterable<T>;
72
+ /** The error to throw in place of `err`, which may be `err` itself. */
73
+ export declare function failed(site: CallSite, err: unknown, tried?: Attempted): unknown;
74
+ /**
75
+ * The sentence a vendor buried, and the status it came with. Exported because
76
+ * it is useful anywhere a raw provider error has to be shown to someone.
77
+ */
78
+ export declare function explain(err: unknown): {
79
+ detail: string;
80
+ status?: number;
81
+ };
82
+ //# sourceMappingURL=failure.d.ts.map
@@ -0,0 +1,323 @@
1
+ import { classify, isAbort, statusOf } from "./embeddings/rate-limit.js";
2
+ export const callSites = (model, provider) => (doing, api) => ({ doing, api, model, provider });
3
+ const ONCE = { attempts: 1, elapsedMs: 0 };
4
+ /** A provider call that failed, with the call it was. */
5
+ export class ProviderError extends Error {
6
+ site;
7
+ /** the vendor's own sentence, unwrapped out of whatever it arrived in */
8
+ detail;
9
+ /** the HTTP status, wherever the SDK or the body left it */
10
+ status;
11
+ attempts;
12
+ elapsedMs;
13
+ chunks;
14
+ constructor(site, detail, status, cause, tried = ONCE) {
15
+ super(`${site.doing} failed — ${where(site)}: ${detail}${trailer(tried)}`, { cause });
16
+ this.name = 'ProviderError';
17
+ this.site = site;
18
+ this.detail = detail;
19
+ this.status = status;
20
+ this.attempts = tried.attempts;
21
+ this.elapsedMs = tried.elapsedMs;
22
+ this.chunks = tried.chunks;
23
+ }
24
+ }
25
+ /**
26
+ * What the failure cost, when that is worth a person's attention. A call that
27
+ * failed at once and was never retried spent nothing worth reporting, and the
28
+ * message is long already.
29
+ */
30
+ function trailer(tried) {
31
+ const said = [];
32
+ if (tried.attempts > 1) {
33
+ said.push(`${tried.attempts} attempts`);
34
+ }
35
+ if (tried.elapsedMs >= 1_000) {
36
+ said.push(`${(tried.elapsedMs / 1000).toFixed(1)}s`);
37
+ }
38
+ if (tried.chunks) {
39
+ // Says why it was not retried, which is the first thing asked of a
40
+ // connection that dropped.
41
+ const many = tried.chunks === 1 ? '' : 's';
42
+ said.push(`${tried.chunks} chunk${many} in, too late to retry`);
43
+ }
44
+ return said.length ? ` [after ${said.join(', ')}]` : '';
45
+ }
46
+ const where = (site) => `${site.provider ? `${site.provider} ` : ''}${site.api}, model "${site.model}"`;
47
+ /** Runs one provider call, and makes sure a refusal says what it refused. */
48
+ export async function called(site, run) {
49
+ const started = Date.now();
50
+ try {
51
+ return await run();
52
+ }
53
+ catch (err) {
54
+ // The SDK's own retries are inside this: an OpenAI or Vertex client
55
+ // gives up only after four of them, so the elapsed time is the only
56
+ // report of what the wait was spent on.
57
+ throw failed(site, err, { attempts: 1, elapsedMs: Date.now() - started });
58
+ }
59
+ }
60
+ /**
61
+ * Attempts to reopen a stream that broke before it said anything, and the pause
62
+ * before each. Short on purpose: the client's own backoff has already been
63
+ * spent getting the request accepted, and a body that dies on an accepted
64
+ * connection is usually a dropped socket rather than a busy server.
65
+ */
66
+ const REOPEN = 2;
67
+ const REOPEN_BACKOFF_MS = 300;
68
+ /**
69
+ * The same for a response body: an HTTP error, and every mid-stream failure,
70
+ * arrives while the stream is being read — long after the call that opened it
71
+ * returned successfully. None of the SDKs retry that: their backoff wraps the
72
+ * request, and by the time a body is being consumed the request has succeeded.
73
+ *
74
+ * So this does, but only while nothing has come out of it. A connection reset
75
+ * before the first chunk is indistinguishable from one that never opened, and
76
+ * reopening is invisible to the caller. One chunk later it is not: the deltas
77
+ * have been delivered, a second stream would repeat them, and the failure has
78
+ * to be reported instead.
79
+ */
80
+ export async function* reading(site, source, reopen) {
81
+ const started = Date.now();
82
+ let stream = source;
83
+ let chunks = 0;
84
+ for (let attempt = 1;; attempt++) {
85
+ try {
86
+ for await (const item of stream) {
87
+ chunks++;
88
+ yield item;
89
+ }
90
+ return;
91
+ }
92
+ catch (err) {
93
+ const again = reopen &&
94
+ chunks === 0 &&
95
+ attempt <= REOPEN &&
96
+ !isAbort(err) &&
97
+ classify(err) !== 'fatal';
98
+ if (!again) {
99
+ throw failed(site, err, {
100
+ attempts: attempt,
101
+ elapsedMs: Date.now() - started,
102
+ chunks,
103
+ });
104
+ }
105
+ await sleep(REOPEN_BACKOFF_MS * attempt);
106
+ stream = await reopen();
107
+ }
108
+ }
109
+ }
110
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
111
+ /** Names for "the caller stopped waiting", which are not the provider's doing. */
112
+ const INTERRUPTED = new Set(['AbortError', 'TimeoutError']);
113
+ /** The error to throw in place of `err`, which may be `err` itself. */
114
+ export function failed(site, err, tried = ONCE) {
115
+ // An abort is the caller's own decision and a deadline is the caller's own
116
+ // clock. Both are recognised by name upstream, so neither is rewritten.
117
+ const name = err?.name;
118
+ if (err instanceof ProviderError ||
119
+ isAbort(err) ||
120
+ (typeof name === 'string' && INTERRUPTED.has(name))) {
121
+ return err;
122
+ }
123
+ const { detail, status } = explain(err);
124
+ return new ProviderError(site, detail, status, err, tried);
125
+ }
126
+ /** Bodies nest at most this deep before the unwrapping is the bug. */
127
+ const MAX_UNWRAP = 4;
128
+ /** Words that name a category of failure without naming the failure. */
129
+ const OPAQUE = /^(fetch failed|failed to fetch|terminated|internal error)\.?$/i;
130
+ /** Longer than this is a document, not a sentence. */
131
+ const MAX_DETAIL = 500;
132
+ /**
133
+ * The sentence a vendor buried, and the status it came with. Exported because
134
+ * it is useful anywhere a raw provider error has to be shown to someone.
135
+ */
136
+ export function explain(err) {
137
+ let status = statusOf(err);
138
+ let code;
139
+ let about;
140
+ let text = messageOf(err);
141
+ // Unwrapping once is not enough — Google's body holds a body — and
142
+ // unwrapping until it stops is a loop waiting for a self-quoting error.
143
+ for (let depth = 0; depth < MAX_UNWRAP; depth++) {
144
+ const said = body(text);
145
+ if (!said) {
146
+ break;
147
+ }
148
+ // The innermost wins: the outer layer says `Bad Request`, the inner one
149
+ // says which argument was invalid.
150
+ status = said.status ?? status;
151
+ code = said.code ?? code;
152
+ about = said.about ?? about;
153
+ text = said.message;
154
+ }
155
+ code ??= symbolOf(err);
156
+ // A failure raised from inside a stream has no HTTP status to carry: the
157
+ // response was a 200 and the refusal arrived as an event in it. The vendor's
158
+ // own word for what went wrong is the only thing left to classify on, and
159
+ // everything downstream — the retry limiter, the CLI's liveness probe —
160
+ // classifies on the status.
161
+ status ??= code ? STATUS.get(code.toLowerCase()) : undefined;
162
+ // `Request contains an invalid argument` names no argument. The vendor's
163
+ // `details` are where the field that was wrong is, when it is anywhere.
164
+ text = collapse(about ? `${text.trim()} — ${about}` : text);
165
+ if (!text || OPAQUE.test(text)) {
166
+ // `fetch failed` is undici's word for DNS, refused, TLS and timeout
167
+ // alike; the reason is only ever in the cause.
168
+ const because = blamed(err, text);
169
+ text = because ? (text ? `${text} — ${because}` : because) : text;
170
+ }
171
+ return { detail: mark(text || String(err), status, code), status };
172
+ }
173
+ const messageOf = (err) => err instanceof Error ? err.message : typeof err === 'string' ? err : String(err);
174
+ /** The vendor's word for the kind of refusal, off the error object itself. */
175
+ function symbolOf(err) {
176
+ for (const link of chain(err)) {
177
+ for (const key of ['code', 'type']) {
178
+ const said = link[key];
179
+ if (typeof said === 'string' && said) {
180
+ return said;
181
+ }
182
+ }
183
+ }
184
+ return undefined;
185
+ }
186
+ /** The error and everything it blames, guarded against a self-referential cause. */
187
+ function* chain(err) {
188
+ const seen = new Set();
189
+ for (let at = err; at && typeof at === 'object' && !seen.has(at); at = cause(at)) {
190
+ seen.add(at);
191
+ yield at;
192
+ }
193
+ }
194
+ const cause = (err) => err.cause;
195
+ /**
196
+ * What a symbolic refusal would have been as an HTTP status, for the vendors
197
+ * that omit one. Only the unambiguous ones: a guess here is a retry decision.
198
+ */
199
+ const STATUS = new Map([
200
+ ['invalid_request_error', 400],
201
+ ['invalid_argument', 400],
202
+ ['context_length_exceeded', 400],
203
+ ['authentication_error', 401],
204
+ ['unauthenticated', 401],
205
+ ['permission_error', 403],
206
+ ['permission_denied', 403],
207
+ ['not_found_error', 404],
208
+ ['not_found', 404],
209
+ ['rate_limit_error', 429],
210
+ ['rate_limit_exceeded', 429],
211
+ ['resource_exhausted', 429],
212
+ ['api_error', 500],
213
+ ['internal', 500],
214
+ ['unavailable', 503],
215
+ ['overloaded_error', 529],
216
+ ]);
217
+ /** A JSON error body, wherever the message happens to begin with one. */
218
+ function body(text) {
219
+ const start = text.indexOf('{');
220
+ if (start === -1) {
221
+ return undefined;
222
+ }
223
+ let parsed;
224
+ try {
225
+ parsed = JSON.parse(text.slice(start));
226
+ }
227
+ catch {
228
+ // Not JSON, or a body cut short. What is already in hand is the answer.
229
+ return undefined;
230
+ }
231
+ const root = parsed;
232
+ const error = root?.error ?? root;
233
+ if (typeof error === 'string') {
234
+ return { message: error };
235
+ }
236
+ if (!error || typeof error !== 'object') {
237
+ return undefined;
238
+ }
239
+ const said = error;
240
+ if (typeof said.message !== 'string') {
241
+ return undefined;
242
+ }
243
+ return {
244
+ message: said.message,
245
+ // `code` is the status on Google and a symbol on OpenAI; `status` is
246
+ // the other way round. Which is which is decided by the type, not by
247
+ // the vendor. `type` is where OpenAI and Anthropic keep the kind.
248
+ status: typeof said.code === 'number' ? said.code : undefined,
249
+ code: typeof said.status === 'string'
250
+ ? said.status
251
+ : typeof said.code === 'string'
252
+ ? said.code
253
+ : typeof said.type === 'string'
254
+ ? said.type
255
+ : undefined,
256
+ about: aboutOf(said.details) ?? (typeof said.param === 'string' ? said.param : undefined),
257
+ };
258
+ }
259
+ /** Names of the field a `google.rpc` detail is about, and of what was wrong with it. */
260
+ const VIOLATION = ['field', 'description', 'reason', 'detail', 'message'];
261
+ /**
262
+ * What a vendor's `details` array says, flattened to one clause.
263
+ *
264
+ * This is where a 400 keeps the part a person needs: the top-level message is
265
+ * `Request contains an invalid argument`, and `fieldViolations[0].field` is the
266
+ * argument. The shapes are `google.rpc.BadRequest`, `ErrorInfo` and `DebugInfo`
267
+ * — read by the field names they have in common rather than by `@type`, since
268
+ * a detail nobody anticipated is still worth printing.
269
+ */
270
+ function aboutOf(details) {
271
+ if (!Array.isArray(details)) {
272
+ return undefined;
273
+ }
274
+ const clauses = [];
275
+ for (const entry of details.flatMap(violations)) {
276
+ if (!entry || typeof entry !== 'object') {
277
+ continue;
278
+ }
279
+ const said = entry;
280
+ const parts = VIOLATION.map((key) => said[key]).filter((v) => typeof v === 'string' && v.length > 0);
281
+ if (parts.length) {
282
+ clauses.push(parts.join(': '));
283
+ }
284
+ }
285
+ return clauses.length ? clauses.join('; ') : undefined;
286
+ }
287
+ /** A detail entry, or the violations inside it when it carries a list of them. */
288
+ function violations(entry) {
289
+ const list = entry?.fieldViolations;
290
+ return Array.isArray(list) ? list : [entry];
291
+ }
292
+ /** One line, however many the vendor sent, and never a whole document. */
293
+ function collapse(text) {
294
+ const flat = text.replace(/\s+/g, ' ').trim();
295
+ return flat.length > MAX_DETAIL ? `${flat.slice(0, MAX_DETAIL)}…` : flat;
296
+ }
297
+ /** What an opaque failure blames, from the first cause that says something else. */
298
+ function blamed(err, said) {
299
+ const seen = new Set();
300
+ let at = err?.cause;
301
+ while (at && typeof at === 'object' && !seen.has(at)) {
302
+ seen.add(at);
303
+ const link = at;
304
+ const message = collapse(typeof link.message === 'string' ? link.message : '');
305
+ if (message && message !== said) {
306
+ return typeof link.code === 'string' ? `${message} (${link.code})` : message;
307
+ }
308
+ at = link.cause;
309
+ }
310
+ return undefined;
311
+ }
312
+ /** Appends the status and the vendor's symbol, unless the sentence has them. */
313
+ function mark(text, status, code) {
314
+ const marks = [];
315
+ if (status !== undefined && !text.includes(String(status))) {
316
+ marks.push(String(status));
317
+ }
318
+ if (code && !text.toLowerCase().includes(code.toLowerCase())) {
319
+ marks.push(code);
320
+ }
321
+ return marks.length ? `${text} (${marks.join(' ')})` : text;
322
+ }
323
+ //# sourceMappingURL=failure.js.map
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export * from './architecture.ts';
3
3
  export * from './embedding.ts';
4
4
  export * from './embeddings/index.ts';
5
5
  export * from './events.ts';
6
+ export * from './failure.ts';
6
7
  export * from './ids.ts';
7
8
  export * from './inspect/index.ts';
8
9
  export * as Kernel from './kernel.ts';
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ export * from "./architecture.js";
3
3
  export * from "./embedding.js";
4
4
  export * from "./embeddings/index.js";
5
5
  export * from "./events.js";
6
+ export * from "./failure.js";
6
7
  export * from "./ids.js";
7
8
  export * from "./inspect/index.js";
8
9
  export * as Kernel from "./kernel.js";