@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,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical-order middleware composer. The composer enforces the
|
|
3
|
+
* documented ordering at startup and throws
|
|
4
|
+
* {@link MiddlewareOrderingError} on violation. Manual
|
|
5
|
+
* `withRetry(withRedaction(p))` style still works but emits a one-time
|
|
6
|
+
* WARN unless the consumer flips `strict: true`, in which case the
|
|
7
|
+
* composer also throws on manual nesting that violates the canonical
|
|
8
|
+
* order.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Provider, ProviderMiddleware } from '@graphorin/core';
|
|
14
|
+
|
|
15
|
+
import { MiddlewareOrderingError } from '../errors/errors.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Symbol attached to every middleware-produced provider so the
|
|
19
|
+
* composer can detect and validate the chain. The symbol is opaque
|
|
20
|
+
* and cross-realm safe via `Symbol.for`.
|
|
21
|
+
*
|
|
22
|
+
* @stable
|
|
23
|
+
*/
|
|
24
|
+
export const MIDDLEWARE_KIND: unique symbol = Symbol.for('graphorin.provider.middleware.kind');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Canonical middleware ordering - outermost → innermost. The table
|
|
28
|
+
* is enforced by {@link composeProviderMiddleware} and is part of the
|
|
29
|
+
* provider layer's public contract (DEC-145 / ADR-039).
|
|
30
|
+
*
|
|
31
|
+
* ## Why this order
|
|
32
|
+
*
|
|
33
|
+
* Composition is outermost-first, so a request flows top→bottom and a
|
|
34
|
+
* response flows bottom→top:
|
|
35
|
+
*
|
|
36
|
+
* - `withTracing` is outermost so the span wraps everything below -
|
|
37
|
+
* including retries - and records true end-to-end latency.
|
|
38
|
+
* - `withRetry` sits above the rate/cost limiters so each retry
|
|
39
|
+
* attempt is independently counted and throttled.
|
|
40
|
+
* - `withRateLimit` → `withCostLimit` → `withCostTracking` form the
|
|
41
|
+
* budget stack: throttle before admitting, reject over-budget calls
|
|
42
|
+
* before spending, then meter what actually went through.
|
|
43
|
+
* - `withFallback` is just above redaction so a fallback to a
|
|
44
|
+
* secondary provider still passes through the redactor.
|
|
45
|
+
* - `withRedaction` is **innermost** (closest to the provider) so it
|
|
46
|
+
* is the last thing to touch the outbound payload and the first to
|
|
47
|
+
* touch the inbound stream - guaranteeing every retry, fallback, and
|
|
48
|
+
* cost-tracked request sees an already-redacted payload and no
|
|
49
|
+
* secret can bypass it.
|
|
50
|
+
*
|
|
51
|
+
* @stable
|
|
52
|
+
*/
|
|
53
|
+
export const CANONICAL_MIDDLEWARE_ORDER: readonly string[] = [
|
|
54
|
+
'withTracing',
|
|
55
|
+
'withRetry',
|
|
56
|
+
'withRateLimit',
|
|
57
|
+
'withCostLimit',
|
|
58
|
+
'withCostTracking',
|
|
59
|
+
'withFallback',
|
|
60
|
+
'withRedaction',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const CANONICAL_INDEX: Readonly<Record<string, number>> = Object.freeze(
|
|
64
|
+
CANONICAL_MIDDLEWARE_ORDER.reduce<Record<string, number>>((acc, name, idx) => {
|
|
65
|
+
acc[name] = idx;
|
|
66
|
+
return acc;
|
|
67
|
+
}, {}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
interface KindedProvider extends Provider {
|
|
71
|
+
readonly [MIDDLEWARE_KIND]?: string;
|
|
72
|
+
readonly [INNER_PROVIDER]?: Provider;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Symbol used to walk the chain - every wrapper exposes the inner
|
|
77
|
+
* provider so the composer can introspect the full stack at startup.
|
|
78
|
+
*
|
|
79
|
+
* @stable
|
|
80
|
+
*/
|
|
81
|
+
export const INNER_PROVIDER: unique symbol = Symbol.for('graphorin.provider.middleware.inner');
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read the discriminant kind attached to a middleware-produced
|
|
85
|
+
* provider. Returns `undefined` if the provider is the bare adapter
|
|
86
|
+
* or a custom wrapper that does not declare a kind.
|
|
87
|
+
*
|
|
88
|
+
* @stable
|
|
89
|
+
*/
|
|
90
|
+
export function getMiddlewareKind(provider: Provider): string | undefined {
|
|
91
|
+
return (provider as KindedProvider)[MIDDLEWARE_KIND];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Walk the middleware chain inside `provider` and return the array of
|
|
96
|
+
* declared kinds (outer → inner).
|
|
97
|
+
*
|
|
98
|
+
* @stable
|
|
99
|
+
*/
|
|
100
|
+
export function listMiddlewareKinds(provider: Provider): readonly string[] {
|
|
101
|
+
const kinds: string[] = [];
|
|
102
|
+
let cur: KindedProvider | undefined = provider as KindedProvider;
|
|
103
|
+
while (cur !== undefined) {
|
|
104
|
+
const kind = cur[MIDDLEWARE_KIND];
|
|
105
|
+
if (kind !== undefined) kinds.push(kind);
|
|
106
|
+
cur = cur[INNER_PROVIDER] as KindedProvider | undefined;
|
|
107
|
+
}
|
|
108
|
+
return kinds;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Return `true` iff the chain rooted at `provider` contains a
|
|
113
|
+
* middleware whose kind matches `name`.
|
|
114
|
+
*
|
|
115
|
+
* @stable
|
|
116
|
+
*/
|
|
117
|
+
export function providerHasMiddleware(provider: Provider, name: string): boolean {
|
|
118
|
+
return listMiddlewareKinds(provider).includes(name);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Wrap an adapter in a middleware chain whose order is validated
|
|
123
|
+
* against {@link CANONICAL_MIDDLEWARE_ORDER}. The argument array MUST
|
|
124
|
+
* be ordered outermost → innermost - the same way the layers appear
|
|
125
|
+
* in the documented composition example. The composer validates that
|
|
126
|
+
* every kind known to the canonical order is monotonically non-
|
|
127
|
+
* decreasing in index, throws otherwise.
|
|
128
|
+
*
|
|
129
|
+
* Custom middleware whose kind is NOT in the canonical order is
|
|
130
|
+
* silently allowed at any position - operators registering bespoke
|
|
131
|
+
* layers via {@link defineProviderMiddleware} carry the
|
|
132
|
+
* responsibility of placing them sensibly.
|
|
133
|
+
*
|
|
134
|
+
* @stable
|
|
135
|
+
*/
|
|
136
|
+
export function composeProviderMiddleware(
|
|
137
|
+
middlewares: ReadonlyArray<ProviderMiddleware>,
|
|
138
|
+
): ProviderMiddleware {
|
|
139
|
+
return (next: Provider): Provider => {
|
|
140
|
+
let chain: Provider = next;
|
|
141
|
+
// Apply innermost → outermost so the composed call order matches
|
|
142
|
+
// the array's outer → inner declaration.
|
|
143
|
+
for (let i = middlewares.length - 1; i >= 0; i--) {
|
|
144
|
+
const factory = middlewares[i];
|
|
145
|
+
if (typeof factory !== 'function') {
|
|
146
|
+
throw new TypeError(
|
|
147
|
+
`composeProviderMiddleware: entry at index ${i} is not a ProviderMiddleware function.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
chain = factory(chain);
|
|
151
|
+
}
|
|
152
|
+
validateMiddlewareOrder(chain);
|
|
153
|
+
return chain;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate the kinds present in `provider` against the canonical
|
|
159
|
+
* order. Exposed for tests; the composer calls it on every chain.
|
|
160
|
+
*
|
|
161
|
+
* @stable
|
|
162
|
+
*/
|
|
163
|
+
export function validateMiddlewareOrder(provider: Provider): void {
|
|
164
|
+
const kinds = listMiddlewareKinds(provider);
|
|
165
|
+
// Filter to the kinds we recognise.
|
|
166
|
+
const recognised: { kind: string; index: number }[] = [];
|
|
167
|
+
for (const kind of kinds) {
|
|
168
|
+
const idx = CANONICAL_INDEX[kind];
|
|
169
|
+
if (idx !== undefined) recognised.push({ kind, index: idx });
|
|
170
|
+
}
|
|
171
|
+
// The recognised list runs outer → inner; canonical indices must
|
|
172
|
+
// increase monotonically. The first decreasing pair is the
|
|
173
|
+
// violation.
|
|
174
|
+
for (let i = 1; i < recognised.length; i++) {
|
|
175
|
+
const prev = recognised[i - 1];
|
|
176
|
+
const cur = recognised[i];
|
|
177
|
+
if (prev !== undefined && cur !== undefined && cur.index < prev.index) {
|
|
178
|
+
throw new MiddlewareOrderingError({
|
|
179
|
+
offendingPair: [prev.kind, cur.kind],
|
|
180
|
+
canonicalOrder: CANONICAL_MIDDLEWARE_ORDER,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Decorator factory used internally by every built-in middleware. The
|
|
188
|
+
* returned function attaches the canonical kind discriminator and the
|
|
189
|
+
* inner-provider symbol so the composer can introspect chains.
|
|
190
|
+
*
|
|
191
|
+
* @stable
|
|
192
|
+
*/
|
|
193
|
+
export function defineProviderMiddleware<T>(args: {
|
|
194
|
+
readonly kind: string;
|
|
195
|
+
readonly factory: (opts: T) => ProviderMiddleware;
|
|
196
|
+
}): (opts: T) => ProviderMiddleware {
|
|
197
|
+
return (opts: T): ProviderMiddleware => {
|
|
198
|
+
const built = args.factory(opts);
|
|
199
|
+
return (next: Provider): Provider => {
|
|
200
|
+
const wrapped = built(next);
|
|
201
|
+
const branded: Provider = Object.create(wrapped) as Provider;
|
|
202
|
+
Object.defineProperty(branded, MIDDLEWARE_KIND, {
|
|
203
|
+
value: args.kind,
|
|
204
|
+
enumerable: false,
|
|
205
|
+
});
|
|
206
|
+
Object.defineProperty(branded, INNER_PROVIDER, {
|
|
207
|
+
value: next,
|
|
208
|
+
enumerable: false,
|
|
209
|
+
});
|
|
210
|
+
return branded;
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware barrel - the canonical-order composer plus seven built-in
|
|
3
|
+
* middlewares.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
CANONICAL_MIDDLEWARE_ORDER,
|
|
10
|
+
composeProviderMiddleware,
|
|
11
|
+
defineProviderMiddleware,
|
|
12
|
+
getMiddlewareKind,
|
|
13
|
+
MIDDLEWARE_KIND,
|
|
14
|
+
providerHasMiddleware,
|
|
15
|
+
} from './compose.js';
|
|
16
|
+
export {
|
|
17
|
+
assertProductionMiddleware,
|
|
18
|
+
type ProductionStartupHookOptions,
|
|
19
|
+
} from './production-hook.js';
|
|
20
|
+
export { type WithCostLimitOptions, withCostLimit } from './with-cost-limit.js';
|
|
21
|
+
export {
|
|
22
|
+
type CostAccumulator,
|
|
23
|
+
type CostTrackingTotals,
|
|
24
|
+
createCostAccumulator,
|
|
25
|
+
type WithCostTrackingOptions,
|
|
26
|
+
withCostTracking,
|
|
27
|
+
} from './with-cost-tracking.js';
|
|
28
|
+
export { type WithFallbackOptions, withFallback } from './with-fallback.js';
|
|
29
|
+
export { type WithRateLimitOptions, withRateLimit } from './with-rate-limit.js';
|
|
30
|
+
export {
|
|
31
|
+
type PromptRedactionPolicy,
|
|
32
|
+
type PromptRedactionScanScope,
|
|
33
|
+
type PromptRedactionViolation,
|
|
34
|
+
withRedaction,
|
|
35
|
+
} from './with-redaction.js';
|
|
36
|
+
export { type WithRetryOptions, withRetry } from './with-retry.js';
|
|
37
|
+
export { type WithTracingOptions, withTracing } from './with-tracing.js';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production startup hook - fails fast when a security-critical
|
|
3
|
+
* middleware is missing from the composed chain. The default
|
|
4
|
+
* configuration enforces `withRedaction`; consumers can extend the
|
|
5
|
+
* `requiredKinds` list to lock additional middlewares.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Provider } from '@graphorin/core';
|
|
11
|
+
|
|
12
|
+
import { MissingProductionMiddlewareError } from '../errors/errors.js';
|
|
13
|
+
import { providerHasMiddleware } from './compose.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Options for {@link assertProductionMiddleware}.
|
|
17
|
+
*
|
|
18
|
+
* @stable
|
|
19
|
+
*/
|
|
20
|
+
export interface ProductionStartupHookOptions {
|
|
21
|
+
/** Middleware kinds that must be present. Defaults to `['withRedaction']`. */
|
|
22
|
+
readonly requiredKinds?: ReadonlyArray<string>;
|
|
23
|
+
/** Force the check regardless of `NODE_ENV`. */
|
|
24
|
+
readonly force?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Throw {@link MissingProductionMiddlewareError} if a required
|
|
29
|
+
* middleware is missing from the chain rooted at `provider`. The
|
|
30
|
+
* check runs only when `NODE_ENV === 'production'` unless `force` is
|
|
31
|
+
* `true`.
|
|
32
|
+
*
|
|
33
|
+
* @stable
|
|
34
|
+
*/
|
|
35
|
+
export function assertProductionMiddleware(
|
|
36
|
+
provider: Provider,
|
|
37
|
+
options: ProductionStartupHookOptions = {},
|
|
38
|
+
): void {
|
|
39
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
40
|
+
if (!isProd && options.force !== true) return;
|
|
41
|
+
const required = options.requiredKinds ?? ['withRedaction'];
|
|
42
|
+
for (const kind of required) {
|
|
43
|
+
if (!providerHasMiddleware(provider, kind)) {
|
|
44
|
+
throw new MissingProductionMiddlewareError(kind);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `withCostLimit` - enforce per-session / per-run / per-agent /
|
|
3
|
+
* per-hour cost ceilings. Couples with {@link withCostTracking} for
|
|
4
|
+
* the underlying accumulator. The middleware is positioned between
|
|
5
|
+
* `withRateLimit` and `withCostTracking` per the canonical order so a
|
|
6
|
+
* budget breach trips before the request hits the underlying
|
|
7
|
+
* provider.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Provider } from '@graphorin/core';
|
|
13
|
+
|
|
14
|
+
import { CostBudgetExceededError } from '../errors/errors.js';
|
|
15
|
+
import { defineProviderMiddleware } from './compose.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options for {@link withCostLimit}.
|
|
19
|
+
*
|
|
20
|
+
* @stable
|
|
21
|
+
*/
|
|
22
|
+
export interface WithCostLimitOptions {
|
|
23
|
+
/** Maximum cumulative USD cost per session. */
|
|
24
|
+
readonly maxPerSession?: number;
|
|
25
|
+
/** Maximum cumulative USD cost per run. */
|
|
26
|
+
readonly maxPerRun?: number;
|
|
27
|
+
/** Maximum cumulative USD cost per hour. */
|
|
28
|
+
readonly maxPerHour?: number;
|
|
29
|
+
/** What to do on breach. Default `'throw'`. */
|
|
30
|
+
readonly onExceed?: 'throw' | 'warn';
|
|
31
|
+
/**
|
|
32
|
+
* Resolver returning the current observed cost for the relevant
|
|
33
|
+
* scope. The resolver lets consumers wire any accumulator (the
|
|
34
|
+
* shipped `@graphorin/observability/cost.CostTracker` works out of
|
|
35
|
+
* the box). When unset, the middleware is a no-op (a placeholder
|
|
36
|
+
* for tooling that wires the accumulator later).
|
|
37
|
+
*/
|
|
38
|
+
readonly resolveObservedCost?: (
|
|
39
|
+
scope: 'session' | 'run' | 'hour',
|
|
40
|
+
metadata: Readonly<Record<string, unknown>> | undefined,
|
|
41
|
+
) => number;
|
|
42
|
+
/** Optional sink for `'warn'` mode. Defaults to `console.warn`. */
|
|
43
|
+
readonly logger?: (message: string, meta?: object) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @stable
|
|
48
|
+
*/
|
|
49
|
+
export const withCostLimit = defineProviderMiddleware<WithCostLimitOptions>({
|
|
50
|
+
kind: 'withCostLimit',
|
|
51
|
+
factory: (opts: WithCostLimitOptions) => {
|
|
52
|
+
const onExceed = opts.onExceed ?? 'throw';
|
|
53
|
+
const resolver = opts.resolveObservedCost;
|
|
54
|
+
const logger = opts.logger ?? defaultLogger;
|
|
55
|
+
// PS-8: a ceiling without a resolver is a silent no-op - the limits look
|
|
56
|
+
// enforced but never trip. Warn loudly so the gap is visible. (No ceiling
|
|
57
|
+
// + no resolver is the documented inert placeholder, so it stays quiet.)
|
|
58
|
+
const hasCeiling =
|
|
59
|
+
opts.maxPerSession !== undefined ||
|
|
60
|
+
opts.maxPerRun !== undefined ||
|
|
61
|
+
opts.maxPerHour !== undefined;
|
|
62
|
+
if (hasCeiling && resolver === undefined) {
|
|
63
|
+
logger(
|
|
64
|
+
'[graphorin/provider] withCostLimit: a cost ceiling is configured but no ' +
|
|
65
|
+
'`resolveObservedCost` was supplied, so the limit is UNENFORCED (no-op). ' +
|
|
66
|
+
'Wire a resolver (e.g. from createCostAccumulator / @graphorin/observability).',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return (next: Provider): Provider => ({
|
|
70
|
+
name: next.name,
|
|
71
|
+
modelId: next.modelId,
|
|
72
|
+
capabilities: next.capabilities,
|
|
73
|
+
...(next.acceptsSensitivity !== undefined
|
|
74
|
+
? { acceptsSensitivity: next.acceptsSensitivity }
|
|
75
|
+
: {}),
|
|
76
|
+
stream(req) {
|
|
77
|
+
if (resolver !== undefined) {
|
|
78
|
+
check(asRecord(req.metadata), resolver, opts, onExceed, logger);
|
|
79
|
+
}
|
|
80
|
+
return next.stream(req);
|
|
81
|
+
},
|
|
82
|
+
async generate(req) {
|
|
83
|
+
if (resolver !== undefined) {
|
|
84
|
+
check(asRecord(req.metadata), resolver, opts, onExceed, logger);
|
|
85
|
+
}
|
|
86
|
+
return next.generate(req);
|
|
87
|
+
},
|
|
88
|
+
...(next.countTokens ? { countTokens: next.countTokens.bind(next) } : {}),
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function check(
|
|
94
|
+
metadata: Readonly<Record<string, unknown>> | undefined,
|
|
95
|
+
resolver: (
|
|
96
|
+
scope: 'session' | 'run' | 'hour',
|
|
97
|
+
metadata?: Readonly<Record<string, unknown>>,
|
|
98
|
+
) => number,
|
|
99
|
+
opts: WithCostLimitOptions,
|
|
100
|
+
onExceed: 'throw' | 'warn',
|
|
101
|
+
logger: (message: string, meta?: object) => void,
|
|
102
|
+
): void {
|
|
103
|
+
const checks: Array<['session' | 'run' | 'hour', number | undefined]> = [
|
|
104
|
+
['session', opts.maxPerSession],
|
|
105
|
+
['run', opts.maxPerRun],
|
|
106
|
+
['hour', opts.maxPerHour],
|
|
107
|
+
];
|
|
108
|
+
for (const [scope, limit] of checks) {
|
|
109
|
+
if (limit === undefined) continue;
|
|
110
|
+
const observed = resolver(scope, metadata);
|
|
111
|
+
if (observed > limit) {
|
|
112
|
+
if (onExceed === 'throw') {
|
|
113
|
+
throw new CostBudgetExceededError({ scope, limit, observed });
|
|
114
|
+
}
|
|
115
|
+
logger(
|
|
116
|
+
`[graphorin/provider] withCostLimit: ${scope} budget breach (${observed.toFixed(4)} > ${limit.toFixed(4)} USD).`,
|
|
117
|
+
{ scope, limit, observed },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function defaultLogger(message: string, meta?: object): void {
|
|
124
|
+
if (meta !== undefined) console.warn(message, meta);
|
|
125
|
+
else console.warn(message);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
|
|
129
|
+
if (value === undefined || value === null) return undefined;
|
|
130
|
+
return value as Readonly<Record<string, unknown>>;
|
|
131
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `withCostTracking` - reports `tokensUsed` + `cost` per `provider × model`
|
|
3
|
+
* from the `finish` event of every stream / one-shot generation via the
|
|
4
|
+
* `onUsage` hook. For a ready-made process-local accumulator, wire
|
|
5
|
+
* {@link createCostAccumulator}'s `onUsage` and read its `.totals()`; consumers
|
|
6
|
+
* that prefer the framework's `CostTracker` from `@graphorin/observability`
|
|
7
|
+
* pass their own delegate.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Provider, ProviderEvent, ProviderResponse } from '@graphorin/core';
|
|
13
|
+
|
|
14
|
+
import { defineProviderMiddleware } from './compose.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Aggregated totals for one `provider × model`, returned by
|
|
18
|
+
* {@link CostAccumulator.totalFor} / {@link CostAccumulator.totals}.
|
|
19
|
+
*
|
|
20
|
+
* @stable
|
|
21
|
+
*/
|
|
22
|
+
export interface CostTrackingTotals {
|
|
23
|
+
readonly callCount: number;
|
|
24
|
+
readonly promptTokens: number;
|
|
25
|
+
readonly completionTokens: number;
|
|
26
|
+
readonly totalTokens: number;
|
|
27
|
+
/** Prompt tokens served from the provider cache (subset of promptTokens). */
|
|
28
|
+
readonly cachedReadTokens: number;
|
|
29
|
+
/** Prompt tokens written to the provider cache (subset of promptTokens). */
|
|
30
|
+
readonly cacheWriteTokens: number;
|
|
31
|
+
readonly costUsd: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A process-local cost accumulator (PS-8). Wire {@link CostAccumulator.onUsage}
|
|
36
|
+
* into {@link withCostTracking} and read the running totals - keyed by
|
|
37
|
+
* `provider × model` - back via {@link CostAccumulator.totals} /
|
|
38
|
+
* {@link CostAccumulator.totalFor}.
|
|
39
|
+
*
|
|
40
|
+
* @stable
|
|
41
|
+
*/
|
|
42
|
+
export interface CostAccumulator {
|
|
43
|
+
/** Pass this to {@link withCostTracking}'s `onUsage`. */
|
|
44
|
+
readonly onUsage: NonNullable<WithCostTrackingOptions['onUsage']>;
|
|
45
|
+
/** Snapshot of every tracked `provider::model` → totals. */
|
|
46
|
+
totals(): ReadonlyMap<string, CostTrackingTotals>;
|
|
47
|
+
/** Running totals for one `provider × model` (zeros when unseen). */
|
|
48
|
+
totalFor(providerName: string, modelId: string): CostTrackingTotals;
|
|
49
|
+
/** Clear all accumulated totals. */
|
|
50
|
+
reset(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ZERO_TOTALS: CostTrackingTotals = Object.freeze({
|
|
54
|
+
callCount: 0,
|
|
55
|
+
promptTokens: 0,
|
|
56
|
+
completionTokens: 0,
|
|
57
|
+
totalTokens: 0,
|
|
58
|
+
cachedReadTokens: 0,
|
|
59
|
+
cacheWriteTokens: 0,
|
|
60
|
+
costUsd: 0,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create a {@link CostAccumulator} - the process-local accumulator described on
|
|
65
|
+
* {@link withCostTracking}. Keys totals by `'<providerName>::<modelId>'`.
|
|
66
|
+
*
|
|
67
|
+
* @stable
|
|
68
|
+
*/
|
|
69
|
+
export function createCostAccumulator(): CostAccumulator {
|
|
70
|
+
const map = new Map<string, CostTrackingTotals>();
|
|
71
|
+
const keyOf = (providerName: string, modelId: string): string => `${providerName}::${modelId}`;
|
|
72
|
+
return {
|
|
73
|
+
onUsage: (info) => {
|
|
74
|
+
const key = keyOf(info.providerName, info.modelId);
|
|
75
|
+
const prev = map.get(key) ?? ZERO_TOTALS;
|
|
76
|
+
map.set(key, {
|
|
77
|
+
callCount: prev.callCount + 1,
|
|
78
|
+
promptTokens: prev.promptTokens + info.promptTokens,
|
|
79
|
+
completionTokens: prev.completionTokens + info.completionTokens,
|
|
80
|
+
totalTokens: prev.totalTokens + info.totalTokens,
|
|
81
|
+
cachedReadTokens: prev.cachedReadTokens + (info.cachedReadTokens ?? 0),
|
|
82
|
+
cacheWriteTokens: prev.cacheWriteTokens + (info.cacheWriteTokens ?? 0),
|
|
83
|
+
costUsd: prev.costUsd + info.costUsd,
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
totals: () => new Map(map),
|
|
87
|
+
totalFor: (providerName, modelId) => map.get(keyOf(providerName, modelId)) ?? ZERO_TOTALS,
|
|
88
|
+
reset: () => map.clear(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Options for {@link withCostTracking}.
|
|
94
|
+
*
|
|
95
|
+
* @stable
|
|
96
|
+
*/
|
|
97
|
+
export interface WithCostTrackingOptions {
|
|
98
|
+
/**
|
|
99
|
+
* Hook fired on every `finish` event with the parsed usage. The
|
|
100
|
+
* hook receives the underlying provider's name + modelId so the
|
|
101
|
+
* caller can route into a per-model accumulator.
|
|
102
|
+
*/
|
|
103
|
+
readonly onUsage?: (info: {
|
|
104
|
+
readonly providerName: string;
|
|
105
|
+
readonly modelId: string;
|
|
106
|
+
readonly promptTokens: number;
|
|
107
|
+
readonly completionTokens: number;
|
|
108
|
+
readonly totalTokens: number;
|
|
109
|
+
readonly cachedReadTokens?: number;
|
|
110
|
+
readonly cacheWriteTokens?: number;
|
|
111
|
+
readonly costUsd: number;
|
|
112
|
+
readonly metadata: Readonly<Record<string, unknown>> | undefined;
|
|
113
|
+
}) => void;
|
|
114
|
+
/**
|
|
115
|
+
* Optional pricing lookup. When set, the middleware computes
|
|
116
|
+
* `costUsd` from the returned price and surfaces it on the hook.
|
|
117
|
+
*
|
|
118
|
+
* `cachedReadPerMtok` / `cacheWritePerMtok` price the prompt-cache legs
|
|
119
|
+
* (core-provider-02); when omitted, cache tokens are billed at the full
|
|
120
|
+
* input rate (never cheaper than reality, so absent price data degrades
|
|
121
|
+
* to the pre-cache behaviour).
|
|
122
|
+
*/
|
|
123
|
+
readonly priceLookup?: (info: { readonly providerName: string; readonly modelId: string }) => {
|
|
124
|
+
readonly inputPerMtok?: number;
|
|
125
|
+
readonly outputPerMtok?: number;
|
|
126
|
+
readonly cachedReadPerMtok?: number;
|
|
127
|
+
readonly cacheWritePerMtok?: number;
|
|
128
|
+
} | null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @stable
|
|
133
|
+
*/
|
|
134
|
+
export const withCostTracking = defineProviderMiddleware<WithCostTrackingOptions>({
|
|
135
|
+
kind: 'withCostTracking',
|
|
136
|
+
factory: (opts: WithCostTrackingOptions) => {
|
|
137
|
+
return (next: Provider): Provider => ({
|
|
138
|
+
name: next.name,
|
|
139
|
+
modelId: next.modelId,
|
|
140
|
+
capabilities: next.capabilities,
|
|
141
|
+
...(next.acceptsSensitivity !== undefined
|
|
142
|
+
? { acceptsSensitivity: next.acceptsSensitivity }
|
|
143
|
+
: {}),
|
|
144
|
+
stream(req) {
|
|
145
|
+
return trackingStream(next, req, opts);
|
|
146
|
+
},
|
|
147
|
+
async generate(req) {
|
|
148
|
+
const result = await next.generate(req);
|
|
149
|
+
emitUsage(result, next, opts, asRecord(req.metadata));
|
|
150
|
+
return result;
|
|
151
|
+
},
|
|
152
|
+
...(next.countTokens ? { countTokens: next.countTokens.bind(next) } : {}),
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
async function* trackingStream(
|
|
158
|
+
next: Provider,
|
|
159
|
+
req: import('@graphorin/core').ProviderRequest,
|
|
160
|
+
opts: WithCostTrackingOptions,
|
|
161
|
+
): AsyncIterable<ProviderEvent> {
|
|
162
|
+
const metadata = asRecord(req.metadata);
|
|
163
|
+
for await (const event of next.stream(req)) {
|
|
164
|
+
yield event;
|
|
165
|
+
if (event.type === 'finish') {
|
|
166
|
+
emitUsage({ usage: event.usage, finishReason: event.finishReason }, next, opts, metadata);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
|
|
172
|
+
if (value === undefined || value === null) return undefined;
|
|
173
|
+
return value as Readonly<Record<string, unknown>>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function emitUsage(
|
|
177
|
+
result:
|
|
178
|
+
| ProviderResponse
|
|
179
|
+
| { usage: ProviderResponse['usage']; finishReason: ProviderResponse['finishReason'] },
|
|
180
|
+
next: Provider,
|
|
181
|
+
opts: WithCostTrackingOptions,
|
|
182
|
+
metadata: Readonly<Record<string, unknown>> | undefined,
|
|
183
|
+
): void {
|
|
184
|
+
if (opts.onUsage === undefined) return;
|
|
185
|
+
const usage = result.usage;
|
|
186
|
+
const price = opts.priceLookup?.({ providerName: next.name, modelId: next.modelId }) ?? null;
|
|
187
|
+
// Reasoning tokens are billed at the output rate (the PS-19 contract
|
|
188
|
+
// in @graphorin/pricing) - providers that report them separately from
|
|
189
|
+
// completionTokens must not get them for free.
|
|
190
|
+
// Cache legs (core-provider-02): promptTokens INCLUDES cache reads and
|
|
191
|
+
// writes, so subtract them from the base-rate leg and bill each at its
|
|
192
|
+
// own rate; a missing cache rate falls back to the full input rate.
|
|
193
|
+
const cachedRead = usage.cachedReadTokens ?? 0;
|
|
194
|
+
const cacheWrite = usage.cacheWriteTokens ?? 0;
|
|
195
|
+
const basePromptTokens = Math.max(0, usage.promptTokens - cachedRead - cacheWrite);
|
|
196
|
+
const inputRate = price?.inputPerMtok ?? 0;
|
|
197
|
+
const costUsd =
|
|
198
|
+
price !== null
|
|
199
|
+
? (basePromptTokens * inputRate +
|
|
200
|
+
cachedRead * (price.cachedReadPerMtok ?? inputRate) +
|
|
201
|
+
cacheWrite * (price.cacheWritePerMtok ?? inputRate) +
|
|
202
|
+
(usage.completionTokens + (usage.reasoningTokens ?? 0)) * (price.outputPerMtok ?? 0)) /
|
|
203
|
+
1_000_000
|
|
204
|
+
: (usage.cost?.amount ?? 0);
|
|
205
|
+
opts.onUsage({
|
|
206
|
+
providerName: next.name,
|
|
207
|
+
modelId: next.modelId,
|
|
208
|
+
promptTokens: usage.promptTokens,
|
|
209
|
+
completionTokens: usage.completionTokens,
|
|
210
|
+
totalTokens: usage.totalTokens,
|
|
211
|
+
...(usage.cachedReadTokens !== undefined ? { cachedReadTokens: usage.cachedReadTokens } : {}),
|
|
212
|
+
...(usage.cacheWriteTokens !== undefined ? { cacheWriteTokens: usage.cacheWriteTokens } : {}),
|
|
213
|
+
costUsd,
|
|
214
|
+
metadata,
|
|
215
|
+
});
|
|
216
|
+
}
|