@zenera/neo 1.1.10 → 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.
- package/dist/embedding.d.ts +20 -1
- package/dist/embedding.js +3 -2
- package/dist/embeddings/fanout.d.ts +51 -0
- package/dist/embeddings/fanout.js +76 -0
- package/dist/embeddings/gemini.d.ts +3 -10
- package/dist/embeddings/gemini.js +46 -30
- package/dist/embeddings/index.d.ts +3 -0
- package/dist/embeddings/index.js +3 -0
- package/dist/embeddings/limiter.d.ts +36 -0
- package/dist/embeddings/limiter.js +182 -0
- package/dist/embeddings/openai.d.ts +3 -2
- package/dist/embeddings/openai.js +54 -13
- package/dist/embeddings/openrouter.d.ts +2 -1
- package/dist/embeddings/openrouter.js +52 -23
- package/dist/embeddings/rate-limit.d.ts +16 -0
- package/dist/embeddings/rate-limit.js +131 -0
- package/dist/failure.d.ts +82 -0
- package/dist/failure.js +323 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/models/anthropic.d.ts +2 -1
- package/dist/models/anthropic.js +11 -4
- package/dist/models/factory.d.ts +15 -0
- package/dist/models/factory.js +49 -10
- package/dist/models/gemini.d.ts +2 -1
- package/dist/models/gemini.js +8 -3
- package/dist/models/openai-chat.d.ts +2 -1
- package/dist/models/openai-chat.js +10 -5
- package/dist/models/openai-responses.d.ts +2 -1
- package/dist/models/openai-responses.js +22 -7
- package/dist/models/openrouter.d.ts +2 -1
- package/dist/models/openrouter.js +14 -6
- package/dist/project/config.d.ts +6 -2
- package/dist/project/config.js +13 -1
- package/package.json +1 -1
package/dist/embedding.d.ts
CHANGED
|
@@ -11,7 +11,11 @@ import type { TokenUsage } from './types.ts';
|
|
|
11
11
|
*/
|
|
12
12
|
export type EmbeddingTaskType = 'query' | 'document';
|
|
13
13
|
export interface EmbeddingRequest {
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* The texts to encode, in the order the vectors come back. Any length: the
|
|
16
|
+
* adapter splits it to whatever the model accepts per request and issues
|
|
17
|
+
* those in parallel, so a caller never has to know the cap or invent one.
|
|
18
|
+
*/
|
|
15
19
|
input: string[];
|
|
16
20
|
taskType?: EmbeddingTaskType;
|
|
17
21
|
/** truncate to this width, where the model supports it */
|
|
@@ -21,6 +25,19 @@ export interface EmbeddingRequest {
|
|
|
21
25
|
* Turn it off to see what the model actually returned.
|
|
22
26
|
*/
|
|
23
27
|
normalize?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Texts finished so far, out of the whole input. A large batch is minutes
|
|
30
|
+
* of one `await`, and a caller with nothing to say for that long is
|
|
31
|
+
* indistinguishable from a hung one.
|
|
32
|
+
*/
|
|
33
|
+
onProgress?: (done: number, total: number) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Each slice of the input as it comes back, `at` being where it started in
|
|
36
|
+
* `input`. A large request is many round trips, and a caller that persists
|
|
37
|
+
* them can survive a crash without paying for the same vectors twice.
|
|
38
|
+
* Vectors arrive normalised, exactly as the response will carry them.
|
|
39
|
+
*/
|
|
40
|
+
onSlice?: (at: number, vectors: number[][]) => void;
|
|
24
41
|
signal?: AbortSignal;
|
|
25
42
|
}
|
|
26
43
|
export interface EmbeddingResponse {
|
|
@@ -57,4 +74,6 @@ export interface Embedder {
|
|
|
57
74
|
* provider without three copies of the same loop.
|
|
58
75
|
*/
|
|
59
76
|
export declare function embeddingResponse(vectors: number[][], req: EmbeddingRequest, usage?: TokenUsage): EmbeddingResponse;
|
|
77
|
+
/** Idempotent, so settling a vector that is already unit length changes nothing. */
|
|
78
|
+
export declare function unitVector(vector: number[]): number[];
|
|
60
79
|
//# sourceMappingURL=embedding.d.ts.map
|
package/dist/embedding.js
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export function embeddingResponse(vectors, req, usage) {
|
|
6
6
|
return {
|
|
7
|
-
vectors: req.normalize === false ? vectors : vectors.map(
|
|
7
|
+
vectors: req.normalize === false ? vectors : vectors.map(unitVector),
|
|
8
8
|
dimensions: vectors[0]?.length ?? 0,
|
|
9
9
|
usage,
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
/** Idempotent, so settling a vector that is already unit length changes nothing. */
|
|
13
|
+
export function unitVector(vector) {
|
|
13
14
|
let sum = 0;
|
|
14
15
|
for (const x of vector) {
|
|
15
16
|
sum += x * x;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type EmbeddingRequest, type EmbeddingResponse } from '../embedding.ts';
|
|
2
|
+
import type { ProviderNamed } from '../failure.ts';
|
|
3
|
+
import { RateLimiter } from './limiter.ts';
|
|
4
|
+
/** What one request came back with, before it is joined to the others. */
|
|
5
|
+
export interface EmbeddingSlice {
|
|
6
|
+
vectors: number[][];
|
|
7
|
+
/** absent where the vendor reports characters, or nothing */
|
|
8
|
+
inputTokens?: number;
|
|
9
|
+
}
|
|
10
|
+
/** The per-model knobs every adapter shares, and the connection they share it with. */
|
|
11
|
+
export interface BatchOptions extends ProviderNamed {
|
|
12
|
+
/** texts per request; the model's own cap applies when unset */
|
|
13
|
+
maxBatch?: number;
|
|
14
|
+
/** estimated tokens per request; the model's own cap applies when unset */
|
|
15
|
+
maxBatchTokens?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Paces every embedding request on one connection. Supplied by the registry
|
|
18
|
+
* so two embedders on one account cannot each ramp up to the full limit;
|
|
19
|
+
* an adapter built by hand gets one of its own.
|
|
20
|
+
*/
|
|
21
|
+
limiter?: RateLimiter;
|
|
22
|
+
}
|
|
23
|
+
export interface FanoutPlan {
|
|
24
|
+
/** the embedder's id, for the one error message that needs it */
|
|
25
|
+
id: string;
|
|
26
|
+
limiter: RateLimiter;
|
|
27
|
+
maxBatch: number;
|
|
28
|
+
maxBatchTokens: number;
|
|
29
|
+
send(input: string[]): Promise<EmbeddingSlice>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Four characters to a token: within about 15% for English prose, wrong for CJK
|
|
33
|
+
* and for long identifiers. It only has to be right enough to stay under a cap
|
|
34
|
+
* that is already generous, and being wrong costs an extra request, not a
|
|
35
|
+
* failed one.
|
|
36
|
+
*/
|
|
37
|
+
export declare const estimateTokens: (text: string) => number;
|
|
38
|
+
export declare function fanout(req: EmbeddingRequest, plan: FanoutPlan): Promise<EmbeddingResponse>;
|
|
39
|
+
interface Part {
|
|
40
|
+
/** where these texts started in the caller's array */
|
|
41
|
+
at: number;
|
|
42
|
+
texts: string[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Greedy, on both caps at once. A single text over the token budget goes alone
|
|
46
|
+
* and is sent as it is: truncating it would answer with a vector for something
|
|
47
|
+
* the caller did not ask about, which is worse than the error it will get.
|
|
48
|
+
*/
|
|
49
|
+
export declare function sliceBatch(input: readonly string[], maxBatch: number, maxBatchTokens: number): Part[];
|
|
50
|
+
export {};
|
|
51
|
+
//# sourceMappingURL=fanout.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { embeddingResponse, unitVector, } from "../embedding.js";
|
|
2
|
+
import { zeroUsage } from "../types.js";
|
|
3
|
+
import { RateLimiter } from "./limiter.js";
|
|
4
|
+
/**
|
|
5
|
+
* Four characters to a token: within about 15% for English prose, wrong for CJK
|
|
6
|
+
* and for long identifiers. It only has to be right enough to stay under a cap
|
|
7
|
+
* that is already generous, and being wrong costs an extra request, not a
|
|
8
|
+
* failed one.
|
|
9
|
+
*/
|
|
10
|
+
export const estimateTokens = (text) => Math.ceil(text.length / 4);
|
|
11
|
+
export async function fanout(req, plan) {
|
|
12
|
+
if (req.input.length === 0) {
|
|
13
|
+
return embeddingResponse([], req);
|
|
14
|
+
}
|
|
15
|
+
const parts = sliceBatch(req.input, plan.maxBatch, plan.maxBatchTokens);
|
|
16
|
+
const settle = req.normalize !== false;
|
|
17
|
+
let done = 0;
|
|
18
|
+
const slices = await Promise.all(parts.map((part) => plan.limiter.run(async () => {
|
|
19
|
+
const slice = await plan.send(part.texts);
|
|
20
|
+
if (slice.vectors.length !== part.texts.length) {
|
|
21
|
+
throw new Error(`embedder "${plan.id}": answered ${slice.vectors.length} vectors ` +
|
|
22
|
+
`for ${part.texts.length} texts`);
|
|
23
|
+
}
|
|
24
|
+
const unit = settle ? slice.vectors.map(unitVector) : slice.vectors;
|
|
25
|
+
done += part.texts.length;
|
|
26
|
+
req.onSlice?.(part.at, unit);
|
|
27
|
+
req.onProgress?.(done, req.input.length);
|
|
28
|
+
return { ...slice, vectors: unit };
|
|
29
|
+
}, req.signal)));
|
|
30
|
+
const vectors = new Array(req.input.length);
|
|
31
|
+
for (const [at, part] of parts.entries()) {
|
|
32
|
+
for (const [i, vector] of slices[at].vectors.entries()) {
|
|
33
|
+
vectors[part.at + i] = vector;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Settled slice by slice above. Doing it again over the join would hold a
|
|
37
|
+
// second copy of every vector at once, which for a large corpus is GBs.
|
|
38
|
+
return embeddingResponse(vectors, { ...req, normalize: false }, usageOf(slices));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Greedy, on both caps at once. A single text over the token budget goes alone
|
|
42
|
+
* and is sent as it is: truncating it would answer with a vector for something
|
|
43
|
+
* the caller did not ask about, which is worse than the error it will get.
|
|
44
|
+
*/
|
|
45
|
+
export function sliceBatch(input, maxBatch, maxBatchTokens) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
let texts = [];
|
|
48
|
+
let at = 0;
|
|
49
|
+
let tokens = 0;
|
|
50
|
+
for (const [i, text] of input.entries()) {
|
|
51
|
+
const cost = estimateTokens(text);
|
|
52
|
+
if (texts.length > 0 && (texts.length >= maxBatch || tokens + cost > maxBatchTokens)) {
|
|
53
|
+
parts.push({ at, texts });
|
|
54
|
+
texts = [];
|
|
55
|
+
at = i;
|
|
56
|
+
tokens = 0;
|
|
57
|
+
}
|
|
58
|
+
texts.push(text);
|
|
59
|
+
tokens += cost;
|
|
60
|
+
}
|
|
61
|
+
if (texts.length > 0) {
|
|
62
|
+
parts.push({ at, texts });
|
|
63
|
+
}
|
|
64
|
+
return parts;
|
|
65
|
+
}
|
|
66
|
+
/** Undefined unless something reported a number: a zero would read as free. */
|
|
67
|
+
function usageOf(slices) {
|
|
68
|
+
let total;
|
|
69
|
+
for (const slice of slices) {
|
|
70
|
+
if (slice.inputTokens !== undefined) {
|
|
71
|
+
total = (total ?? 0) + slice.inputTokens;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return total === undefined ? undefined : { ...zeroUsage(), inputTokens: total };
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=fanout.js.map
|
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
import type { GoogleGenAI } from '@google/genai';
|
|
2
|
-
import {
|
|
2
|
+
import type { Embedder, EmbeddingRequest, EmbeddingResponse } from '../embedding.ts';
|
|
3
|
+
import { type BatchOptions } from './fanout.ts';
|
|
3
4
|
/** Provider-specific knobs. */
|
|
4
|
-
export interface GeminiEmbedderOptions {
|
|
5
|
+
export interface GeminiEmbedderOptions extends BatchOptions {
|
|
5
6
|
/** default width; a request may override it */
|
|
6
7
|
dimensions?: number;
|
|
7
8
|
/** a document's title, which the retrieval task type takes into account */
|
|
8
9
|
title?: string;
|
|
9
|
-
/**
|
|
10
|
-
* Texts this model accepts per request. One is the default because that is
|
|
11
|
-
* what every `gemini-embedding-*` model takes — `embedContent` is a
|
|
12
|
-
* single-document endpoint there, and the batch path is a separate,
|
|
13
|
-
* job-based API. The older `text-embedding-*` models accept far more and
|
|
14
|
-
* can say so.
|
|
15
|
-
*/
|
|
16
|
-
maxBatch?: number;
|
|
17
10
|
}
|
|
18
11
|
export declare class GeminiEmbedder implements Embedder {
|
|
19
12
|
#private;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { callSites, called } from "../failure.js";
|
|
2
|
+
import { fanout } from "./fanout.js";
|
|
3
|
+
import { RateLimiter } from "./limiter.js";
|
|
2
4
|
// ---------------------------------------------------------------------------
|
|
3
5
|
// Google Gemini embeddings adapter
|
|
4
6
|
//
|
|
@@ -15,16 +17,36 @@ const TASK_TYPES = {
|
|
|
15
17
|
query: 'RETRIEVAL_QUERY',
|
|
16
18
|
document: 'RETRIEVAL_DOCUMENT',
|
|
17
19
|
};
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
+
/**
|
|
21
|
+
* One, because `embedContent` is a single-document endpoint for every
|
|
22
|
+
* `gemini-embedding-*` model — it answers more than one content with *"The
|
|
23
|
+
* embedContent API for this model only supports one content at a time"* — and
|
|
24
|
+
* the batch path is a separate, job-based API. The older `text-embedding-*`
|
|
25
|
+
* models take far more.
|
|
26
|
+
*
|
|
27
|
+
* This is why the fan-out matters most here: a corpus is one request per chunk,
|
|
28
|
+
* so how many may be in flight is the entire runtime of a build.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_BATCH = 1;
|
|
31
|
+
const LEGACY_MAX_BATCH = 250;
|
|
32
|
+
const MAX_BATCH_TOKENS = 20_000;
|
|
33
|
+
const LEGACY_MODEL = /^text-embedding-/;
|
|
20
34
|
export class GeminiEmbedder {
|
|
21
35
|
id;
|
|
22
36
|
#client;
|
|
23
37
|
#options;
|
|
38
|
+
#limiter;
|
|
39
|
+
#maxBatch;
|
|
40
|
+
#maxBatchTokens;
|
|
41
|
+
#site;
|
|
24
42
|
constructor(id, client, options = {}) {
|
|
25
43
|
this.id = id;
|
|
26
44
|
this.#client = client;
|
|
27
45
|
this.#options = options;
|
|
46
|
+
this.#limiter = options.limiter ?? new RateLimiter();
|
|
47
|
+
this.#site = callSites(id, options.provider);
|
|
48
|
+
this.#maxBatch = options.maxBatch ?? (LEGACY_MODEL.test(id) ? LEGACY_MAX_BATCH : MAX_BATCH);
|
|
49
|
+
this.#maxBatchTokens = options.maxBatchTokens ?? MAX_BATCH_TOKENS;
|
|
28
50
|
}
|
|
29
51
|
async embed(req) {
|
|
30
52
|
const config = {
|
|
@@ -34,38 +56,32 @@ export class GeminiEmbedder {
|
|
|
34
56
|
title: this.#options.title,
|
|
35
57
|
outputDimensionality: req.dimensions ?? this.#options.dimensions,
|
|
36
58
|
abortSignal: req.signal,
|
|
59
|
+
// The SDK retries nothing unless `retryOptions` is present, and the
|
|
60
|
+
// client was built with some; one attempt hands a refusal back out
|
|
61
|
+
// to the limiter, which is the only thing that can slow the run down.
|
|
62
|
+
httpOptions: { retryOptions: { attempts: 1 } },
|
|
37
63
|
};
|
|
38
|
-
// The contract is batch-first and this endpoint usually is not, so the
|
|
39
|
-
// splitting happens here rather than in every caller. Chunks are issued
|
|
40
|
-
// in waves and their results concatenated in order, so a caller cannot
|
|
41
|
-
// tell how many requests it took.
|
|
42
|
-
const size = Math.max(1, this.#options.maxBatch ?? 1);
|
|
43
|
-
const chunks = [];
|
|
44
|
-
for (let i = 0; i < req.input.length; i += size) {
|
|
45
|
-
chunks.push(req.input.slice(i, i + size));
|
|
46
|
-
}
|
|
47
|
-
const vectors = [];
|
|
48
|
-
for (let i = 0; i < chunks.length; i += CONCURRENCY) {
|
|
49
|
-
const wave = await Promise.all(chunks.slice(i, i + CONCURRENCY).map((chunk) => this.#send(chunk, config)));
|
|
50
|
-
for (const part of wave) {
|
|
51
|
-
vectors.push(...part);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
64
|
// Usage stays undefined on purpose: this API reports billable
|
|
55
65
|
// *characters*, on Vertex only, which is not a token count and would be
|
|
56
66
|
// a lie in `TokenUsage`.
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
return await fanout(req, {
|
|
68
|
+
id: this.id,
|
|
69
|
+
limiter: this.#limiter,
|
|
70
|
+
maxBatch: this.#maxBatch,
|
|
71
|
+
maxBatchTokens: this.#maxBatchTokens,
|
|
72
|
+
send: async (input) => {
|
|
73
|
+
const res = await called(this.#site('embedding', 'models.embedContent'), () => this.#client.models.embedContent({
|
|
74
|
+
model: this.id,
|
|
75
|
+
// Spelled out as one `Content` per text.
|
|
76
|
+
// `ContentListUnion` also accepts a `string[]`, and
|
|
77
|
+
// reads it as the *parts of a single document* — six
|
|
78
|
+
// sentences in, one vector back, and no error.
|
|
79
|
+
contents: input.map((text) => ({ parts: [{ text }] })),
|
|
80
|
+
config,
|
|
81
|
+
}));
|
|
82
|
+
return { vectors: (res.embeddings ?? []).map((e) => e.values ?? []) };
|
|
83
|
+
},
|
|
67
84
|
});
|
|
68
|
-
return (res.embeddings ?? []).map((e) => e.values ?? []);
|
|
69
85
|
}
|
|
70
86
|
}
|
|
71
87
|
//# sourceMappingURL=gemini.js.map
|
package/dist/embeddings/index.js
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface LimiterOptions {
|
|
2
|
+
/** never go below this many in flight */
|
|
3
|
+
min?: number;
|
|
4
|
+
/** where to start, before anything is known */
|
|
5
|
+
start?: number;
|
|
6
|
+
/** never go above this many, whatever the provider tolerates */
|
|
7
|
+
max?: number;
|
|
8
|
+
/** consecutive successes, while saturated, that earn one more slot */
|
|
9
|
+
stride?: number;
|
|
10
|
+
/** the first wait after a refusal, doubling while they continue */
|
|
11
|
+
initialBackoffMs?: number;
|
|
12
|
+
maxBackoffMs?: number;
|
|
13
|
+
/** attempts after the first, per task */
|
|
14
|
+
maxRetries?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare const DEFAULT_MIN = 1;
|
|
17
|
+
export declare const DEFAULT_START = 4;
|
|
18
|
+
export declare const DEFAULT_MAX = 16;
|
|
19
|
+
export declare const DEFAULT_STRIDE = 8;
|
|
20
|
+
export declare const DEFAULT_INITIAL_BACKOFF_MS = 1000;
|
|
21
|
+
export declare const DEFAULT_MAX_BACKOFF_MS = 60000;
|
|
22
|
+
export declare const DEFAULT_MAX_RETRIES = 4;
|
|
23
|
+
export declare class RateLimiter {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(options?: LimiterOptions);
|
|
26
|
+
/** How many may be in flight as things stand. Moves; read it for reporting only. */
|
|
27
|
+
get limit(): number;
|
|
28
|
+
get inFlight(): number;
|
|
29
|
+
/**
|
|
30
|
+
* Runs the task when there is room, retries it when the failure allows, and
|
|
31
|
+
* gives up when it does not. The slot is released before any wait, so a
|
|
32
|
+
* task serving its backoff is not also holding the concurrency down.
|
|
33
|
+
*/
|
|
34
|
+
run<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=limiter.d.ts.map
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { classify, retryAfterMs } from "./rate-limit.js";
|
|
2
|
+
export const DEFAULT_MIN = 1;
|
|
3
|
+
export const DEFAULT_START = 4;
|
|
4
|
+
export const DEFAULT_MAX = 16;
|
|
5
|
+
export const DEFAULT_STRIDE = 8;
|
|
6
|
+
export const DEFAULT_INITIAL_BACKOFF_MS = 1_000;
|
|
7
|
+
export const DEFAULT_MAX_BACKOFF_MS = 60_000;
|
|
8
|
+
export const DEFAULT_MAX_RETRIES = 4;
|
|
9
|
+
export class RateLimiter {
|
|
10
|
+
#min;
|
|
11
|
+
#max;
|
|
12
|
+
#stride;
|
|
13
|
+
#initial;
|
|
14
|
+
#maxBackoff;
|
|
15
|
+
#retries;
|
|
16
|
+
#limit;
|
|
17
|
+
#active = 0;
|
|
18
|
+
#successes = 0;
|
|
19
|
+
/** bumped on every decrease; a refusal from an older one is already answered */
|
|
20
|
+
#epoch = 0;
|
|
21
|
+
#penalty;
|
|
22
|
+
#pausedUntil = 0;
|
|
23
|
+
#timer;
|
|
24
|
+
#waiting = [];
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.#min = Math.max(1, options.min ?? DEFAULT_MIN);
|
|
27
|
+
this.#max = Math.max(this.#min, options.max ?? DEFAULT_MAX);
|
|
28
|
+
this.#stride = Math.max(1, options.stride ?? DEFAULT_STRIDE);
|
|
29
|
+
this.#initial = Math.max(0, options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS);
|
|
30
|
+
this.#maxBackoff = Math.max(this.#initial, options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS);
|
|
31
|
+
this.#retries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES);
|
|
32
|
+
this.#limit = clamp(options.start ?? DEFAULT_START, this.#min, this.#max);
|
|
33
|
+
this.#penalty = this.#initial;
|
|
34
|
+
}
|
|
35
|
+
/** How many may be in flight as things stand. Moves; read it for reporting only. */
|
|
36
|
+
get limit() {
|
|
37
|
+
return this.#limit;
|
|
38
|
+
}
|
|
39
|
+
get inFlight() {
|
|
40
|
+
return this.#active;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Runs the task when there is room, retries it when the failure allows, and
|
|
44
|
+
* gives up when it does not. The slot is released before any wait, so a
|
|
45
|
+
* task serving its backoff is not also holding the concurrency down.
|
|
46
|
+
*/
|
|
47
|
+
async run(task, signal) {
|
|
48
|
+
let backoff = this.#initial;
|
|
49
|
+
for (let attempt = 0;; attempt++) {
|
|
50
|
+
const epoch = await this.#acquire(signal);
|
|
51
|
+
let failure = 'fatal';
|
|
52
|
+
let err;
|
|
53
|
+
try {
|
|
54
|
+
const value = await task();
|
|
55
|
+
this.#succeeded();
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
catch (thrown) {
|
|
59
|
+
err = thrown;
|
|
60
|
+
failure = classify(thrown);
|
|
61
|
+
// Before the slot is let go, so the pause is already in force
|
|
62
|
+
// when the next waiter is granted one.
|
|
63
|
+
if (failure === 'rate-limit') {
|
|
64
|
+
this.#slowDown(epoch, retryAfterMs(thrown));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
this.#release();
|
|
69
|
+
}
|
|
70
|
+
if (failure === 'fatal' || attempt >= this.#retries) {
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
// A refusal is already being served by the shared pause, which the
|
|
74
|
+
// next acquire blocks on; waiting again here would double it.
|
|
75
|
+
if (failure === 'transient') {
|
|
76
|
+
await sleep(jitter(Math.min(backoff, this.#maxBackoff)), signal);
|
|
77
|
+
backoff *= 2;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
#acquire(signal) {
|
|
82
|
+
if (signal?.aborted) {
|
|
83
|
+
return Promise.reject(signal.reason);
|
|
84
|
+
}
|
|
85
|
+
if (this.#free()) {
|
|
86
|
+
this.#active++;
|
|
87
|
+
return Promise.resolve(this.#epoch);
|
|
88
|
+
}
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const waiter = {
|
|
91
|
+
grant: (epoch) => {
|
|
92
|
+
signal?.removeEventListener('abort', abort);
|
|
93
|
+
resolve(epoch);
|
|
94
|
+
},
|
|
95
|
+
fail: reject,
|
|
96
|
+
};
|
|
97
|
+
const abort = () => {
|
|
98
|
+
const at = this.#waiting.indexOf(waiter);
|
|
99
|
+
if (at >= 0) {
|
|
100
|
+
this.#waiting.splice(at, 1);
|
|
101
|
+
}
|
|
102
|
+
reject(signal?.reason);
|
|
103
|
+
};
|
|
104
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
105
|
+
this.#waiting.push(waiter);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
#free() {
|
|
109
|
+
return this.#active < this.#limit && Date.now() >= this.#pausedUntil;
|
|
110
|
+
}
|
|
111
|
+
#release() {
|
|
112
|
+
this.#active--;
|
|
113
|
+
this.#pump();
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The slot is taken here rather than by the waiter, so several woken at
|
|
117
|
+
* once cannot each see the same free one before any of them claims it.
|
|
118
|
+
*/
|
|
119
|
+
#pump() {
|
|
120
|
+
while (this.#waiting.length > 0 && this.#free()) {
|
|
121
|
+
this.#active++;
|
|
122
|
+
this.#waiting.shift().grant(this.#epoch);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
#succeeded() {
|
|
126
|
+
this.#penalty = this.#initial;
|
|
127
|
+
// Only a limit that is actually the constraint has earned being raised;
|
|
128
|
+
// with nothing queued, more slots would go unused anyway.
|
|
129
|
+
if (this.#waiting.length === 0) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (++this.#successes >= this.#stride) {
|
|
133
|
+
this.#successes = 0;
|
|
134
|
+
this.#limit = Math.min(this.#max, this.#limit + 1);
|
|
135
|
+
this.#pump();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
#slowDown(epoch, retryAfter) {
|
|
139
|
+
if (epoch === this.#epoch) {
|
|
140
|
+
this.#epoch++;
|
|
141
|
+
this.#limit = Math.max(this.#min, Math.floor(this.#limit / 2));
|
|
142
|
+
this.#successes = 0;
|
|
143
|
+
this.#penalty = Math.min(this.#maxBackoff, this.#penalty * 2);
|
|
144
|
+
}
|
|
145
|
+
// Even a refusal that arrived too late to lower the limit still knows
|
|
146
|
+
// how long this provider wants to be left alone.
|
|
147
|
+
this.#pause(retryAfter ?? this.#penalty);
|
|
148
|
+
}
|
|
149
|
+
#pause(ms) {
|
|
150
|
+
const until = Date.now() + Math.max(0, ms);
|
|
151
|
+
if (until <= this.#pausedUntil) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.#pausedUntil = until;
|
|
155
|
+
clearTimeout(this.#timer);
|
|
156
|
+
this.#timer = setTimeout(() => {
|
|
157
|
+
this.#timer = undefined;
|
|
158
|
+
this.#pump();
|
|
159
|
+
}, until - Date.now());
|
|
160
|
+
this.#timer.unref?.();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const clamp = (n, low, high) => Math.min(high, Math.max(low, n));
|
|
164
|
+
/** Full jitter: without it, everything that backed off together returns together. */
|
|
165
|
+
const jitter = (ms) => ms / 2 + Math.random() * (ms / 2);
|
|
166
|
+
function sleep(ms, signal) {
|
|
167
|
+
if (signal?.aborted) {
|
|
168
|
+
return Promise.reject(signal.reason);
|
|
169
|
+
}
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const timer = setTimeout(() => {
|
|
172
|
+
signal?.removeEventListener('abort', abort);
|
|
173
|
+
resolve();
|
|
174
|
+
}, ms);
|
|
175
|
+
const abort = () => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
reject(signal?.reason);
|
|
178
|
+
};
|
|
179
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=limiter.js.map
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type OpenAI from 'openai';
|
|
2
|
-
import {
|
|
2
|
+
import type { Embedder, EmbeddingRequest, EmbeddingResponse } from '../embedding.ts';
|
|
3
|
+
import { type BatchOptions } from './fanout.ts';
|
|
3
4
|
/**
|
|
4
5
|
* Provider-specific knobs. `taskType` has no counterpart here: OpenAI's models
|
|
5
6
|
* are symmetric, so a query and a document are encoded identically and the
|
|
6
7
|
* request's hint is simply not sent.
|
|
7
8
|
*/
|
|
8
|
-
export interface OpenAIEmbedderOptions {
|
|
9
|
+
export interface OpenAIEmbedderOptions extends BatchOptions {
|
|
9
10
|
/** default width; a request may override it */
|
|
10
11
|
dimensions?: number;
|
|
11
12
|
}
|
|
@@ -1,26 +1,67 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { callSites, called } from "../failure.js";
|
|
2
|
+
import { fanout } from "./fanout.js";
|
|
3
|
+
import { RateLimiter } from "./limiter.js";
|
|
4
|
+
/**
|
|
5
|
+
* What OpenAI documents for `text-embedding-*`: 2048 inputs, and 300k tokens
|
|
6
|
+
* across them. The token figure is the one that fires in practice, and it is
|
|
7
|
+
* held under the real cap because the estimate feeding it counts characters.
|
|
8
|
+
*/
|
|
9
|
+
const MAX_BATCH = 2048;
|
|
10
|
+
const MAX_BATCH_TOKENS = 250_000;
|
|
11
|
+
/**
|
|
12
|
+
* A gateway is not OpenAI. `openai-compatible` reaches vLLM, Ollama and
|
|
13
|
+
* whatever else speaks the shape, none of which promise those numbers, so an
|
|
14
|
+
* unrecognised id gets something any implementation can be expected to hold.
|
|
15
|
+
*/
|
|
16
|
+
const GATEWAY_BATCH = 96;
|
|
17
|
+
const GATEWAY_BATCH_TOKENS = 100_000;
|
|
18
|
+
const OPENAI_MODEL = /^text-embedding-/;
|
|
3
19
|
export class OpenAIEmbedder {
|
|
4
20
|
id;
|
|
5
21
|
#client;
|
|
6
22
|
#dimensions;
|
|
23
|
+
#limiter;
|
|
24
|
+
#maxBatch;
|
|
25
|
+
#maxBatchTokens;
|
|
26
|
+
#site;
|
|
7
27
|
constructor(id, client, options = {}) {
|
|
8
28
|
this.id = id;
|
|
9
29
|
this.#client = client;
|
|
10
30
|
this.#dimensions = options.dimensions;
|
|
31
|
+
this.#limiter = options.limiter ?? new RateLimiter();
|
|
32
|
+
this.#site = callSites(id, options.provider);
|
|
33
|
+
const known = OPENAI_MODEL.test(id);
|
|
34
|
+
this.#maxBatch = options.maxBatch ?? (known ? MAX_BATCH : GATEWAY_BATCH);
|
|
35
|
+
this.#maxBatchTokens =
|
|
36
|
+
options.maxBatchTokens ?? (known ? MAX_BATCH_TOKENS : GATEWAY_BATCH_TOKENS);
|
|
11
37
|
}
|
|
12
38
|
async embed(req) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
return await fanout(req, {
|
|
40
|
+
id: this.id,
|
|
41
|
+
limiter: this.#limiter,
|
|
42
|
+
maxBatch: this.#maxBatch,
|
|
43
|
+
maxBatchTokens: this.#maxBatchTokens,
|
|
44
|
+
send: async (input) => {
|
|
45
|
+
const res = await called(this.#site('embedding', 'embeddings.create'), () => this.#client.embeddings.create({
|
|
46
|
+
model: this.id,
|
|
47
|
+
input,
|
|
48
|
+
dimensions: req.dimensions ?? this.#dimensions,
|
|
49
|
+
},
|
|
50
|
+
// The client's own retrying is off because a retry it
|
|
51
|
+
// serves is one the limiter never sees: the call
|
|
52
|
+
// succeeds, nothing learns the provider is refusing, and
|
|
53
|
+
// the run quietly takes as long as the backoff it did
|
|
54
|
+
// not report.
|
|
55
|
+
{ signal: req.signal, maxRetries: 0 }));
|
|
56
|
+
// The API documents the order but does not promise it, and
|
|
57
|
+
// `index` is the only thing tying a vector back to its text.
|
|
58
|
+
return {
|
|
59
|
+
vectors: [...res.data]
|
|
60
|
+
.sort((a, b) => a.index - b.index)
|
|
61
|
+
.map((d) => d.embedding),
|
|
62
|
+
inputTokens: res.usage.prompt_tokens,
|
|
63
|
+
};
|
|
64
|
+
},
|
|
24
65
|
});
|
|
25
66
|
}
|
|
26
67
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { OpenRouter } from '@openrouter/sdk';
|
|
2
2
|
import type { ProviderPreferences } from '@openrouter/sdk/models';
|
|
3
3
|
import type { Embedder, EmbeddingRequest, EmbeddingResponse } from '../embedding.ts';
|
|
4
|
+
import { type BatchOptions } from './fanout.ts';
|
|
4
5
|
/** Provider-specific knobs. */
|
|
5
|
-
export interface OpenRouterEmbedderOptions {
|
|
6
|
+
export interface OpenRouterEmbedderOptions extends BatchOptions {
|
|
6
7
|
/** default width; a request may override it */
|
|
7
8
|
dimensions?: number;
|
|
8
9
|
/**
|