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