@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/adapters/llamacpp-server.d.ts +1 -1
  4. package/dist/adapters/llamacpp-server.js.map +1 -1
  5. package/dist/adapters/ollama.d.ts.map +1 -1
  6. package/dist/adapters/ollama.js +19 -6
  7. package/dist/adapters/ollama.js.map +1 -1
  8. package/dist/adapters/vercel.d.ts +1 -1
  9. package/dist/adapters/vercel.d.ts.map +1 -1
  10. package/dist/adapters/vercel.js +111 -33
  11. package/dist/adapters/vercel.js.map +1 -1
  12. package/dist/errors/errors.d.ts +1 -1
  13. package/dist/errors/errors.js +1 -1
  14. package/dist/errors/errors.js.map +1 -1
  15. package/dist/index.js +0 -6
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/http.js +111 -7
  18. package/dist/internal/http.js.map +1 -1
  19. package/dist/internal/openai-shaped.js +21 -4
  20. package/dist/internal/openai-shaped.js.map +1 -1
  21. package/dist/middleware/with-fallback.js +1 -1
  22. package/dist/middleware/with-rate-limit.d.ts +21 -8
  23. package/dist/middleware/with-rate-limit.d.ts.map +1 -1
  24. package/dist/middleware/with-rate-limit.js +65 -12
  25. package/dist/middleware/with-rate-limit.js.map +1 -1
  26. package/dist/middleware/with-retry.js +1 -1
  27. package/dist/package.js +1 -1
  28. package/dist/package.js.map +1 -1
  29. package/package.json +18 -16
  30. package/src/adapters/index.ts +14 -0
  31. package/src/adapters/llamacpp-server.ts +102 -0
  32. package/src/adapters/ollama.ts +382 -0
  33. package/src/adapters/openai-compatible.ts +95 -0
  34. package/src/adapters/vercel-messages.ts +308 -0
  35. package/src/adapters/vercel.ts +706 -0
  36. package/src/counters/anthropic-wire.ts +199 -0
  37. package/src/counters/anthropic.ts +114 -0
  38. package/src/counters/bedrock.ts +46 -0
  39. package/src/counters/dispatcher.ts +127 -0
  40. package/src/counters/global.ts +39 -0
  41. package/src/counters/google.ts +46 -0
  42. package/src/counters/heuristic.ts +107 -0
  43. package/src/counters/index.ts +35 -0
  44. package/src/counters/js-tiktoken.ts +135 -0
  45. package/src/counters/serialize.ts +85 -0
  46. package/src/errors/errors.ts +316 -0
  47. package/src/errors/index.ts +20 -0
  48. package/src/index.ts +42 -0
  49. package/src/internal/abort.ts +30 -0
  50. package/src/internal/http.ts +388 -0
  51. package/src/internal/openai-shaped.ts +555 -0
  52. package/src/internal/sse.ts +112 -0
  53. package/src/internal/url-utils.ts +20 -0
  54. package/src/middleware/compose.ts +213 -0
  55. package/src/middleware/index.ts +37 -0
  56. package/src/middleware/production-hook.ts +47 -0
  57. package/src/middleware/with-cost-limit.ts +131 -0
  58. package/src/middleware/with-cost-tracking.ts +216 -0
  59. package/src/middleware/with-fallback.ts +157 -0
  60. package/src/middleware/with-rate-limit.ts +306 -0
  61. package/src/middleware/with-redaction.ts +671 -0
  62. package/src/middleware/with-retry.ts +274 -0
  63. package/src/middleware/with-tracing.ts +117 -0
  64. package/src/model-tier/classify.ts +125 -0
  65. package/src/model-tier/index.ts +11 -0
  66. package/src/provider.ts +121 -0
  67. package/src/reasoning/apply-policy.ts +89 -0
  68. package/src/reasoning/classify-contract.ts +120 -0
  69. package/src/reasoning/index.ts +21 -0
  70. package/src/reasoning/retention.ts +64 -0
  71. package/src/tool-examples.ts +54 -0
  72. package/src/trust/classify-local-provider.ts +254 -0
  73. package/src/trust/index.ts +14 -0
  74. 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
+ }