autotel 3.2.0 → 3.3.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/dist/auto.cjs +2 -2
- package/dist/auto.js +1 -1
- package/dist/{chunk-ZKKJQS6R.js → chunk-32AXF4MA.js} +29 -7
- package/dist/chunk-32AXF4MA.js.map +1 -0
- package/dist/{chunk-RRTFFAG3.cjs → chunk-3MZJ7Y24.cjs} +5 -5
- package/dist/{chunk-RRTFFAG3.cjs.map → chunk-3MZJ7Y24.cjs.map} +1 -1
- package/dist/{chunk-5RZ3NZ2M.cjs → chunk-4RA6HIYF.cjs} +5 -5
- package/dist/{chunk-5RZ3NZ2M.cjs.map → chunk-4RA6HIYF.cjs.map} +1 -1
- package/dist/{chunk-NIDUQZIN.js → chunk-4TAQQZDU.js} +3 -3
- package/dist/{chunk-NIDUQZIN.js.map → chunk-4TAQQZDU.js.map} +1 -1
- package/dist/{chunk-UV64CWMA.cjs → chunk-DQSVSGK3.cjs} +13 -13
- package/dist/{chunk-UV64CWMA.cjs.map → chunk-DQSVSGK3.cjs.map} +1 -1
- package/dist/{chunk-IS2QJ44P.js → chunk-FZROHTZZ.js} +3 -3
- package/dist/{chunk-IS2QJ44P.js.map → chunk-FZROHTZZ.js.map} +1 -1
- package/dist/{chunk-4UUEGERM.cjs → chunk-MQH5OOZK.cjs} +17 -17
- package/dist/{chunk-4UUEGERM.cjs.map → chunk-MQH5OOZK.cjs.map} +1 -1
- package/dist/{chunk-QVLMGNQF.js → chunk-OACAWYLR.js} +4 -4
- package/dist/{chunk-QVLMGNQF.js.map → chunk-OACAWYLR.js.map} +1 -1
- package/dist/{chunk-RZI5XXAD.js → chunk-OPCTN527.js} +3 -3
- package/dist/{chunk-RZI5XXAD.js.map → chunk-OPCTN527.js.map} +1 -1
- package/dist/{chunk-NN2GODP4.cjs → chunk-QICFEFD6.cjs} +7 -7
- package/dist/{chunk-NN2GODP4.cjs.map → chunk-QICFEFD6.cjs.map} +1 -1
- package/dist/{chunk-KKIYPZOP.cjs → chunk-QJYWKAC5.cjs} +29 -7
- package/dist/chunk-QJYWKAC5.cjs.map +1 -0
- package/dist/{chunk-7EVW3Z37.js → chunk-TGV2XF57.js} +3 -3
- package/dist/{chunk-7EVW3Z37.js.map → chunk-TGV2XF57.js.map} +1 -1
- package/dist/{chunk-FVA2YDEQ.js → chunk-U4D5IBSB.js} +4 -4
- package/dist/{chunk-FVA2YDEQ.js.map → chunk-U4D5IBSB.js.map} +1 -1
- package/dist/{chunk-EEQHQKPP.cjs → chunk-U72TGONP.cjs} +32 -32
- package/dist/{chunk-EEQHQKPP.cjs.map → chunk-U72TGONP.cjs.map} +1 -1
- package/dist/correlation-id.cjs +10 -10
- package/dist/correlation-id.js +2 -2
- package/dist/decorators.cjs +4 -4
- package/dist/decorators.js +3 -3
- package/dist/event.cjs +6 -6
- package/dist/event.js +3 -3
- package/dist/functional.cjs +11 -11
- package/dist/functional.js +3 -3
- package/dist/http.cjs +3 -3
- package/dist/http.js +2 -2
- package/dist/index.cjs +215 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +185 -2
- package/dist/index.d.ts +185 -2
- package/dist/index.js +149 -12
- package/dist/index.js.map +1 -1
- package/dist/{init-BSyIyDs5.d.ts → init-DyE43paw.d.ts} +7 -2
- package/dist/{init-D9Bxx39e.d.cts → init-gyesUMwz.d.cts} +7 -2
- package/dist/instrumentation.cjs +8 -8
- package/dist/instrumentation.js +1 -1
- package/dist/messaging.cjs +7 -7
- package/dist/messaging.js +4 -4
- package/dist/semantic-helpers.cjs +8 -8
- package/dist/semantic-helpers.js +4 -4
- package/dist/webhook.cjs +5 -5
- package/dist/webhook.js +3 -3
- package/dist/workflow-distributed.cjs +5 -5
- package/dist/workflow-distributed.js +3 -3
- package/dist/workflow.cjs +8 -8
- package/dist/workflow.js +4 -4
- package/dist/yaml-config.d.cts +1 -1
- package/dist/yaml-config.d.ts +1 -1
- package/package.json +1 -1
- package/skills/build-audit-trails/SKILL.md +150 -5
- package/skills/build-audit-trails/references/audit-queries.md +73 -0
- package/skills/build-audit-trails/references/framework-wiring.md +187 -0
- package/skills/review-otel-patterns/SKILL.md +41 -0
- package/src/error-catalog.test.ts +128 -0
- package/src/error-catalog.ts +259 -0
- package/src/gen-ai-cost.test.ts +81 -0
- package/src/gen-ai-cost.ts +145 -0
- package/src/index.ts +29 -0
- package/src/init-auto-redactor.test.ts +53 -0
- package/src/init.ts +46 -7
- package/dist/chunk-KKIYPZOP.cjs.map +0 -1
- package/dist/chunk-ZKKJQS6R.js.map +0 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
defineErrorCatalog,
|
|
4
|
+
defineAuditCatalog,
|
|
5
|
+
isCatalogError,
|
|
6
|
+
getCatalogCode,
|
|
7
|
+
} from './error-catalog';
|
|
8
|
+
|
|
9
|
+
describe('defineErrorCatalog', () => {
|
|
10
|
+
const billing = defineErrorCatalog('billing', {
|
|
11
|
+
PAYMENT_DECLINED: {
|
|
12
|
+
status: 402,
|
|
13
|
+
message: 'Card declined',
|
|
14
|
+
why: 'The issuer rejected the charge',
|
|
15
|
+
fix: 'Try a different payment method',
|
|
16
|
+
link: 'https://docs.example.com/billing',
|
|
17
|
+
},
|
|
18
|
+
INSUFFICIENT_FUNDS: {
|
|
19
|
+
status: 402,
|
|
20
|
+
message: ({ available, required }: { available: number; required: number }) =>
|
|
21
|
+
`Insufficient funds: $${available} of $${required}`,
|
|
22
|
+
why: ({ required }: { available: number; required: number }) =>
|
|
23
|
+
`Needs $${required}`,
|
|
24
|
+
},
|
|
25
|
+
LEGACY: {
|
|
26
|
+
code: 'BILLING_LEGACY_42',
|
|
27
|
+
message: 'Legacy failure',
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('builds a structured error from a static entry', () => {
|
|
32
|
+
const err = billing.PAYMENT_DECLINED();
|
|
33
|
+
expect(err).toBeInstanceOf(Error);
|
|
34
|
+
expect(err.message).toBe('Card declined');
|
|
35
|
+
expect(err.status).toBe(402);
|
|
36
|
+
expect(err.why).toBe('The issuer rejected the charge');
|
|
37
|
+
expect(err.fix).toBe('Try a different payment method');
|
|
38
|
+
expect(err.link).toBe('https://docs.example.com/billing');
|
|
39
|
+
expect(err.code).toBe('billing.PAYMENT_DECLINED');
|
|
40
|
+
expect(err.name).toBe('PAYMENT_DECLINED');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('interpolates typed params in message and why', () => {
|
|
44
|
+
const err = billing.INSUFFICIENT_FUNDS({ available: 5, required: 100 });
|
|
45
|
+
expect(err.message).toBe('Insufficient funds: $5 of $100');
|
|
46
|
+
expect(err.why).toBe('Needs $100');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('honors a custom code', () => {
|
|
50
|
+
const err = billing.LEGACY();
|
|
51
|
+
expect(err.code).toBe('BILLING_LEGACY_42');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('exposes the code on the builder', () => {
|
|
55
|
+
expect(billing.PAYMENT_DECLINED.code).toBe('billing.PAYMENT_DECLINED');
|
|
56
|
+
expect(billing.LEGACY.code).toBe('BILLING_LEGACY_42');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('matches its own errors and rejects others', () => {
|
|
60
|
+
const declined = billing.PAYMENT_DECLINED();
|
|
61
|
+
const funds = billing.INSUFFICIENT_FUNDS({ available: 1, required: 2 });
|
|
62
|
+
expect(billing.PAYMENT_DECLINED.match(declined)).toBe(true);
|
|
63
|
+
expect(billing.PAYMENT_DECLINED.match(funds)).toBe(false);
|
|
64
|
+
expect(billing.PAYMENT_DECLINED.match(new Error('nope'))).toBe(false);
|
|
65
|
+
expect(billing.PAYMENT_DECLINED.match(null)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('tags errors so isCatalogError / getCatalogCode work', () => {
|
|
69
|
+
const err = billing.PAYMENT_DECLINED();
|
|
70
|
+
expect(isCatalogError(err)).toBe(true);
|
|
71
|
+
expect(isCatalogError(new Error('plain'))).toBe(false);
|
|
72
|
+
expect(getCatalogCode(err)).toBe('billing.PAYMENT_DECLINED');
|
|
73
|
+
expect(getCatalogCode(new Error('plain'))).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('passes cause, details, and internal through build options', () => {
|
|
77
|
+
const cause = new Error('stripe boom');
|
|
78
|
+
const err = billing.PAYMENT_DECLINED({
|
|
79
|
+
cause,
|
|
80
|
+
details: { attempt: 2 },
|
|
81
|
+
internal: { stripeId: 'ch_1' },
|
|
82
|
+
});
|
|
83
|
+
expect(err.cause).toBe(cause);
|
|
84
|
+
expect(err.details).toEqual({ attempt: 2 });
|
|
85
|
+
expect(err.internal).toEqual({ stripeId: 'ch_1' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('accepts options as the second arg for param entries', () => {
|
|
89
|
+
const cause = new Error('root');
|
|
90
|
+
const err = billing.INSUFFICIENT_FUNDS(
|
|
91
|
+
{ available: 5, required: 100 },
|
|
92
|
+
{ cause },
|
|
93
|
+
);
|
|
94
|
+
expect(err.message).toBe('Insufficient funds: $5 of $100');
|
|
95
|
+
expect(err.cause).toBe(cause);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('defineAuditCatalog', () => {
|
|
100
|
+
const audit = defineAuditCatalog('user', {
|
|
101
|
+
LOGIN: { message: 'User logged in' },
|
|
102
|
+
ROLE_CHANGED: {
|
|
103
|
+
severity: 'critical',
|
|
104
|
+
message: ({ role }: { role: string }) => `Role set to ${role}`,
|
|
105
|
+
},
|
|
106
|
+
DELETED: { action: 'user.account.deleted', severity: 'warn' },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('produces typed action descriptors with defaults', () => {
|
|
110
|
+
const action = audit.LOGIN();
|
|
111
|
+
expect(action.action).toBe('user.LOGIN');
|
|
112
|
+
expect(action.severity).toBe('info');
|
|
113
|
+
expect(action.message).toBe('User logged in');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('interpolates params and respects severity', () => {
|
|
117
|
+
const action = audit.ROLE_CHANGED({ role: 'admin' });
|
|
118
|
+
expect(action.action).toBe('user.ROLE_CHANGED');
|
|
119
|
+
expect(action.severity).toBe('critical');
|
|
120
|
+
expect(action.message).toBe('Role set to admin');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('honors a custom action name', () => {
|
|
124
|
+
expect(audit.DELETED.action).toBe('user.account.deleted');
|
|
125
|
+
expect(audit.DELETED.severity).toBe('warn');
|
|
126
|
+
expect(audit.DELETED().message).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error and audit catalogs.
|
|
3
|
+
*
|
|
4
|
+
* Group related errors into one catalog and get a refactor-safe builder per
|
|
5
|
+
* code, with autocomplete at every call site and typed message parameters.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { defineErrorCatalog } from 'autotel';
|
|
10
|
+
*
|
|
11
|
+
* export const billing = defineErrorCatalog('billing', {
|
|
12
|
+
* PAYMENT_DECLINED: {
|
|
13
|
+
* status: 402,
|
|
14
|
+
* message: 'Card declined',
|
|
15
|
+
* why: 'The issuer rejected the charge',
|
|
16
|
+
* fix: 'Try a different payment method',
|
|
17
|
+
* },
|
|
18
|
+
* INSUFFICIENT_FUNDS: {
|
|
19
|
+
* status: 402,
|
|
20
|
+
* message: ({ available, required }: { available: number; required: number }) =>
|
|
21
|
+
* `Insufficient funds: $${available} of $${required}`,
|
|
22
|
+
* },
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* throw billing.PAYMENT_DECLINED({ cause: stripeError });
|
|
26
|
+
* throw billing.INSUFFICIENT_FUNDS({ available: 5, required: 100 });
|
|
27
|
+
*
|
|
28
|
+
* // In a catch block — refactor-safe, no magic strings:
|
|
29
|
+
* if (billing.PAYMENT_DECLINED.match(err)) { ... }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { createStructuredError, type StructuredError } from './structured-error';
|
|
34
|
+
|
|
35
|
+
const catalogCodeKey = Symbol.for('autotel.catalog.code');
|
|
36
|
+
|
|
37
|
+
/** Definition of a single error in a catalog. */
|
|
38
|
+
export interface ErrorCatalogEntry {
|
|
39
|
+
/**
|
|
40
|
+
* Human-readable message. Use a function to interpolate typed parameters;
|
|
41
|
+
* the parameter type flows through to the call site.
|
|
42
|
+
*/
|
|
43
|
+
message: string | ((params: never) => string);
|
|
44
|
+
/** HTTP status to surface to clients. */
|
|
45
|
+
status?: number;
|
|
46
|
+
/** Stable error code. Defaults to `${namespace}.${KEY}`. */
|
|
47
|
+
code?: string | number;
|
|
48
|
+
/** Why it happened. A function receives the same params as `message`. */
|
|
49
|
+
why?: string | ((params: never) => string);
|
|
50
|
+
/** What the caller should do next. */
|
|
51
|
+
fix?: string;
|
|
52
|
+
/** Docs or runbook link. */
|
|
53
|
+
link?: string;
|
|
54
|
+
/** Error name. Defaults to the catalog key. */
|
|
55
|
+
name?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Per-call options passed alongside (or instead of) typed params. */
|
|
59
|
+
export interface ErrorBuildOptions {
|
|
60
|
+
cause?: unknown;
|
|
61
|
+
details?: Record<string, unknown>;
|
|
62
|
+
/** Backend-only context. Never serialized to clients. */
|
|
63
|
+
internal?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ParamsOf<E> = E extends { message: (params: infer P) => string }
|
|
67
|
+
? P
|
|
68
|
+
: E extends { why: (params: infer P) => string }
|
|
69
|
+
? P
|
|
70
|
+
: void;
|
|
71
|
+
|
|
72
|
+
type BuilderArgs<E extends ErrorCatalogEntry> = ParamsOf<E> extends void
|
|
73
|
+
? [options?: ErrorBuildOptions]
|
|
74
|
+
: [params: ParamsOf<E>, options?: ErrorBuildOptions];
|
|
75
|
+
|
|
76
|
+
/** A callable error factory produced by {@link defineErrorCatalog}. */
|
|
77
|
+
export interface ErrorBuilder<E extends ErrorCatalogEntry> {
|
|
78
|
+
(...args: BuilderArgs<E>): StructuredError;
|
|
79
|
+
/** Stable code assigned to every error from this entry. */
|
|
80
|
+
readonly code: string | number;
|
|
81
|
+
/** True when `error` was produced by this catalog entry. */
|
|
82
|
+
match(error: unknown): boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type ErrorCatalog<T extends Record<string, ErrorCatalogEntry>> = {
|
|
86
|
+
readonly [K in keyof T]: ErrorBuilder<T[K]>;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function readCatalogCode(error: unknown): string | number | undefined {
|
|
90
|
+
if (error === null || typeof error !== 'object') return undefined;
|
|
91
|
+
return (error as Record<symbol, unknown>)[catalogCodeKey] as
|
|
92
|
+
| string
|
|
93
|
+
| number
|
|
94
|
+
| undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True when `error` was produced by any autotel error catalog. */
|
|
98
|
+
export function isCatalogError(error: unknown): error is StructuredError {
|
|
99
|
+
return readCatalogCode(error) !== undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Returns the catalog code of `error`, or `undefined` if it has none. */
|
|
103
|
+
export function getCatalogCode(error: unknown): string | number | undefined {
|
|
104
|
+
return readCatalogCode(error);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Define a typed error catalog. Returns an object whose keys are error
|
|
109
|
+
* builders. Each builder produces a {@link StructuredError} carrying the
|
|
110
|
+
* entry's message, status, code, why, fix, and link.
|
|
111
|
+
*/
|
|
112
|
+
export function defineErrorCatalog<
|
|
113
|
+
const T extends Record<string, ErrorCatalogEntry>,
|
|
114
|
+
>(namespace: string, entries: T): ErrorCatalog<T> {
|
|
115
|
+
const catalog: Record<string, ErrorBuilder<ErrorCatalogEntry>> = {};
|
|
116
|
+
|
|
117
|
+
for (const [key, entry] of Object.entries(entries) as [
|
|
118
|
+
string,
|
|
119
|
+
ErrorCatalogEntry,
|
|
120
|
+
][]) {
|
|
121
|
+
const code = entry.code ?? `${namespace}.${key}`;
|
|
122
|
+
const usesParams =
|
|
123
|
+
typeof entry.message === 'function' || typeof entry.why === 'function';
|
|
124
|
+
|
|
125
|
+
const builder = ((
|
|
126
|
+
paramsOrOptions?: unknown,
|
|
127
|
+
maybeOptions?: ErrorBuildOptions,
|
|
128
|
+
): StructuredError => {
|
|
129
|
+
const params = usesParams ? paramsOrOptions : undefined;
|
|
130
|
+
const options = (
|
|
131
|
+
usesParams ? maybeOptions : paramsOrOptions
|
|
132
|
+
) as ErrorBuildOptions | undefined;
|
|
133
|
+
|
|
134
|
+
const message =
|
|
135
|
+
typeof entry.message === 'function'
|
|
136
|
+
? (entry.message as (p: unknown) => string)(params)
|
|
137
|
+
: entry.message;
|
|
138
|
+
const why =
|
|
139
|
+
typeof entry.why === 'function'
|
|
140
|
+
? (entry.why as (p: unknown) => string)(params)
|
|
141
|
+
: entry.why;
|
|
142
|
+
|
|
143
|
+
const error = createStructuredError({
|
|
144
|
+
message,
|
|
145
|
+
name: entry.name ?? key,
|
|
146
|
+
code,
|
|
147
|
+
...(entry.status === undefined ? {} : { status: entry.status }),
|
|
148
|
+
...(why === undefined ? {} : { why }),
|
|
149
|
+
...(entry.fix === undefined ? {} : { fix: entry.fix }),
|
|
150
|
+
...(entry.link === undefined ? {} : { link: entry.link }),
|
|
151
|
+
...(options?.cause === undefined ? {} : { cause: options.cause }),
|
|
152
|
+
...(options?.details === undefined ? {} : { details: options.details }),
|
|
153
|
+
...(options?.internal === undefined
|
|
154
|
+
? {}
|
|
155
|
+
: { internal: options.internal }),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
Object.defineProperty(error, catalogCodeKey, {
|
|
159
|
+
value: code,
|
|
160
|
+
enumerable: false,
|
|
161
|
+
writable: false,
|
|
162
|
+
configurable: true,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return error;
|
|
166
|
+
}) as ErrorBuilder<ErrorCatalogEntry>;
|
|
167
|
+
|
|
168
|
+
Object.defineProperty(builder, 'code', {
|
|
169
|
+
value: code,
|
|
170
|
+
enumerable: true,
|
|
171
|
+
});
|
|
172
|
+
Object.defineProperty(builder, 'match', {
|
|
173
|
+
value: (error: unknown): boolean => readCatalogCode(error) === code,
|
|
174
|
+
enumerable: false,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
catalog[key] = builder;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return Object.freeze(catalog) as ErrorCatalog<T>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Severity of an audit action. */
|
|
184
|
+
export type AuditSeverity = 'info' | 'warn' | 'critical';
|
|
185
|
+
|
|
186
|
+
/** Definition of a single action in an audit catalog. */
|
|
187
|
+
export interface AuditCatalogEntry {
|
|
188
|
+
/** Human-readable description. Use a function for typed params. */
|
|
189
|
+
message?: string | ((params: never) => string);
|
|
190
|
+
/** Stable action name. Defaults to `${namespace}.${KEY}`. */
|
|
191
|
+
action?: string;
|
|
192
|
+
/** Severity of the action. Defaults to `'info'`. */
|
|
193
|
+
severity?: AuditSeverity;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A resolved audit action descriptor produced by an audit catalog. */
|
|
197
|
+
export interface AuditAction {
|
|
198
|
+
readonly action: string;
|
|
199
|
+
readonly severity: AuditSeverity;
|
|
200
|
+
readonly message?: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type AuditDescriptorArgs<E extends AuditCatalogEntry> = ParamsOf<E> extends void
|
|
204
|
+
? []
|
|
205
|
+
: [params: ParamsOf<E>];
|
|
206
|
+
|
|
207
|
+
/** A callable audit-action descriptor produced by {@link defineAuditCatalog}. */
|
|
208
|
+
export interface AuditDescriptor<E extends AuditCatalogEntry> {
|
|
209
|
+
(...args: AuditDescriptorArgs<E>): AuditAction;
|
|
210
|
+
readonly action: string;
|
|
211
|
+
readonly severity: AuditSeverity;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export type AuditCatalog<T extends Record<string, AuditCatalogEntry>> = {
|
|
215
|
+
readonly [K in keyof T]: AuditDescriptor<T[K]>;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Define a typed audit catalog. Returns typed action descriptors you can pass
|
|
220
|
+
* to `track()` or audit helpers without scattering magic strings.
|
|
221
|
+
*/
|
|
222
|
+
export function defineAuditCatalog<
|
|
223
|
+
const T extends Record<string, AuditCatalogEntry>,
|
|
224
|
+
>(namespace: string, entries: T): AuditCatalog<T> {
|
|
225
|
+
const catalog: Record<string, AuditDescriptor<AuditCatalogEntry>> = {};
|
|
226
|
+
|
|
227
|
+
for (const [key, entry] of Object.entries(entries) as [
|
|
228
|
+
string,
|
|
229
|
+
AuditCatalogEntry,
|
|
230
|
+
][]) {
|
|
231
|
+
const action = entry.action ?? `${namespace}.${key}`;
|
|
232
|
+
const severity: AuditSeverity = entry.severity ?? 'info';
|
|
233
|
+
|
|
234
|
+
const descriptor = ((params?: unknown): AuditAction => {
|
|
235
|
+
const message =
|
|
236
|
+
typeof entry.message === 'function'
|
|
237
|
+
? (entry.message as (p: unknown) => string)(params)
|
|
238
|
+
: entry.message;
|
|
239
|
+
return Object.freeze({
|
|
240
|
+
action,
|
|
241
|
+
severity,
|
|
242
|
+
...(message === undefined ? {} : { message }),
|
|
243
|
+
});
|
|
244
|
+
}) as AuditDescriptor<AuditCatalogEntry>;
|
|
245
|
+
|
|
246
|
+
Object.defineProperty(descriptor, 'action', {
|
|
247
|
+
value: action,
|
|
248
|
+
enumerable: true,
|
|
249
|
+
});
|
|
250
|
+
Object.defineProperty(descriptor, 'severity', {
|
|
251
|
+
value: severity,
|
|
252
|
+
enumerable: true,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
catalog[key] = descriptor;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return Object.freeze(catalog) as AuditCatalog<T>;
|
|
259
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
estimateLLMCost,
|
|
4
|
+
recordLLMCost,
|
|
5
|
+
GEN_AI_COST_ATTRIBUTE,
|
|
6
|
+
} from './gen-ai-cost';
|
|
7
|
+
|
|
8
|
+
describe('estimateLLMCost', () => {
|
|
9
|
+
it('estimates cost from input and output tokens', () => {
|
|
10
|
+
// claude-sonnet-4: $3 / 1M in, $15 / 1M out
|
|
11
|
+
const cost = estimateLLMCost('claude-sonnet-4', {
|
|
12
|
+
inputTokens: 1_000_000,
|
|
13
|
+
outputTokens: 1_000_000,
|
|
14
|
+
});
|
|
15
|
+
expect(cost).toBe(18);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('matches versioned model ids by longest prefix', () => {
|
|
19
|
+
const cost = estimateLLMCost('claude-sonnet-4-6-20251101', {
|
|
20
|
+
inputTokens: 1_000_000,
|
|
21
|
+
outputTokens: 0,
|
|
22
|
+
});
|
|
23
|
+
expect(cost).toBe(3);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns undefined for an unknown model', () => {
|
|
27
|
+
expect(
|
|
28
|
+
estimateLLMCost('totally-made-up', { inputTokens: 1000 }),
|
|
29
|
+
).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('bills cached input tokens at the cached rate', () => {
|
|
33
|
+
const pricing = {
|
|
34
|
+
custom: { inputPer1M: 10, outputPer1M: 30, cachedInputPer1M: 1 },
|
|
35
|
+
};
|
|
36
|
+
// 1M input, of which 800k cached: 200k @ $10/M + 800k @ $1/M = 2 + 0.8
|
|
37
|
+
const cost = estimateLLMCost(
|
|
38
|
+
'custom',
|
|
39
|
+
{ inputTokens: 1_000_000, cachedInputTokens: 800_000 },
|
|
40
|
+
{ pricing },
|
|
41
|
+
);
|
|
42
|
+
expect(cost).toBeCloseTo(2.8, 6);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('accepts a pricing override and extends the table', () => {
|
|
46
|
+
const cost = estimateLLMCost(
|
|
47
|
+
'my-model',
|
|
48
|
+
{ inputTokens: 500_000, outputTokens: 500_000 },
|
|
49
|
+
{ pricing: { 'my-model': { inputPer1M: 4, outputPer1M: 8 } } },
|
|
50
|
+
);
|
|
51
|
+
expect(cost).toBe(6);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('handles partial usage without throwing', () => {
|
|
55
|
+
expect(estimateLLMCost('gpt-4o-mini', {})).toBe(0);
|
|
56
|
+
expect(estimateLLMCost('gpt-4o-mini', { outputTokens: 1_000_000 })).toBe(
|
|
57
|
+
0.6,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('recordLLMCost', () => {
|
|
63
|
+
it('sets the cost attribute on the context for a known model', () => {
|
|
64
|
+
const setAttribute = vi.fn();
|
|
65
|
+
const cost = recordLLMCost({ setAttribute }, 'gpt-4o', {
|
|
66
|
+
inputTokens: 1_000_000,
|
|
67
|
+
outputTokens: 0,
|
|
68
|
+
});
|
|
69
|
+
expect(cost).toBe(2.5);
|
|
70
|
+
expect(setAttribute).toHaveBeenCalledWith(GEN_AI_COST_ATTRIBUTE, 2.5);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('sets no attribute for an unknown model', () => {
|
|
74
|
+
const setAttribute = vi.fn();
|
|
75
|
+
const cost = recordLLMCost({ setAttribute }, 'unknown-model', {
|
|
76
|
+
inputTokens: 100,
|
|
77
|
+
});
|
|
78
|
+
expect(cost).toBeUndefined();
|
|
79
|
+
expect(setAttribute).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-model LLM cost estimation.
|
|
3
|
+
*
|
|
4
|
+
* Estimate the USD cost of an LLM call from its token usage and record it as a
|
|
5
|
+
* span attribute (`gen_ai.usage.cost.usd`). Pair with the
|
|
6
|
+
* `gen_ai.client.cost.usd` metric bucket advice in `gen-ai-metrics`.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { trace, recordLLMCost } from 'autotel';
|
|
11
|
+
*
|
|
12
|
+
* export const chat = trace((ctx) => async (prompt: string) => {
|
|
13
|
+
* const res = await client.messages.create({ model, ... });
|
|
14
|
+
* recordLLMCost(ctx, model, {
|
|
15
|
+
* inputTokens: res.usage.input_tokens,
|
|
16
|
+
* outputTokens: res.usage.output_tokens,
|
|
17
|
+
* });
|
|
18
|
+
* return res;
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { TraceContext } from './trace-context';
|
|
24
|
+
|
|
25
|
+
/** Span attribute key autotel sets for an estimated call cost. */
|
|
26
|
+
export const GEN_AI_COST_ATTRIBUTE = 'gen_ai.usage.cost.usd';
|
|
27
|
+
|
|
28
|
+
/** Pricing for a single model, in USD per 1,000,000 tokens. */
|
|
29
|
+
export interface ModelPricing {
|
|
30
|
+
/** USD per 1M input (prompt) tokens. */
|
|
31
|
+
inputPer1M: number;
|
|
32
|
+
/** USD per 1M output (completion) tokens. */
|
|
33
|
+
outputPer1M: number;
|
|
34
|
+
/** USD per 1M cached input tokens. Defaults to {@link ModelPricing.inputPer1M}. */
|
|
35
|
+
cachedInputPer1M?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Token counts for a single LLM call. */
|
|
39
|
+
export interface TokenUsage {
|
|
40
|
+
inputTokens?: number;
|
|
41
|
+
outputTokens?: number;
|
|
42
|
+
/** Cached input tokens, billed at {@link ModelPricing.cachedInputPer1M}. */
|
|
43
|
+
cachedInputTokens?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface EstimateCostOptions {
|
|
47
|
+
/** Override or extend {@link MODEL_PRICING}. Keys are matched first. */
|
|
48
|
+
pricing?: Record<string, ModelPricing>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Approximate public list prices (USD per 1M tokens) at the time of writing.
|
|
53
|
+
* Prices change; treat these as convenience defaults, not a billing source of
|
|
54
|
+
* truth. Override per call via `options.pricing` or mutate this table at init.
|
|
55
|
+
* Matching is exact first, then by longest key prefix, so versioned model ids
|
|
56
|
+
* (`claude-sonnet-4-6-20251101`) resolve to a base entry (`claude-sonnet-4-6`).
|
|
57
|
+
*/
|
|
58
|
+
export const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
59
|
+
// OpenAI
|
|
60
|
+
'gpt-4o': { inputPer1M: 2.5, outputPer1M: 10 },
|
|
61
|
+
'gpt-4o-mini': { inputPer1M: 0.15, outputPer1M: 0.6 },
|
|
62
|
+
'gpt-4.1': { inputPer1M: 2, outputPer1M: 8 },
|
|
63
|
+
'gpt-4.1-mini': { inputPer1M: 0.4, outputPer1M: 1.6 },
|
|
64
|
+
'gpt-4.1-nano': { inputPer1M: 0.1, outputPer1M: 0.4 },
|
|
65
|
+
'o3-mini': { inputPer1M: 1.1, outputPer1M: 4.4 },
|
|
66
|
+
// Anthropic Claude
|
|
67
|
+
'claude-opus-4': { inputPer1M: 15, outputPer1M: 75 },
|
|
68
|
+
'claude-sonnet-4': { inputPer1M: 3, outputPer1M: 15 },
|
|
69
|
+
'claude-3-5-sonnet': { inputPer1M: 3, outputPer1M: 15 },
|
|
70
|
+
'claude-3-5-haiku': { inputPer1M: 0.8, outputPer1M: 4 },
|
|
71
|
+
'claude-3-opus': { inputPer1M: 15, outputPer1M: 75 },
|
|
72
|
+
'claude-3-haiku': { inputPer1M: 0.25, outputPer1M: 1.25 },
|
|
73
|
+
// Google Gemini
|
|
74
|
+
'gemini-1.5-pro': { inputPer1M: 1.25, outputPer1M: 5 },
|
|
75
|
+
'gemini-1.5-flash': { inputPer1M: 0.075, outputPer1M: 0.3 },
|
|
76
|
+
'gemini-2.0-flash': { inputPer1M: 0.1, outputPer1M: 0.4 },
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function resolvePricing(
|
|
80
|
+
table: Record<string, ModelPricing>,
|
|
81
|
+
model: string,
|
|
82
|
+
): ModelPricing | undefined {
|
|
83
|
+
const exact = table[model];
|
|
84
|
+
if (exact) return exact;
|
|
85
|
+
|
|
86
|
+
let best: ModelPricing | undefined;
|
|
87
|
+
let bestLength = 0;
|
|
88
|
+
for (const key of Object.keys(table)) {
|
|
89
|
+
if (model.startsWith(key) && key.length > bestLength) {
|
|
90
|
+
best = table[key];
|
|
91
|
+
bestLength = key.length;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return best;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function round(value: number): number {
|
|
98
|
+
return Math.round(value * 1e6) / 1e6;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Estimate the USD cost of an LLM call. Returns `undefined` when the model has
|
|
103
|
+
* no known pricing (supply one via `options.pricing`).
|
|
104
|
+
*/
|
|
105
|
+
export function estimateLLMCost(
|
|
106
|
+
model: string,
|
|
107
|
+
usage: TokenUsage,
|
|
108
|
+
options?: EstimateCostOptions,
|
|
109
|
+
): number | undefined {
|
|
110
|
+
const table = options?.pricing
|
|
111
|
+
? { ...MODEL_PRICING, ...options.pricing }
|
|
112
|
+
: MODEL_PRICING;
|
|
113
|
+
const price = resolvePricing(table, model);
|
|
114
|
+
if (!price) return undefined;
|
|
115
|
+
|
|
116
|
+
const cachedInput = usage.cachedInputTokens ?? 0;
|
|
117
|
+
const billedInput = Math.max(0, (usage.inputTokens ?? 0) - cachedInput);
|
|
118
|
+
const output = usage.outputTokens ?? 0;
|
|
119
|
+
const cachedRate = price.cachedInputPer1M ?? price.inputPer1M;
|
|
120
|
+
|
|
121
|
+
const cost =
|
|
122
|
+
(billedInput / 1_000_000) * price.inputPer1M +
|
|
123
|
+
(cachedInput / 1_000_000) * cachedRate +
|
|
124
|
+
(output / 1_000_000) * price.outputPer1M;
|
|
125
|
+
|
|
126
|
+
return round(cost);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Estimate cost and record it on `ctx` as the `gen_ai.usage.cost.usd` span
|
|
131
|
+
* attribute. Returns the estimated cost, or `undefined` when the model is
|
|
132
|
+
* unknown (in which case no attribute is set).
|
|
133
|
+
*/
|
|
134
|
+
export function recordLLMCost(
|
|
135
|
+
ctx: Pick<TraceContext, 'setAttribute'>,
|
|
136
|
+
model: string,
|
|
137
|
+
usage: TokenUsage,
|
|
138
|
+
options?: EstimateCostOptions,
|
|
139
|
+
): number | undefined {
|
|
140
|
+
const cost = estimateLLMCost(model, usage, options);
|
|
141
|
+
if (cost !== undefined) {
|
|
142
|
+
ctx.setAttribute(GEN_AI_COST_ATTRIBUTE, cost);
|
|
143
|
+
}
|
|
144
|
+
return cost;
|
|
145
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -151,6 +151,23 @@ export {
|
|
|
151
151
|
// parseError
|
|
152
152
|
export { parseError, type ParsedError } from './parse-error';
|
|
153
153
|
|
|
154
|
+
// Typed error + audit catalogs
|
|
155
|
+
export {
|
|
156
|
+
defineErrorCatalog,
|
|
157
|
+
defineAuditCatalog,
|
|
158
|
+
isCatalogError,
|
|
159
|
+
getCatalogCode,
|
|
160
|
+
type ErrorCatalog,
|
|
161
|
+
type ErrorCatalogEntry,
|
|
162
|
+
type ErrorBuilder,
|
|
163
|
+
type ErrorBuildOptions,
|
|
164
|
+
type AuditCatalog,
|
|
165
|
+
type AuditCatalogEntry,
|
|
166
|
+
type AuditDescriptor,
|
|
167
|
+
type AuditAction,
|
|
168
|
+
type AuditSeverity,
|
|
169
|
+
} from './error-catalog';
|
|
170
|
+
|
|
154
171
|
// Attribute flattening
|
|
155
172
|
export { toAttributeValue, flattenToAttributes } from './flatten-attributes';
|
|
156
173
|
|
|
@@ -241,6 +258,18 @@ export {
|
|
|
241
258
|
type StreamFirstTokenEvent,
|
|
242
259
|
} from './gen-ai-events';
|
|
243
260
|
|
|
261
|
+
// Per-model LLM cost estimation — estimate USD cost from token usage and
|
|
262
|
+
// record it as the gen_ai.usage.cost.usd span attribute.
|
|
263
|
+
export {
|
|
264
|
+
estimateLLMCost,
|
|
265
|
+
recordLLMCost,
|
|
266
|
+
MODEL_PRICING,
|
|
267
|
+
GEN_AI_COST_ATTRIBUTE,
|
|
268
|
+
type ModelPricing,
|
|
269
|
+
type TokenUsage,
|
|
270
|
+
type EstimateCostOptions,
|
|
271
|
+
} from './gen-ai-cost';
|
|
272
|
+
|
|
244
273
|
// Tracer helpers for custom spans
|
|
245
274
|
export {
|
|
246
275
|
getTracer,
|