@graphorin/provider 0.6.0 → 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 +42 -0
- package/README.md +8 -3
- 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.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -8
- 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 +6 -0
- package/dist/package.js.map +1 -0
- 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,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-counter dispatcher and per-vendor strategies.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
AnthropicAPICounter,
|
|
9
|
+
type AnthropicAPICounterOptions,
|
|
10
|
+
} from './anthropic.js';
|
|
11
|
+
export {
|
|
12
|
+
BedrockAPICounter,
|
|
13
|
+
type BedrockAPICounterOptions,
|
|
14
|
+
} from './bedrock.js';
|
|
15
|
+
export {
|
|
16
|
+
type CreateDefaultCounterOptions,
|
|
17
|
+
createDefaultCounter,
|
|
18
|
+
} from './dispatcher.js';
|
|
19
|
+
export {
|
|
20
|
+
__resetGlobalTokenCounter,
|
|
21
|
+
getGlobalTokenCounter,
|
|
22
|
+
setGlobalTokenCounter,
|
|
23
|
+
} from './global.js';
|
|
24
|
+
export { GoogleAPICounter, type GoogleAPICounterOptions } from './google.js';
|
|
25
|
+
export { HeuristicCounter, type HeuristicCounterOptions } from './heuristic.js';
|
|
26
|
+
export {
|
|
27
|
+
__resetTiktokenCache,
|
|
28
|
+
JsTiktokenCounter,
|
|
29
|
+
type JsTiktokenCounterOptions,
|
|
30
|
+
} from './js-tiktoken.js';
|
|
31
|
+
export {
|
|
32
|
+
type SerializedMessage,
|
|
33
|
+
serialiseMessageForCount,
|
|
34
|
+
serializedToString,
|
|
35
|
+
} from './serialize.js';
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `JsTiktokenCounter` - default counter for OpenAI / OpenAI-compatible
|
|
3
|
+
* models. The package depends on the `js-tiktoken` peer; when the peer
|
|
4
|
+
* is missing, instantiation throws at first call rather than silently
|
|
5
|
+
* downgrading to a heuristic.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Message, TokenCounter } from '@graphorin/core';
|
|
11
|
+
|
|
12
|
+
import { serialiseMessageForCount, serializedToString } from './serialize.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Loose shape of a `js-tiktoken` encoding. Matches the public
|
|
16
|
+
* `Tiktoken` instance returned by `getEncoding(...)` in the upstream
|
|
17
|
+
* package (we only use `encode` / `name`).
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
interface TiktokenEncoding {
|
|
22
|
+
readonly name?: string;
|
|
23
|
+
encode(text: string): { length: number };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface TiktokenModule {
|
|
27
|
+
getEncoding(name: string): TiktokenEncoding;
|
|
28
|
+
encodingForModel?: (model: string) => TiktokenEncoding;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Options for {@link JsTiktokenCounter}.
|
|
33
|
+
*
|
|
34
|
+
* @stable
|
|
35
|
+
*/
|
|
36
|
+
export interface JsTiktokenCounterOptions {
|
|
37
|
+
/** Encoding name (`'cl100k_base'`, `'o200k_base'`, …). Default `'cl100k_base'`. */
|
|
38
|
+
readonly encoding?: string;
|
|
39
|
+
/** Optional model id used by `encodingForModel`. Falls back to the explicit `encoding`. */
|
|
40
|
+
readonly modelId?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Override the dynamically-loaded module. Used by tests to inject a
|
|
43
|
+
* stub without taking the real `js-tiktoken` peer dependency.
|
|
44
|
+
*/
|
|
45
|
+
readonly moduleOverride?: TiktokenModule;
|
|
46
|
+
/** Identifier carried on the produced counter. */
|
|
47
|
+
readonly id?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let cachedModule: TiktokenModule | null = null;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Counter that delegates to the `js-tiktoken` package. Caches the
|
|
54
|
+
* dynamically-loaded module per process; tests use `moduleOverride`
|
|
55
|
+
* to supply a fixture-shaped substitute.
|
|
56
|
+
*
|
|
57
|
+
* @stable
|
|
58
|
+
*/
|
|
59
|
+
export class JsTiktokenCounter implements TokenCounter {
|
|
60
|
+
readonly id: string;
|
|
61
|
+
readonly version: string;
|
|
62
|
+
readonly #encoding: string;
|
|
63
|
+
readonly #modelId?: string;
|
|
64
|
+
readonly #moduleOverride?: TiktokenModule;
|
|
65
|
+
#encoder: TiktokenEncoding | null = null;
|
|
66
|
+
|
|
67
|
+
constructor(options: JsTiktokenCounterOptions = {}) {
|
|
68
|
+
this.#encoding = options.encoding ?? 'cl100k_base';
|
|
69
|
+
if (options.modelId !== undefined) this.#modelId = options.modelId;
|
|
70
|
+
if (options.moduleOverride !== undefined) this.#moduleOverride = options.moduleOverride;
|
|
71
|
+
this.id = options.id ?? `js-tiktoken@${this.#encoding}`;
|
|
72
|
+
this.version = `js-tiktoken-${this.#encoding}-v1`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async count(messages: ReadonlyArray<Message>): Promise<number> {
|
|
76
|
+
const enc = await this.#getEncoder();
|
|
77
|
+
let total = 0;
|
|
78
|
+
for (const msg of messages) {
|
|
79
|
+
const serialised = serialiseMessageForCount(msg);
|
|
80
|
+
total += enc.encode(serializedToString(serialised)).length;
|
|
81
|
+
}
|
|
82
|
+
return total;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async countText(text: string): Promise<number> {
|
|
86
|
+
if (text.length === 0) return 0;
|
|
87
|
+
const enc = await this.#getEncoder();
|
|
88
|
+
return enc.encode(text).length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async #getEncoder(): Promise<TiktokenEncoding> {
|
|
92
|
+
if (this.#encoder !== null) return this.#encoder;
|
|
93
|
+
const mod = this.#moduleOverride ?? (await loadTiktokenModule());
|
|
94
|
+
if (this.#modelId !== undefined && mod.encodingForModel !== undefined) {
|
|
95
|
+
try {
|
|
96
|
+
this.#encoder = mod.encodingForModel(this.#modelId);
|
|
97
|
+
return this.#encoder;
|
|
98
|
+
} catch {
|
|
99
|
+
// Fall through to the explicit encoding.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.#encoder = mod.getEncoding(this.#encoding);
|
|
103
|
+
return this.#encoder;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function loadTiktokenModule(): Promise<TiktokenModule> {
|
|
108
|
+
if (cachedModule !== null) return cachedModule;
|
|
109
|
+
let mod: TiktokenModule;
|
|
110
|
+
try {
|
|
111
|
+
mod = (await import('js-tiktoken')) as TiktokenModule;
|
|
112
|
+
} catch (cause) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"[graphorin/provider] JsTiktokenCounter requires the 'js-tiktoken' peer dependency. " +
|
|
115
|
+
'Install it with `pnpm add js-tiktoken` or pass moduleOverride for tests.',
|
|
116
|
+
{ cause },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (typeof mod.getEncoding !== 'function') {
|
|
120
|
+
throw new Error(
|
|
121
|
+
'[graphorin/provider] JsTiktokenCounter: installed js-tiktoken does not expose getEncoding().',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
cachedModule = mod;
|
|
125
|
+
return cachedModule;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Test-only hook that resets the cached `js-tiktoken` module loader.
|
|
130
|
+
*
|
|
131
|
+
* @internal
|
|
132
|
+
*/
|
|
133
|
+
export function __resetTiktokenCache(): void {
|
|
134
|
+
cachedModule = null;
|
|
135
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical text-only serialization of a Graphorin `Message`. Used by
|
|
3
|
+
* counters that lack a vendor-native API (`JsTiktokenCounter`,
|
|
4
|
+
* `HeuristicCounter`).
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Message, MessageContent } from '@graphorin/core';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Plain-text projection of a {@link Message}. The shape is what the
|
|
13
|
+
* counter actually feeds the tokenizer.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export interface SerializedMessage {
|
|
18
|
+
readonly role: string;
|
|
19
|
+
readonly text: string;
|
|
20
|
+
readonly toolCalls: ReadonlyArray<{ readonly name: string; readonly args: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Project a `Message` into a flat string suitable for tokenizers that
|
|
25
|
+
* do not understand multimodal content. Image / audio / file parts
|
|
26
|
+
* are replaced with the canonical placeholder `[image]` / `[audio]` /
|
|
27
|
+
* `[file]` so byte counts stay deterministic.
|
|
28
|
+
*
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export function serialiseMessageForCount(msg: Message): SerializedMessage {
|
|
32
|
+
const role = msg.role;
|
|
33
|
+
const text = serialiseContent(msg.content);
|
|
34
|
+
const toolCalls =
|
|
35
|
+
(msg as { toolCalls?: ReadonlyArray<{ toolName: string; args: unknown }> }).toolCalls?.map(
|
|
36
|
+
(tc) => ({
|
|
37
|
+
name: tc.toolName,
|
|
38
|
+
args: typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args),
|
|
39
|
+
}),
|
|
40
|
+
) ?? [];
|
|
41
|
+
return { role, text, toolCalls };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function serialiseContent(content: string | ReadonlyArray<MessageContent>): string {
|
|
45
|
+
if (typeof content === 'string') return content;
|
|
46
|
+
const buffer: string[] = [];
|
|
47
|
+
for (const part of content) {
|
|
48
|
+
switch (part.type) {
|
|
49
|
+
case 'text':
|
|
50
|
+
buffer.push(part.text);
|
|
51
|
+
break;
|
|
52
|
+
case 'image':
|
|
53
|
+
buffer.push('[image]');
|
|
54
|
+
break;
|
|
55
|
+
case 'audio':
|
|
56
|
+
buffer.push('[audio]');
|
|
57
|
+
break;
|
|
58
|
+
case 'file':
|
|
59
|
+
buffer.push(`[file:${part.mimeType}]`);
|
|
60
|
+
break;
|
|
61
|
+
case 'reasoning':
|
|
62
|
+
buffer.push(part.text);
|
|
63
|
+
break;
|
|
64
|
+
default:
|
|
65
|
+
// Unknown content variants are silently skipped to keep the
|
|
66
|
+
// counter forward-compatible.
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return buffer.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Render a `SerializedMessage` as a single string with role prefix.
|
|
75
|
+
* Useful for naive estimators that count by character length.
|
|
76
|
+
*
|
|
77
|
+
* @internal
|
|
78
|
+
*/
|
|
79
|
+
export function serializedToString(msg: SerializedMessage): string {
|
|
80
|
+
const parts = [`[${msg.role}]`, msg.text];
|
|
81
|
+
for (const tc of msg.toolCalls) {
|
|
82
|
+
parts.push(`[tool-call:${tc.name}] ${tc.args}`);
|
|
83
|
+
}
|
|
84
|
+
return parts.join('\n');
|
|
85
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error hierarchy for `@graphorin/provider`. Every error class
|
|
3
|
+
* carries a stable `kind` discriminant (lowercase, kebab-case) plus an
|
|
4
|
+
* optional `hint` string pointing the user at the recommended fix.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ProviderErrorKind } from '@graphorin/core';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Base class for every error thrown by `@graphorin/provider`. Consumers
|
|
13
|
+
* narrow on the discriminant `kind` rather than `instanceof` - both
|
|
14
|
+
* work, but discriminants are subclass-friendly.
|
|
15
|
+
*
|
|
16
|
+
* @stable
|
|
17
|
+
*/
|
|
18
|
+
export class GraphorinProviderError extends Error {
|
|
19
|
+
override readonly name: string = 'GraphorinProviderError';
|
|
20
|
+
/** Stable discriminant - `'middleware-ordering'`, `'rate-limit-exceeded'`, … */
|
|
21
|
+
readonly kind: string;
|
|
22
|
+
/** Optional remediation hint shown alongside the message. */
|
|
23
|
+
readonly hint?: string;
|
|
24
|
+
|
|
25
|
+
constructor(kind: string, message: string, options?: { cause?: unknown; hint?: string }) {
|
|
26
|
+
super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
|
|
27
|
+
this.kind = kind;
|
|
28
|
+
if (options?.hint !== undefined) {
|
|
29
|
+
this.hint = options.hint;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The middleware composer detected an out-of-order middleware. The
|
|
36
|
+
* `offendingPair` field carries the `[outer, inner]` middleware names
|
|
37
|
+
* that violated the canonical order so consumers can surface a precise
|
|
38
|
+
* fix in IDE diagnostics.
|
|
39
|
+
*
|
|
40
|
+
* @stable
|
|
41
|
+
*/
|
|
42
|
+
export class MiddlewareOrderingError extends GraphorinProviderError {
|
|
43
|
+
override readonly name = 'MiddlewareOrderingError';
|
|
44
|
+
readonly offendingPair: readonly [string, string];
|
|
45
|
+
readonly canonicalOrder: readonly string[];
|
|
46
|
+
|
|
47
|
+
constructor(args: {
|
|
48
|
+
offendingPair: readonly [string, string];
|
|
49
|
+
canonicalOrder: readonly string[];
|
|
50
|
+
message?: string;
|
|
51
|
+
}) {
|
|
52
|
+
super(
|
|
53
|
+
'middleware-ordering',
|
|
54
|
+
args.message ??
|
|
55
|
+
`Middleware order violation: '${args.offendingPair[0]}' must not wrap '${args.offendingPair[1]}'. ` +
|
|
56
|
+
`Canonical order (outer → inner): ${args.canonicalOrder.join(' → ')}.`,
|
|
57
|
+
{
|
|
58
|
+
hint:
|
|
59
|
+
'Reorder the middleware array passed to composeProviderMiddleware([...]) to match the canonical order; ' +
|
|
60
|
+
'see @graphorin/provider/middleware for documentation.',
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
this.offendingPair = args.offendingPair;
|
|
64
|
+
this.canonicalOrder = args.canonicalOrder;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `withRedaction` was missing at production startup. Thrown by the
|
|
70
|
+
* production hook applied to every composed provider when
|
|
71
|
+
* `process.env.NODE_ENV === 'production'`.
|
|
72
|
+
*
|
|
73
|
+
* @stable
|
|
74
|
+
*/
|
|
75
|
+
export class MissingProductionMiddlewareError extends GraphorinProviderError {
|
|
76
|
+
override readonly name = 'MissingProductionMiddlewareError';
|
|
77
|
+
readonly missing: string;
|
|
78
|
+
|
|
79
|
+
constructor(missing: string) {
|
|
80
|
+
super(
|
|
81
|
+
'missing-production-middleware',
|
|
82
|
+
`Production startup hook: required middleware '${missing}' is missing from the provider chain.`,
|
|
83
|
+
{
|
|
84
|
+
hint:
|
|
85
|
+
`Compose '${missing}' as the innermost layer (closest to the underlying provider) before deploying. ` +
|
|
86
|
+
'See @graphorin/provider/middleware for the canonical composition order.',
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
this.missing = missing;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `withCostLimit` exceeded its configured ceiling. Carries the
|
|
95
|
+
* triggering scope (`session`, `agent`, `run`, …) so the caller can
|
|
96
|
+
* decide how to surface the breach.
|
|
97
|
+
*
|
|
98
|
+
* @stable
|
|
99
|
+
*/
|
|
100
|
+
export class CostBudgetExceededError extends GraphorinProviderError {
|
|
101
|
+
override readonly name = 'CostBudgetExceededError';
|
|
102
|
+
readonly scope: string;
|
|
103
|
+
readonly limit: number;
|
|
104
|
+
readonly observed: number;
|
|
105
|
+
|
|
106
|
+
constructor(args: { scope: string; limit: number; observed: number; currency?: string }) {
|
|
107
|
+
super(
|
|
108
|
+
'cost-budget-exceeded',
|
|
109
|
+
`Cost budget exceeded for '${args.scope}': observed ${args.observed.toFixed(4)} ${args.currency ?? 'USD'} ` +
|
|
110
|
+
`> limit ${args.limit.toFixed(4)} ${args.currency ?? 'USD'}.`,
|
|
111
|
+
{
|
|
112
|
+
hint: 'Raise the limit in withCostLimit({...}), tighten the prompt, or switch to a cheaper model tier.',
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
this.scope = args.scope;
|
|
116
|
+
this.limit = args.limit;
|
|
117
|
+
this.observed = args.observed;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `withRateLimit` exhausted its bucket and the caller did not opt
|
|
123
|
+
* into `'queue'` mode.
|
|
124
|
+
*
|
|
125
|
+
* @stable
|
|
126
|
+
*/
|
|
127
|
+
export class RateLimitExceededError extends GraphorinProviderError {
|
|
128
|
+
override readonly name = 'RateLimitExceededError';
|
|
129
|
+
readonly retryAfterMs: number;
|
|
130
|
+
|
|
131
|
+
constructor(retryAfterMs: number) {
|
|
132
|
+
super('rate-limit-exceeded', `Rate limit exceeded; retry after ${retryAfterMs}ms.`, {
|
|
133
|
+
hint: 'Slow down the request rate, raise the bucket size in withRateLimit({...}), or switch to mode: "queue".',
|
|
134
|
+
});
|
|
135
|
+
this.retryAfterMs = retryAfterMs;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `withRedaction` detected a hit AND the policy is configured for
|
|
141
|
+
* `failClosed: true`. The matched value is **never** part of the
|
|
142
|
+
* message - only the pattern name and field path.
|
|
143
|
+
*
|
|
144
|
+
* @stable
|
|
145
|
+
*/
|
|
146
|
+
export class PromptRedactionError extends GraphorinProviderError {
|
|
147
|
+
override readonly name = 'PromptRedactionError';
|
|
148
|
+
readonly patternName: string;
|
|
149
|
+
readonly fieldPath: string;
|
|
150
|
+
readonly role?: string;
|
|
151
|
+
|
|
152
|
+
constructor(args: { patternName: string; fieldPath: string; role?: string }) {
|
|
153
|
+
super(
|
|
154
|
+
'prompt-redaction-fail-closed',
|
|
155
|
+
`Outbound prompt-redaction policy is fail-closed and matched pattern '${args.patternName}' ` +
|
|
156
|
+
`at '${args.fieldPath}'${args.role ? ` (role: ${args.role})` : ''}.`,
|
|
157
|
+
{
|
|
158
|
+
hint: 'Sanitize the prompt before calling the provider, or disable failClosed in withRedaction({...}) for this environment.',
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
this.patternName = args.patternName;
|
|
162
|
+
this.fieldPath = args.fieldPath;
|
|
163
|
+
if (args.role !== undefined) this.role = args.role;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Raised when a `baseUrl`-driven local adapter is configured against
|
|
169
|
+
* a public host over plaintext HTTP. Adapters refuse to start unless
|
|
170
|
+
* `allowInsecureTransport: true` is supplied (and the override is
|
|
171
|
+
* audit-logged).
|
|
172
|
+
*
|
|
173
|
+
* @stable
|
|
174
|
+
*/
|
|
175
|
+
export class LocalProviderInsecureTransportError extends GraphorinProviderError {
|
|
176
|
+
override readonly name = 'LocalProviderInsecureTransportError';
|
|
177
|
+
readonly baseUrl: string;
|
|
178
|
+
|
|
179
|
+
constructor(baseUrl: string) {
|
|
180
|
+
super(
|
|
181
|
+
'local-provider-insecure-transport',
|
|
182
|
+
`Refusing to start: the provider baseUrl '${baseUrl}' is public AND plaintext (http://). ` +
|
|
183
|
+
`Switch to https:// or set allowInsecureTransport: true to acknowledge the risk.`,
|
|
184
|
+
{
|
|
185
|
+
hint:
|
|
186
|
+
'Public local-LLM endpoints must be served over TLS. Acceptable alternatives: terminate TLS in front of the model server, ' +
|
|
187
|
+
'tunnel through SSH / WireGuard / Tailscale, or pass allowInsecureTransport: true (audit-logged).',
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
this.baseUrl = baseUrl;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Legacy alias preserved for one minor; prefer
|
|
196
|
+
* {@link LocalProviderInsecureTransportError}. Removed in v0.2.
|
|
197
|
+
*
|
|
198
|
+
* @deprecated Use {@link LocalProviderInsecureTransportError} instead.
|
|
199
|
+
*/
|
|
200
|
+
export const OllamaInsecureTransportError = LocalProviderInsecureTransportError;
|
|
201
|
+
export type OllamaInsecureTransportError = LocalProviderInsecureTransportError;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Body phrasings used by the major providers when the request blew the
|
|
205
|
+
* model's context window. Sniffed by {@link classifyHttpStatus} so a
|
|
206
|
+
* 400 that is really a context overflow surfaces as `'context-length'`
|
|
207
|
+
* (the kind the compaction / emergency tiers key on), not a generic
|
|
208
|
+
* `'invalid-request'`.
|
|
209
|
+
*/
|
|
210
|
+
const CONTEXT_LENGTH_BODY_RE =
|
|
211
|
+
/context[_ -]?length|context window|prompt is too long|maximum context|too many tokens|exceeds? the (?:model'?s? )?max/i;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Map an HTTP status (plus optional error-body text) onto the
|
|
215
|
+
* canonical `ProviderErrorKind`. One
|
|
216
|
+
* shared table so `withRetry` / `withFallback` and consumers switching
|
|
217
|
+
* on the documented kinds see consistent values from every HTTP
|
|
218
|
+
* adapter:
|
|
219
|
+
*
|
|
220
|
+
* - `429` → `'rate-limit'`
|
|
221
|
+
* - `401` / `403` → `'unauthorized'`
|
|
222
|
+
* - `400` / `404` / `422` → `'invalid-request'` (or
|
|
223
|
+
* `'context-length'` when the body says so)
|
|
224
|
+
* - `503` / `529` → `'capacity'` (529 is Anthropic's overloaded code)
|
|
225
|
+
* - other `5xx` and `0` (network failure) → `'transient'`
|
|
226
|
+
*
|
|
227
|
+
* @stable
|
|
228
|
+
*/
|
|
229
|
+
export function classifyHttpStatus(status: number, bodyText?: string): ProviderErrorKind {
|
|
230
|
+
if (status === 429) return 'rate-limit';
|
|
231
|
+
if (status === 401 || status === 403) return 'unauthorized';
|
|
232
|
+
if (status === 400 || status === 404 || status === 422) {
|
|
233
|
+
if (bodyText !== undefined && CONTEXT_LENGTH_BODY_RE.test(bodyText)) return 'context-length';
|
|
234
|
+
return 'invalid-request';
|
|
235
|
+
}
|
|
236
|
+
if (status === 503 || status === 529) return 'capacity';
|
|
237
|
+
if (status >= 500 || status === 0) return 'transient';
|
|
238
|
+
return 'unknown';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Wrapped HTTP error returned by an adapter. Carries the original
|
|
243
|
+
* status code so middleware (`withRetry`, `withFallback`) can decide
|
|
244
|
+
* whether the error is retryable.
|
|
245
|
+
*
|
|
246
|
+
* @stable
|
|
247
|
+
*/
|
|
248
|
+
export class ProviderHttpError extends GraphorinProviderError {
|
|
249
|
+
override readonly name = 'ProviderHttpError';
|
|
250
|
+
readonly status: number;
|
|
251
|
+
readonly providerName: string;
|
|
252
|
+
/**
|
|
253
|
+
* The canonical `ProviderErrorKind` mapped from the HTTP status via
|
|
254
|
+
* {@link classifyHttpStatus} (the `kind` field keeps its stable
|
|
255
|
+
* `'provider-http'` discriminant). Middleware predicates consult
|
|
256
|
+
* this so a 429 fails over / retries as a rate limit.
|
|
257
|
+
*/
|
|
258
|
+
readonly errorKind: ProviderErrorKind;
|
|
259
|
+
/**
|
|
260
|
+
* Backoff-relevant response headers captured from the failed
|
|
261
|
+
* response (`retry-after`, `x-ratelimit-*`), lowercased.
|
|
262
|
+
* `withRetry`'s Retry-After hint reader consumes them.
|
|
263
|
+
*/
|
|
264
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
265
|
+
|
|
266
|
+
constructor(args: {
|
|
267
|
+
providerName: string;
|
|
268
|
+
status: number;
|
|
269
|
+
message: string;
|
|
270
|
+
cause?: unknown;
|
|
271
|
+
errorKind?: ProviderErrorKind;
|
|
272
|
+
headers?: Readonly<Record<string, string>>;
|
|
273
|
+
}) {
|
|
274
|
+
super('provider-http', `[${args.providerName}] HTTP ${args.status}: ${args.message}`, {
|
|
275
|
+
...(args.cause !== undefined ? { cause: args.cause } : {}),
|
|
276
|
+
});
|
|
277
|
+
this.providerName = args.providerName;
|
|
278
|
+
this.status = args.status;
|
|
279
|
+
this.errorKind = args.errorKind ?? classifyHttpStatus(args.status, args.message);
|
|
280
|
+
if (args.headers !== undefined) this.headers = args.headers;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The streaming response did not match the documented wire format
|
|
286
|
+
* (e.g. malformed SSE chunk, truncated JSON).
|
|
287
|
+
*
|
|
288
|
+
* @stable
|
|
289
|
+
*/
|
|
290
|
+
export class ProviderStreamParseError extends GraphorinProviderError {
|
|
291
|
+
override readonly name = 'ProviderStreamParseError';
|
|
292
|
+
readonly providerName: string;
|
|
293
|
+
|
|
294
|
+
constructor(providerName: string, message: string, cause?: unknown) {
|
|
295
|
+
super('provider-stream-parse', `[${providerName}] ${message}`, {
|
|
296
|
+
...(cause !== undefined ? { cause } : {}),
|
|
297
|
+
});
|
|
298
|
+
this.providerName = providerName;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The classifier dispatcher was given a non-Provider value. Programming
|
|
304
|
+
* error; fail-fast at the boundary.
|
|
305
|
+
*
|
|
306
|
+
* @stable
|
|
307
|
+
*/
|
|
308
|
+
export class InvalidProviderError extends GraphorinProviderError {
|
|
309
|
+
override readonly name = 'InvalidProviderError';
|
|
310
|
+
|
|
311
|
+
constructor(message: string) {
|
|
312
|
+
super('invalid-provider', message, {
|
|
313
|
+
hint: 'Pass the value returned by createProvider(...) (or any adapter factory) instead of a raw object.',
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public error surface for `@graphorin/provider`.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
CostBudgetExceededError,
|
|
9
|
+
classifyHttpStatus,
|
|
10
|
+
GraphorinProviderError,
|
|
11
|
+
InvalidProviderError,
|
|
12
|
+
LocalProviderInsecureTransportError,
|
|
13
|
+
MiddlewareOrderingError,
|
|
14
|
+
MissingProductionMiddlewareError,
|
|
15
|
+
OllamaInsecureTransportError,
|
|
16
|
+
PromptRedactionError,
|
|
17
|
+
ProviderHttpError,
|
|
18
|
+
ProviderStreamParseError,
|
|
19
|
+
RateLimitExceededError,
|
|
20
|
+
} from './errors.js';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @graphorin/provider - vendor-neutral LLM provider layer for the
|
|
3
|
+
* Graphorin framework.
|
|
4
|
+
*
|
|
5
|
+
* The package owns:
|
|
6
|
+
*
|
|
7
|
+
* - The `createProvider(...)` factory for wrapping adapter modules in
|
|
8
|
+
* a stable `Provider` shape.
|
|
9
|
+
* - Four adapters out of the box: the Vercel AI SDK adapter for the
|
|
10
|
+
* default cloud path, plus three local-LLM adapters (Ollama HTTP,
|
|
11
|
+
* llama-server HTTP, and a generic OpenAI-compatible adapter for
|
|
12
|
+
* LMStudio / LocalAI / vLLM / Together-style endpoints). All three
|
|
13
|
+
* `baseUrl`-driven adapters share the same {@link LocalProviderTrust}
|
|
14
|
+
* classifier.
|
|
15
|
+
* - The canonical-order middleware composer
|
|
16
|
+
* ({@link composeProviderMiddleware}) and the seven built-in
|
|
17
|
+
* middlewares (`withTracing`, `withRetry`, `withRateLimit`,
|
|
18
|
+
* `withCostLimit`, `withCostTracking`, `withFallback`,
|
|
19
|
+
* `withRedaction`).
|
|
20
|
+
* - The pluggable `TokenCounter` dispatcher (`createDefaultCounter`)
|
|
21
|
+
* plus the per-vendor strategies and a heuristic fallback.
|
|
22
|
+
* - The per-provider model-tier auto-classifier
|
|
23
|
+
* ({@link classifyModelTier}) that maps a `Provider`'s `modelId` to
|
|
24
|
+
* one of `'fast' | 'balanced' | 'smart'`.
|
|
25
|
+
*
|
|
26
|
+
* @packageDocumentation
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Canonical version constant, derived from `package.json` at build time. */
|
|
30
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
31
|
+
|
|
32
|
+
export const VERSION: string = pkg.version;
|
|
33
|
+
|
|
34
|
+
export * from './adapters/index.js';
|
|
35
|
+
export * from './counters/index.js';
|
|
36
|
+
export * from './errors/index.js';
|
|
37
|
+
export * from './middleware/index.js';
|
|
38
|
+
export * from './model-tier/index.js';
|
|
39
|
+
export * from './provider.js';
|
|
40
|
+
export * from './reasoning/index.js';
|
|
41
|
+
export { foldToolExamples } from './tool-examples.js';
|
|
42
|
+
export * from './trust/index.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abort detection shared by the retry / fallback predicates (PS-2).
|
|
3
|
+
*
|
|
4
|
+
* A network failure surfaces as `ProviderHttpError{ status: 0, cause }`,
|
|
5
|
+
* and a user/agent abort surfaces the same way - the distinguishing
|
|
6
|
+
* signal is an `AbortError` (DOMException) somewhere on the `cause`
|
|
7
|
+
* chain. Network errors should be retried and failed over; an abort must
|
|
8
|
+
* not be, even though the retry/fallback loops also short-circuit on
|
|
9
|
+
* `req.signal?.aborted` independently.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* True when `err` - or any error on its `cause` chain - is an abort
|
|
16
|
+
* (`name === 'AbortError'` or Node's `code === 'ABORT_ERR'`). Walks at
|
|
17
|
+
* most a few links so a cyclic / pathological cause chain cannot loop.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export function isAbortError(err: unknown): boolean {
|
|
22
|
+
let cursor: unknown = err;
|
|
23
|
+
for (let depth = 0; depth < 8 && cursor !== null && typeof cursor === 'object'; depth++) {
|
|
24
|
+
const e = cursor as { name?: unknown; code?: unknown; cause?: unknown };
|
|
25
|
+
if (e.name === 'AbortError' || e.code === 'ABORT_ERR') return true;
|
|
26
|
+
if (e.cause === cursor) break;
|
|
27
|
+
cursor = e.cause;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|