@graphorin/provider 0.6.1 → 0.7.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/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/dist/adapters/llamacpp-server.d.ts +1 -1
- package/dist/adapters/llamacpp-server.js.map +1 -1
- package/dist/adapters/ollama.d.ts.map +1 -1
- package/dist/adapters/ollama.js +19 -6
- package/dist/adapters/ollama.js.map +1 -1
- package/dist/adapters/vercel.d.ts +1 -1
- package/dist/adapters/vercel.d.ts.map +1 -1
- package/dist/adapters/vercel.js +111 -33
- package/dist/adapters/vercel.js.map +1 -1
- package/dist/errors/errors.d.ts +1 -1
- package/dist/errors/errors.js +1 -1
- package/dist/errors/errors.js.map +1 -1
- package/dist/index.js +0 -6
- package/dist/index.js.map +1 -1
- package/dist/internal/http.js +111 -7
- package/dist/internal/http.js.map +1 -1
- package/dist/internal/openai-shaped.js +21 -4
- package/dist/internal/openai-shaped.js.map +1 -1
- package/dist/middleware/with-fallback.js +1 -1
- package/dist/middleware/with-rate-limit.d.ts +21 -8
- package/dist/middleware/with-rate-limit.d.ts.map +1 -1
- package/dist/middleware/with-rate-limit.js +65 -12
- package/dist/middleware/with-rate-limit.js.map +1 -1
- package/dist/middleware/with-retry.js +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +18 -16
- package/src/adapters/index.ts +14 -0
- package/src/adapters/llamacpp-server.ts +102 -0
- package/src/adapters/ollama.ts +382 -0
- package/src/adapters/openai-compatible.ts +95 -0
- package/src/adapters/vercel-messages.ts +308 -0
- package/src/adapters/vercel.ts +706 -0
- package/src/counters/anthropic-wire.ts +199 -0
- package/src/counters/anthropic.ts +114 -0
- package/src/counters/bedrock.ts +46 -0
- package/src/counters/dispatcher.ts +127 -0
- package/src/counters/global.ts +39 -0
- package/src/counters/google.ts +46 -0
- package/src/counters/heuristic.ts +107 -0
- package/src/counters/index.ts +35 -0
- package/src/counters/js-tiktoken.ts +135 -0
- package/src/counters/serialize.ts +85 -0
- package/src/errors/errors.ts +316 -0
- package/src/errors/index.ts +20 -0
- package/src/index.ts +42 -0
- package/src/internal/abort.ts +30 -0
- package/src/internal/http.ts +388 -0
- package/src/internal/openai-shaped.ts +555 -0
- package/src/internal/sse.ts +112 -0
- package/src/internal/url-utils.ts +20 -0
- package/src/middleware/compose.ts +213 -0
- package/src/middleware/index.ts +37 -0
- package/src/middleware/production-hook.ts +47 -0
- package/src/middleware/with-cost-limit.ts +131 -0
- package/src/middleware/with-cost-tracking.ts +216 -0
- package/src/middleware/with-fallback.ts +157 -0
- package/src/middleware/with-rate-limit.ts +306 -0
- package/src/middleware/with-redaction.ts +671 -0
- package/src/middleware/with-retry.ts +274 -0
- package/src/middleware/with-tracing.ts +117 -0
- package/src/model-tier/classify.ts +125 -0
- package/src/model-tier/index.ts +11 -0
- package/src/provider.ts +121 -0
- package/src/reasoning/apply-policy.ts +89 -0
- package/src/reasoning/classify-contract.ts +120 -0
- package/src/reasoning/index.ts +21 -0
- package/src/reasoning/retention.ts +64 -0
- package/src/tool-examples.ts +54 -0
- package/src/trust/classify-local-provider.ts +254 -0
- package/src/trust/index.ts +14 -0
- package/dist/adapters/index.js +0 -6
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `withFallback` - request-level fallback chain. When the wrapped
|
|
3
|
+
* provider raises a fallback-eligible error, the middleware retries
|
|
4
|
+
* the same request against each alternate provider in `fallbacks`.
|
|
5
|
+
* The model concept is preserved: callers wire `[primary, alt1, alt2]`
|
|
6
|
+
* pointing at providers serving the same logical model. Cross-model
|
|
7
|
+
* fallbacks are the agent-level concern (Phase 12).
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Provider, ProviderEvent, ProviderResponse } from '@graphorin/core';
|
|
13
|
+
|
|
14
|
+
import { isAbortError } from '../internal/abort.js';
|
|
15
|
+
import { defineProviderMiddleware } from './compose.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options for {@link withFallback}.
|
|
19
|
+
*
|
|
20
|
+
* @stable
|
|
21
|
+
*/
|
|
22
|
+
export interface WithFallbackOptions {
|
|
23
|
+
/** Alternate providers tried in order. */
|
|
24
|
+
readonly fallbacks: ReadonlyArray<Provider>;
|
|
25
|
+
/** Predicate deciding whether an error should trigger a fallback. */
|
|
26
|
+
readonly shouldFallback?: (err: unknown) => boolean;
|
|
27
|
+
/** Optional log sink; defaults to `console.warn`. */
|
|
28
|
+
readonly logger?: (message: string, meta?: object) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @stable
|
|
33
|
+
*/
|
|
34
|
+
export const withFallback = defineProviderMiddleware<WithFallbackOptions>({
|
|
35
|
+
kind: 'withFallback',
|
|
36
|
+
factory: (opts: WithFallbackOptions) => {
|
|
37
|
+
const shouldFallback = opts.shouldFallback ?? defaultShouldFallback;
|
|
38
|
+
const logger = opts.logger ?? defaultLogger;
|
|
39
|
+
if (!Array.isArray(opts.fallbacks) || opts.fallbacks.length === 0) {
|
|
40
|
+
throw new TypeError('withFallback: fallbacks array must contain at least one Provider.');
|
|
41
|
+
}
|
|
42
|
+
return (next: Provider): Provider => {
|
|
43
|
+
const candidates: ReadonlyArray<Provider> = [next, ...opts.fallbacks];
|
|
44
|
+
return {
|
|
45
|
+
name: next.name,
|
|
46
|
+
modelId: next.modelId,
|
|
47
|
+
capabilities: next.capabilities,
|
|
48
|
+
...(next.acceptsSensitivity !== undefined
|
|
49
|
+
? { acceptsSensitivity: next.acceptsSensitivity }
|
|
50
|
+
: {}),
|
|
51
|
+
stream(req) {
|
|
52
|
+
return fallbackStream(candidates, req, shouldFallback, logger);
|
|
53
|
+
},
|
|
54
|
+
async generate(req) {
|
|
55
|
+
let lastErr: unknown;
|
|
56
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
57
|
+
const cand = candidates[i];
|
|
58
|
+
if (cand === undefined) continue;
|
|
59
|
+
try {
|
|
60
|
+
return await cand.generate(req);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (req.signal?.aborted) throw err;
|
|
63
|
+
lastErr = err;
|
|
64
|
+
if (i === candidates.length - 1 || !shouldFallback(err)) throw err;
|
|
65
|
+
logger(`[graphorin/provider] withFallback: '${cand.name}' failed, trying next.`, {
|
|
66
|
+
error: (err as Error).message,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw lastErr ?? new Error('withFallback: no fallback succeeded.');
|
|
71
|
+
},
|
|
72
|
+
...(next.countTokens ? { countTokens: next.countTokens.bind(next) } : {}),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
async function* fallbackStream(
|
|
79
|
+
candidates: ReadonlyArray<Provider>,
|
|
80
|
+
req: import('@graphorin/core').ProviderRequest,
|
|
81
|
+
shouldFallback: (err: unknown) => boolean,
|
|
82
|
+
logger: (message: string, meta?: object) => void,
|
|
83
|
+
): AsyncIterable<ProviderEvent> {
|
|
84
|
+
let lastErr: unknown;
|
|
85
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
86
|
+
const cand = candidates[i];
|
|
87
|
+
if (cand === undefined) continue;
|
|
88
|
+
let yieldedAny = false;
|
|
89
|
+
try {
|
|
90
|
+
for await (const event of cand.stream(req)) {
|
|
91
|
+
yieldedAny = true;
|
|
92
|
+
yield event;
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (req.signal?.aborted) throw err;
|
|
97
|
+
lastErr = err;
|
|
98
|
+
if (yieldedAny) {
|
|
99
|
+
// Once we have streamed events, falling back would split the
|
|
100
|
+
// logical response across providers - propagate instead.
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
if (i === candidates.length - 1 || !shouldFallback(err)) throw err;
|
|
104
|
+
logger(`[graphorin/provider] withFallback: '${cand.name}' failed, trying next.`, {
|
|
105
|
+
error: (err as Error).message,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
throw lastErr ?? new Error('withFallback: no fallback succeeded.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function defaultShouldFallback(err: unknown): boolean {
|
|
113
|
+
if (err === null || typeof err !== 'object') return false;
|
|
114
|
+
// An aborted request must not trigger a fallback, even as a `status: 0`
|
|
115
|
+
// network error (PS-2). The loop also short-circuits on `req.signal?.aborted`.
|
|
116
|
+
if (isAbortError(err)) return false;
|
|
117
|
+
const e = err as { kind?: string; errorKind?: string; status?: number };
|
|
118
|
+
// `ProviderHttpError.kind` is always 'provider-http' (stable discriminant);
|
|
119
|
+
// the mapped canonical kind rides on `errorKind`. Consult both so a 429
|
|
120
|
+
// fails over as a rate limit and a 400/context overflow never does.
|
|
121
|
+
const kinds = [e.kind, e.errorKind];
|
|
122
|
+
if (
|
|
123
|
+
kinds.includes('unauthorized') ||
|
|
124
|
+
kinds.includes('invalid-request') ||
|
|
125
|
+
kinds.includes('context-length') ||
|
|
126
|
+
kinds.includes('content-filter')
|
|
127
|
+
) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
kinds.includes('transient') ||
|
|
132
|
+
kinds.includes('rate-limit') ||
|
|
133
|
+
kinds.includes('rate-limit-exceeded') ||
|
|
134
|
+
kinds.includes('capacity')
|
|
135
|
+
) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
// A 429 on the primary is exactly the case a same-model fallback chain
|
|
139
|
+
// exists for - the alternate deployment has its own rate budget.
|
|
140
|
+
if (typeof e.status === 'number' && e.status === 429) return true;
|
|
141
|
+
if (typeof e.status === 'number' && e.status >= 500) return true;
|
|
142
|
+
// PS-2: the headline fallback scenario - the primary provider is down
|
|
143
|
+
// (a local server refusing connections) surfaces as `status: 0`. Treat a
|
|
144
|
+
// network failure as fallback-eligible so the chain advances to the next
|
|
145
|
+
// provider (abort already excluded above).
|
|
146
|
+
if (typeof e.status === 'number' && e.status === 0) return true;
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function defaultLogger(message: string, meta?: object): void {
|
|
151
|
+
if (meta !== undefined) console.warn(message, meta);
|
|
152
|
+
else console.warn(message);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Cast to silence the "declared but unused" lint when only the types
|
|
156
|
+
// are needed by consumers.
|
|
157
|
+
export type _FallbackResponseGuard = ProviderResponse;
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `withRateLimit` - token-bucket rate limiter keyed per `provider × model`.
|
|
3
|
+
* Configurable mode: `'throw'` (default) raises
|
|
4
|
+
* {@link RateLimitExceededError} on overflow; `'queue'` waits for the
|
|
5
|
+
* next available slot.
|
|
6
|
+
*
|
|
7
|
+
* W-145: an optional second dimension, `tokensPerMinute`, budgets
|
|
8
|
+
* MODEL tokens alongside requests. For agentic workloads the binding
|
|
9
|
+
* provider limit is TPM, not RPM - a 150k-token compacted transcript
|
|
10
|
+
* used to occupy the same single slot as a 200-token reranker call,
|
|
11
|
+
* so bursts of long-context steps sailed through the RPM gate and
|
|
12
|
+
* surfaced as provider 429s that only `withRetry` absorbed.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Provider, ProviderRequest } from '@graphorin/core';
|
|
18
|
+
|
|
19
|
+
import { RateLimitExceededError } from '../errors/errors.js';
|
|
20
|
+
import { defineProviderMiddleware } from './compose.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Options for {@link withRateLimit}.
|
|
24
|
+
*
|
|
25
|
+
* @stable
|
|
26
|
+
*/
|
|
27
|
+
export interface WithRateLimitOptions {
|
|
28
|
+
/** Allowed requests per minute. */
|
|
29
|
+
readonly requestsPerMinute: number;
|
|
30
|
+
/** Burst size - defaults to `requestsPerMinute / 4` (rounded up to >= 1). */
|
|
31
|
+
readonly burst?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Optional token budget per minute (W-145). When set, each request
|
|
34
|
+
* additionally reserves its estimated token weight from a second
|
|
35
|
+
* bucket whose capacity is the full minute budget; a request whose
|
|
36
|
+
* weight exceeds the remaining budget waits (queue mode) or throws
|
|
37
|
+
* with the TPM-aware `retryAfterMs` (throw mode) even when the RPM
|
|
38
|
+
* bucket has room. Unset: behaviour is byte-identical to the
|
|
39
|
+
* RPM-only limiter.
|
|
40
|
+
*/
|
|
41
|
+
readonly tokensPerMinute?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Estimator for a request's token weight (only consulted when
|
|
44
|
+
* `tokensPerMinute` is set). The default is the deliberate cheap
|
|
45
|
+
* heuristic `ceil(textChars / 4) + (maxTokens ?? 0)` - synchronous
|
|
46
|
+
* and allocation-free, because this runs in the request hot path and
|
|
47
|
+
* must not add latency or network. Wire the counter from
|
|
48
|
+
* `@graphorin/provider/counters` (`createDefaultCounter`) here when
|
|
49
|
+
* you need provider-accurate weights.
|
|
50
|
+
*/
|
|
51
|
+
readonly estimateTokens?: (req: ProviderRequest) => number;
|
|
52
|
+
/** What to do on overflow. Default `'throw'`. */
|
|
53
|
+
readonly mode?: 'throw' | 'queue';
|
|
54
|
+
/** Test hook overriding `Date.now`. */
|
|
55
|
+
readonly nowImpl?: () => number;
|
|
56
|
+
/** Test hook overriding `setTimeout`-based wait. */
|
|
57
|
+
readonly sleepImpl?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Waiter {
|
|
61
|
+
resolve: () => void;
|
|
62
|
+
reject: (err: unknown) => void;
|
|
63
|
+
signal?: AbortSignal;
|
|
64
|
+
cancelled: boolean;
|
|
65
|
+
/** TPM weight this waiter must reserve (0 when TPM is unset). */
|
|
66
|
+
weight: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface BucketState {
|
|
70
|
+
tokens: number;
|
|
71
|
+
lastRefill: number;
|
|
72
|
+
/** W-145: remaining TPM budget (only meaningful when TPM is set). */
|
|
73
|
+
tpmTokens: number;
|
|
74
|
+
tpmLastRefill: number;
|
|
75
|
+
/** FIFO waiters in `'queue'` mode (PS-18). */
|
|
76
|
+
queue: Waiter[];
|
|
77
|
+
/** Whether the single drain loop for this bucket is running. */
|
|
78
|
+
draining: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @stable
|
|
83
|
+
*/
|
|
84
|
+
export const withRateLimit = defineProviderMiddleware<WithRateLimitOptions>({
|
|
85
|
+
kind: 'withRateLimit',
|
|
86
|
+
factory: (opts: WithRateLimitOptions) => {
|
|
87
|
+
if (!(opts.requestsPerMinute > 0)) {
|
|
88
|
+
throw new RangeError('withRateLimit: requestsPerMinute must be > 0.');
|
|
89
|
+
}
|
|
90
|
+
if (opts.tokensPerMinute !== undefined && !(opts.tokensPerMinute > 0)) {
|
|
91
|
+
throw new RangeError('withRateLimit: tokensPerMinute must be > 0 when set.');
|
|
92
|
+
}
|
|
93
|
+
const burst = Math.max(opts.burst ?? Math.ceil(opts.requestsPerMinute / 4), 1);
|
|
94
|
+
const refillPerMs = opts.requestsPerMinute / 60_000;
|
|
95
|
+
const tpm = opts.tokensPerMinute;
|
|
96
|
+
const tpmRefillPerMs = tpm === undefined ? 0 : tpm / 60_000;
|
|
97
|
+
const estimate = opts.estimateTokens ?? estimateTokensHeuristic;
|
|
98
|
+
const mode = opts.mode ?? 'throw';
|
|
99
|
+
const now = opts.nowImpl ?? (() => Date.now());
|
|
100
|
+
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
101
|
+
const buckets = new Map<string, BucketState>();
|
|
102
|
+
return (next: Provider): Provider => {
|
|
103
|
+
const key = `${next.name}::${next.modelId}`;
|
|
104
|
+
const bucketFor = (): BucketState => {
|
|
105
|
+
let bucket = buckets.get(key);
|
|
106
|
+
if (bucket === undefined) {
|
|
107
|
+
bucket = {
|
|
108
|
+
tokens: burst,
|
|
109
|
+
lastRefill: now(),
|
|
110
|
+
tpmTokens: tpm ?? 0,
|
|
111
|
+
tpmLastRefill: now(),
|
|
112
|
+
queue: [],
|
|
113
|
+
draining: false,
|
|
114
|
+
};
|
|
115
|
+
buckets.set(key, bucket);
|
|
116
|
+
}
|
|
117
|
+
return bucket;
|
|
118
|
+
};
|
|
119
|
+
const refill = (bucket: BucketState): void => {
|
|
120
|
+
const ts = now();
|
|
121
|
+
bucket.tokens = Math.min(burst, bucket.tokens + (ts - bucket.lastRefill) * refillPerMs);
|
|
122
|
+
bucket.lastRefill = ts;
|
|
123
|
+
if (tpm !== undefined) {
|
|
124
|
+
// TPM bucket capacity = the full minute budget, so any
|
|
125
|
+
// (clamped) weight is eventually satisfiable.
|
|
126
|
+
bucket.tpmTokens = Math.min(
|
|
127
|
+
tpm,
|
|
128
|
+
bucket.tpmTokens + (ts - bucket.tpmLastRefill) * tpmRefillPerMs,
|
|
129
|
+
);
|
|
130
|
+
bucket.tpmLastRefill = ts;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* A request's TPM weight, clamped to the bucket capacity - a
|
|
135
|
+
* single request estimated above the whole minute budget must
|
|
136
|
+
* degrade to "wait for a full bucket", not deadlock the queue
|
|
137
|
+
* forever behind an unsatisfiable reservation.
|
|
138
|
+
*/
|
|
139
|
+
const weightFor = (req: ProviderRequest): number => {
|
|
140
|
+
if (tpm === undefined) return 0;
|
|
141
|
+
const raw = estimate(req);
|
|
142
|
+
return Math.min(Math.max(0, Number.isFinite(raw) ? raw : tpm), tpm);
|
|
143
|
+
};
|
|
144
|
+
/** Both dimensions available for this weight? */
|
|
145
|
+
const available = (bucket: BucketState, weight: number): boolean =>
|
|
146
|
+
bucket.tokens >= 1 && (tpm === undefined || bucket.tpmTokens >= weight);
|
|
147
|
+
/** Wait until BOTH dimensions can serve `weight`, in ms. */
|
|
148
|
+
const waitMsFor = (bucket: BucketState, weight: number): number => {
|
|
149
|
+
const reqWait = bucket.tokens >= 1 ? 0 : (1 - bucket.tokens) / refillPerMs;
|
|
150
|
+
const tpmWait =
|
|
151
|
+
tpm === undefined || bucket.tpmTokens >= weight
|
|
152
|
+
? 0
|
|
153
|
+
: (weight - bucket.tpmTokens) / tpmRefillPerMs;
|
|
154
|
+
return Math.ceil(Math.max(reqWait, tpmWait));
|
|
155
|
+
};
|
|
156
|
+
/** Deduct BOTH dimensions atomically (caller checked `available`). */
|
|
157
|
+
const take = (bucket: BucketState, weight: number): void => {
|
|
158
|
+
bucket.tokens -= 1;
|
|
159
|
+
if (tpm !== undefined) bucket.tpmTokens -= weight;
|
|
160
|
+
};
|
|
161
|
+
// PS-18: a single drain loop per bucket grants queued waiters ONE slot at
|
|
162
|
+
// a time, sleeping until the next token actually refills. Previously every
|
|
163
|
+
// concurrent waiter computed the same wait, slept once, and then all
|
|
164
|
+
// passed - letting N requests through on ~1 token (a burst). FIFO order is
|
|
165
|
+
// preserved and grants track the real refill schedule. W-145: a grant now
|
|
166
|
+
// requires BOTH the request token and the head's TPM weight.
|
|
167
|
+
const drain = (bucket: BucketState): void => {
|
|
168
|
+
if (bucket.draining) return;
|
|
169
|
+
bucket.draining = true;
|
|
170
|
+
void (async () => {
|
|
171
|
+
while (bucket.queue.length > 0) {
|
|
172
|
+
const head = bucket.queue[0];
|
|
173
|
+
if (head === undefined) break;
|
|
174
|
+
if (head.cancelled) {
|
|
175
|
+
bucket.queue.shift();
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
refill(bucket);
|
|
179
|
+
if (available(bucket, head.weight)) {
|
|
180
|
+
take(bucket, head.weight);
|
|
181
|
+
bucket.queue.shift();
|
|
182
|
+
head.resolve();
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const waitMs = waitMsFor(bucket, head.weight);
|
|
186
|
+
try {
|
|
187
|
+
await sleep(waitMs);
|
|
188
|
+
} catch {
|
|
189
|
+
// The shared drain sleep carries no per-waiter signal; individual
|
|
190
|
+
// aborts are handled on the waiter via its listener.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
bucket.draining = false;
|
|
194
|
+
})();
|
|
195
|
+
};
|
|
196
|
+
const acquire = async (signal?: AbortSignal, weight = 0): Promise<void> => {
|
|
197
|
+
if (signal?.aborted === true) throw signal.reason ?? new Error('aborted');
|
|
198
|
+
const bucket = bucketFor();
|
|
199
|
+
refill(bucket);
|
|
200
|
+
// Fast path: both dimensions free AND nobody queued ahead (FIFO).
|
|
201
|
+
if (available(bucket, weight) && bucket.queue.length === 0) {
|
|
202
|
+
take(bucket, weight);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (mode === 'throw') {
|
|
206
|
+
throw new RateLimitExceededError(waitMsFor(bucket, weight));
|
|
207
|
+
}
|
|
208
|
+
await new Promise<void>((resolve, reject) => {
|
|
209
|
+
const waiter: Waiter = {
|
|
210
|
+
resolve,
|
|
211
|
+
reject,
|
|
212
|
+
...(signal ? { signal } : {}),
|
|
213
|
+
cancelled: false,
|
|
214
|
+
weight,
|
|
215
|
+
};
|
|
216
|
+
if (signal !== undefined) {
|
|
217
|
+
const onAbort = (): void => {
|
|
218
|
+
waiter.cancelled = true;
|
|
219
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
220
|
+
};
|
|
221
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
222
|
+
const settle = resolve;
|
|
223
|
+
waiter.resolve = (): void => {
|
|
224
|
+
signal.removeEventListener('abort', onAbort);
|
|
225
|
+
settle();
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
bucket.queue.push(waiter);
|
|
229
|
+
drain(bucket);
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
name: next.name,
|
|
234
|
+
modelId: next.modelId,
|
|
235
|
+
capabilities: next.capabilities,
|
|
236
|
+
...(next.acceptsSensitivity !== undefined
|
|
237
|
+
? { acceptsSensitivity: next.acceptsSensitivity }
|
|
238
|
+
: {}),
|
|
239
|
+
stream(req) {
|
|
240
|
+
return rateLimitedStream(next, req, acquire, weightFor(req));
|
|
241
|
+
},
|
|
242
|
+
async generate(req) {
|
|
243
|
+
await acquire(req.signal, weightFor(req));
|
|
244
|
+
return next.generate(req);
|
|
245
|
+
},
|
|
246
|
+
...(next.countTokens ? { countTokens: next.countTokens.bind(next) } : {}),
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Default TPM weight heuristic: `ceil(textChars / 4) + (maxTokens ?? 0)`.
|
|
254
|
+
* Counts string contents and the `text` of content parts plus the
|
|
255
|
+
* system message; images / other parts are not weighed (heuristic by
|
|
256
|
+
* design - see {@link WithRateLimitOptions.estimateTokens}).
|
|
257
|
+
*/
|
|
258
|
+
function estimateTokensHeuristic(req: ProviderRequest): number {
|
|
259
|
+
let chars = req.systemMessage?.length ?? 0;
|
|
260
|
+
for (const message of req.messages) {
|
|
261
|
+
const content: unknown = message.content;
|
|
262
|
+
if (typeof content === 'string') {
|
|
263
|
+
chars += content.length;
|
|
264
|
+
} else if (Array.isArray(content)) {
|
|
265
|
+
for (const part of content) {
|
|
266
|
+
const text = (part as { readonly text?: unknown }).text;
|
|
267
|
+
if (typeof text === 'string') chars += text.length;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return Math.ceil(chars / 4) + (req.maxTokens ?? 0);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function* rateLimitedStream(
|
|
275
|
+
next: Provider,
|
|
276
|
+
req: import('@graphorin/core').ProviderRequest,
|
|
277
|
+
acquire: (signal?: AbortSignal, weight?: number) => Promise<void>,
|
|
278
|
+
weight: number,
|
|
279
|
+
): AsyncIterable<import('@graphorin/core').ProviderEvent> {
|
|
280
|
+
await acquire(req.signal, weight);
|
|
281
|
+
for await (const event of next.stream(req)) {
|
|
282
|
+
yield event;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
287
|
+
return new Promise<void>((resolve, reject) => {
|
|
288
|
+
if (signal?.aborted) {
|
|
289
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const timer = setTimeout(() => {
|
|
293
|
+
cleanup();
|
|
294
|
+
resolve();
|
|
295
|
+
}, ms);
|
|
296
|
+
const onAbort = () => {
|
|
297
|
+
cleanup();
|
|
298
|
+
reject(signal?.reason ?? new Error('aborted'));
|
|
299
|
+
};
|
|
300
|
+
function cleanup(): void {
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
signal?.removeEventListener('abort', onAbort);
|
|
303
|
+
}
|
|
304
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
305
|
+
});
|
|
306
|
+
}
|