@graphorin/pricing 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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * W-097: pure converter from the `@pydantic/genai-prices` published
3
+ * dataset into graphorin-native {@link ModelPrice} entries, so
4
+ * `graphorin pricing refresh --url <genai-prices raw URL>` actually
5
+ * works - previously the docs pointed operators at that dataset while
6
+ * `refreshPricing` only accepted the native shape, leaving every
7
+ * operator to write their own transformer.
8
+ *
9
+ * Supported subset (documented deliberately - the converter is
10
+ * tolerant and SKIPS what it cannot represent, it never throws on a
11
+ * strange model entry):
12
+ *
13
+ * ```jsonc
14
+ * {
15
+ * "providers": [
16
+ * {
17
+ * "id": "anthropic", // or "provider_id" / "name"
18
+ * "models": [
19
+ * {
20
+ * "id": "claude-sonnet-4-5", // or "model" / "name"
21
+ * "prices": {
22
+ * "input_mtok": 3, // USD per million tokens
23
+ * "output_mtok": 15,
24
+ * "cache_read_mtok": 0.3, // optional
25
+ * "cache_write_mtok": 3.75 // optional
26
+ * }
27
+ * }
28
+ * ]
29
+ * }
30
+ * ]
31
+ * }
32
+ * ```
33
+ *
34
+ * Per-Mtok figures divide by 1e6 into per-token USD. Model entries
35
+ * whose `prices` is an ARRAY (conditional / time-tiered pricing) are
36
+ * used only when the array holds exactly one usable record; everything
37
+ * else - tiered objects, missing input/output figures - lands in the
38
+ * `skipped` counter instead of failing the refresh. The dataset is
39
+ * dollar-denominated; the currency guard lives in `refreshPricing`.
40
+ *
41
+ * @packageDocumentation
42
+ */
43
+
44
+ import type { ModelPrice } from './types.js';
45
+
46
+ const MTOK = 1_000_000;
47
+
48
+ /** Result of {@link convertGenaiPrices}. @stable */
49
+ export interface GenaiPricesConversion {
50
+ readonly entries: ReadonlyArray<ModelPrice>;
51
+ /** Model entries the supported subset could not represent. */
52
+ readonly skipped: number;
53
+ }
54
+
55
+ /**
56
+ * Cheap structural detector: does this body look like the
57
+ * genai-prices dataset (a `providers` array of objects carrying
58
+ * `models` arrays)?
59
+ *
60
+ * @stable
61
+ */
62
+ export function isGenaiPricesShape(body: unknown): boolean {
63
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) return false;
64
+ const providers = (body as { providers?: unknown }).providers;
65
+ if (!Array.isArray(providers) || providers.length === 0) return false;
66
+ return providers.every(
67
+ (p) => typeof p === 'object' && p !== null && Array.isArray((p as { models?: unknown }).models),
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Convert a genai-prices dataset body. Tolerant: unrepresentable model
73
+ * entries are counted in `skipped`, never thrown on.
74
+ *
75
+ * @stable
76
+ */
77
+ export function convertGenaiPrices(body: unknown): GenaiPricesConversion {
78
+ if (!isGenaiPricesShape(body)) {
79
+ throw new Error('convertGenaiPrices: body does not match the genai-prices dataset shape');
80
+ }
81
+ const providers = (body as { providers: ReadonlyArray<unknown> }).providers;
82
+ const entries: ModelPrice[] = [];
83
+ let skipped = 0;
84
+ for (const rawProvider of providers) {
85
+ const provider = rawProvider as {
86
+ id?: unknown;
87
+ provider_id?: unknown;
88
+ name?: unknown;
89
+ models: ReadonlyArray<unknown>;
90
+ };
91
+ const providerId = firstString(provider.id, provider.provider_id, provider.name);
92
+ if (providerId === undefined) {
93
+ skipped += provider.models.length;
94
+ continue;
95
+ }
96
+ for (const rawModel of provider.models) {
97
+ const entry = convertModel(providerId, rawModel);
98
+ if (entry === null) skipped += 1;
99
+ else entries.push(entry);
100
+ }
101
+ }
102
+ return Object.freeze({ entries: Object.freeze(entries), skipped });
103
+ }
104
+
105
+ function convertModel(providerId: string, rawModel: unknown): ModelPrice | null {
106
+ if (typeof rawModel !== 'object' || rawModel === null) return null;
107
+ const model = rawModel as {
108
+ id?: unknown;
109
+ model?: unknown;
110
+ name?: unknown;
111
+ prices?: unknown;
112
+ };
113
+ const modelId = firstString(model.id, model.model, model.name);
114
+ if (modelId === undefined) return null;
115
+ const prices = selectPriceRecord(model.prices);
116
+ if (prices === null) return null;
117
+ const input = perToken(prices.input_mtok);
118
+ const output = perToken(prices.output_mtok);
119
+ if (input === undefined || output === undefined) return null;
120
+ const cacheRead = perToken(prices.cache_read_mtok);
121
+ const cacheWrite = perToken(prices.cache_write_mtok);
122
+ return Object.freeze({
123
+ provider: providerId.toLowerCase(),
124
+ model: modelId.toLowerCase(),
125
+ inputUsdPerToken: input,
126
+ outputUsdPerToken: output,
127
+ ...(cacheRead !== undefined ? { cachedReadUsdPerToken: cacheRead } : {}),
128
+ ...(cacheWrite !== undefined ? { cacheWriteUsdPerToken: cacheWrite } : {}),
129
+ });
130
+ }
131
+
132
+ interface GenaiPriceRecord {
133
+ readonly input_mtok?: unknown;
134
+ readonly output_mtok?: unknown;
135
+ readonly cache_read_mtok?: unknown;
136
+ readonly cache_write_mtok?: unknown;
137
+ }
138
+
139
+ /**
140
+ * Pick the price record to convert. A plain object is used directly;
141
+ * an array is usable only when it holds exactly ONE record (multiple
142
+ * records mean conditional / time-tiered pricing the flat
143
+ * {@link ModelPrice} cannot express - skipped, per the module doc).
144
+ */
145
+ function selectPriceRecord(prices: unknown): GenaiPriceRecord | null {
146
+ if (Array.isArray(prices)) {
147
+ if (prices.length !== 1) return null;
148
+ return selectPriceRecord(prices[0]);
149
+ }
150
+ if (typeof prices === 'object' && prices !== null) return prices as GenaiPriceRecord;
151
+ return null;
152
+ }
153
+
154
+ /** Per-Mtok USD figure -> per-token USD; non-finite/tiered -> undefined. */
155
+ function perToken(mtok: unknown): number | undefined {
156
+ return typeof mtok === 'number' && Number.isFinite(mtok) && mtok >= 0 ? mtok / MTOK : undefined;
157
+ }
158
+
159
+ function firstString(...values: ReadonlyArray<unknown>): string | undefined {
160
+ for (const value of values) {
161
+ if (typeof value === 'string' && value.length > 0) return value;
162
+ }
163
+ return undefined;
164
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `diffPricing(...)` - produce a row-by-row delta between two pricing
3
+ * snapshots. Used by the upcoming `graphorin pricing diff` CLI to
4
+ * surface upstream changes after a `refresh`.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ import type { ModelPrice, PricingDiffEntry, PricingSnapshot } from './types.js';
10
+
11
+ const COMPARED_FIELDS: ReadonlyArray<keyof ModelPrice> = [
12
+ 'inputUsdPerToken',
13
+ 'outputUsdPerToken',
14
+ 'cachedReadUsdPerToken',
15
+ 'cacheWriteUsdPerToken',
16
+ 'reasoningUsdPerToken',
17
+ 'region',
18
+ 'notes',
19
+ ];
20
+
21
+ /**
22
+ * Compare two snapshots and return one entry per (provider, model)
23
+ * pair that has been added, removed, or changed. The result is sorted
24
+ * by `(provider, model, kind)` for deterministic output.
25
+ *
26
+ * @stable
27
+ */
28
+ export function diffPricing(
29
+ before: PricingSnapshot,
30
+ after: PricingSnapshot,
31
+ ): ReadonlyArray<PricingDiffEntry> {
32
+ const beforeMap = indexBy(before.entries);
33
+ const afterMap = indexBy(after.entries);
34
+ const out: PricingDiffEntry[] = [];
35
+
36
+ for (const [key, entry] of beforeMap) {
37
+ const next = afterMap.get(key);
38
+ if (next === undefined) {
39
+ out.push({ provider: entry.provider, model: entry.model, kind: 'removed', before: entry });
40
+ continue;
41
+ }
42
+ const changedFields = COMPARED_FIELDS.filter((field) => entry[field] !== next[field]);
43
+ if (changedFields.length > 0) {
44
+ out.push({
45
+ provider: entry.provider,
46
+ model: entry.model,
47
+ kind: 'changed',
48
+ before: entry,
49
+ after: next,
50
+ changedFields,
51
+ });
52
+ }
53
+ }
54
+
55
+ for (const [key, entry] of afterMap) {
56
+ if (beforeMap.has(key)) continue;
57
+ out.push({ provider: entry.provider, model: entry.model, kind: 'added', after: entry });
58
+ }
59
+
60
+ return out.sort((a, b) => {
61
+ if (a.provider !== b.provider) return a.provider < b.provider ? -1 : 1;
62
+ if (a.model !== b.model) return a.model < b.model ? -1 : 1;
63
+ return a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0;
64
+ });
65
+ }
66
+
67
+ function indexBy(entries: ReadonlyArray<ModelPrice>): Map<string, ModelPrice> {
68
+ const out = new Map<string, ModelPrice>();
69
+ for (const entry of entries) {
70
+ out.set(`${entry.provider}/${entry.model}/${entry.region ?? ''}`, entry);
71
+ }
72
+ return out;
73
+ }
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @graphorin/pricing - bundled LLM pricing snapshot for the Graphorin
3
+ * framework.
4
+ *
5
+ * Highlights:
6
+ *
7
+ * - {@link BUNDLED_SNAPSHOT} - bundled snapshot covering Anthropic,
8
+ * OpenAI, Google, Mistral, Cohere + the local providers
9
+ * (Ollama / llama.cpp). Each entry carries a per-token price and
10
+ * the canonical SHA-256 digest of the `entries` array.
11
+ * - {@link lookupPrice} - resolve a `(provider, model)` pair against a
12
+ * snapshot. Returns `null` for unknown entries plus emits one WARN
13
+ * per process-lifetime per unknown pair.
14
+ * - {@link calculateCost} - multiply a price by token counts for a
15
+ * single LLM call.
16
+ * - {@link diffPricing} - row-by-row delta between two snapshots.
17
+ * - {@link refreshPricing} - opt-in network call. Never automatic.
18
+ * - {@link listMissingModels} - scan trace spans for unknown models;
19
+ * used by `graphorin pricing missing` (Phase 15).
20
+ *
21
+ * The bundled snapshot is intentionally small - operators wanting the
22
+ * full upstream catalogue should run `refreshPricing(...)` and
23
+ * persist the result to disk.
24
+ *
25
+ * @packageDocumentation
26
+ */
27
+
28
+ /** Canonical version constant, derived from `package.json` at build time. */
29
+ import pkg from '../package.json' with { type: 'json' };
30
+
31
+ export const VERSION: string = pkg.version;
32
+
33
+ export {
34
+ DEFAULT_PRICING_AUTO_REFRESH,
35
+ type PricingAutoRefreshConfig,
36
+ type PricingConfig,
37
+ } from './config.js';
38
+ export {
39
+ convertGenaiPrices,
40
+ type GenaiPricesConversion,
41
+ isGenaiPricesShape,
42
+ } from './convert-genai-prices.js';
43
+ export { diffPricing } from './diff.js';
44
+ export {
45
+ _resetLookupWarningsForTesting,
46
+ calculateCost,
47
+ lookupPrice,
48
+ setLookupWarnSink,
49
+ } from './lookup.js';
50
+ export { listMissingModels, type MissingModelEntry } from './missing.js';
51
+ export { type RefreshPricingOptions, refreshPricing } from './refresh.js';
52
+ export { BUNDLED_SNAPSHOT, computeEntriesDigest, SNAPSHOT_DATE } from './snapshot/index.js';
53
+ export type {
54
+ Cost,
55
+ LookupPriceArgs,
56
+ LookupPriceResult,
57
+ ModelPrice,
58
+ PricingDiffEntry,
59
+ PricingSnapshot,
60
+ PricingTraceSpanLike,
61
+ } from './types.js';
package/src/lookup.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `lookupPrice(...)` - resolve a per-token price for a given
3
+ * (provider, model) pair against a snapshot. Returns `null` when the
4
+ * model is unknown and emits one WARN per process-lifetime per
5
+ * unknown (provider, model) pair.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import { BUNDLED_SNAPSHOT } from './snapshot/bundled.js';
11
+ import type { LookupPriceArgs, LookupPriceResult, ModelPrice, PricingSnapshot } from './types.js';
12
+
13
+ const WARNED = new Set<string>();
14
+
15
+ /**
16
+ * Optional sink for the deduplicated WARN emitted on unknown models.
17
+ * Defaults to `console.warn`. Override in tests.
18
+ *
19
+ * @internal
20
+ */
21
+ export function setLookupWarnSink(sink: (line: string) => void): void {
22
+ WARN_SINK = sink;
23
+ }
24
+
25
+ let WARN_SINK: (line: string) => void = (line) => console.warn(line);
26
+
27
+ /**
28
+ * @internal - exposed for tests so the WARN-once cache can be reset.
29
+ */
30
+ export function _resetLookupWarningsForTesting(): void {
31
+ WARNED.clear();
32
+ }
33
+
34
+ /**
35
+ * Resolve a per-token price for the (provider, model) pair. Returns
36
+ * `null` when the snapshot does not contain an entry for the model.
37
+ *
38
+ * The function emits one WARN per process per unknown (provider, model)
39
+ * pair so cost dashboards surface drift without spamming the log.
40
+ *
41
+ * @stable
42
+ */
43
+ export function lookupPrice(
44
+ args: LookupPriceArgs,
45
+ snapshot: PricingSnapshot = BUNDLED_SNAPSHOT,
46
+ ): LookupPriceResult | null {
47
+ const exact = snapshot.entries.find(
48
+ (entry) =>
49
+ entry.provider === args.provider &&
50
+ entry.model === args.model &&
51
+ (args.region === undefined || entry.region === undefined || entry.region === args.region),
52
+ );
53
+
54
+ if (exact !== undefined) return entryToResult(exact, snapshot);
55
+
56
+ // Date-suffix fallback (core-provider-03): dated ids like
57
+ // `claude-haiku-4-5-20251001` resolve to their dateless alias entry
58
+ // (`claude-haiku-4-5`) so the snapshot does not need one row per
59
+ // dated release.
60
+ const dateless = args.model.replace(/-\d{8}$/, '');
61
+ if (dateless !== args.model) {
62
+ const alias = snapshot.entries.find(
63
+ (entry) =>
64
+ entry.provider === args.provider &&
65
+ entry.model === dateless &&
66
+ (args.region === undefined || entry.region === undefined || entry.region === args.region),
67
+ );
68
+ if (alias !== undefined) return entryToResult(alias, snapshot);
69
+ }
70
+
71
+ // Wildcard fallback - used by local providers (`provider: 'ollama', model: '*'`).
72
+ const wildcard = snapshot.entries.find(
73
+ (entry) => entry.provider === args.provider && entry.model === '*',
74
+ );
75
+ if (wildcard !== undefined) return entryToResult(wildcard, snapshot);
76
+
77
+ warnOnce(args);
78
+ return null;
79
+ }
80
+
81
+ function entryToResult(entry: ModelPrice, snapshot: PricingSnapshot): LookupPriceResult {
82
+ return {
83
+ inputUsdPerToken: entry.inputUsdPerToken,
84
+ outputUsdPerToken: entry.outputUsdPerToken,
85
+ ...(entry.cachedReadUsdPerToken === undefined
86
+ ? {}
87
+ : { cachedReadUsdPerToken: entry.cachedReadUsdPerToken }),
88
+ ...(entry.cacheWriteUsdPerToken === undefined
89
+ ? {}
90
+ : { cacheWriteUsdPerToken: entry.cacheWriteUsdPerToken }),
91
+ ...(entry.reasoningUsdPerToken === undefined
92
+ ? {}
93
+ : { reasoningUsdPerToken: entry.reasoningUsdPerToken }),
94
+ source: snapshot.source,
95
+ snapshotDate: snapshot.snapshotDate,
96
+ };
97
+ }
98
+
99
+ function warnOnce(args: LookupPriceArgs): void {
100
+ const key = `${args.provider}/${args.model}`;
101
+ if (WARNED.has(key)) return;
102
+ WARNED.add(key);
103
+ WARN_SINK(
104
+ `[graphorin/pricing] WARN: no pricing entry for ${args.provider}/${args.model}; ` +
105
+ 'cost is null. Consider running `graphorin pricing refresh` or contributing the ' +
106
+ 'entry upstream.',
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Multiply a per-token price by an integer token count. Returns `null`
112
+ * when the price is unknown. Useful when caller wants to compute cost
113
+ * for a single LLM call without instantiating the cost tracker.
114
+ *
115
+ * Token-count contract (PS-19):
116
+ * - `inputTokens` **excludes** `cachedReadTokens` and `cacheWriteTokens` -
117
+ * the cache legs are billed separately at their own rates, so pass the
118
+ * non-cached prompt count to avoid double-billing.
119
+ * - `reasoningTokens` are billed at `outputUsdPerToken` unless the model entry
120
+ * declares an explicit `reasoningUsdPerToken`.
121
+ * - `cacheWriteTokens` are billed at `cacheWriteUsdPerToken` when the entry
122
+ * declares one, else at the full input rate (a cache write is at minimum a
123
+ * normal input token - the fallback never under-bills relative to no cache).
124
+ *
125
+ * Units contract (W-045): this is the CANONICAL producer of core
126
+ * `Cost.amount`, and it returns WHOLE US dollars (per-token USD rates
127
+ * times token counts) - never cents / minor units. One million input
128
+ * tokens at `inputUsdPerToken = 3e-6` cost `3` (three dollars).
129
+ *
130
+ * @stable
131
+ */
132
+ export function calculateCost(
133
+ args: LookupPriceArgs & {
134
+ /** Non-cached prompt tokens (excludes cache reads AND cache writes). */
135
+ readonly inputTokens: number;
136
+ readonly outputTokens: number;
137
+ readonly cachedReadTokens?: number;
138
+ readonly cacheWriteTokens?: number;
139
+ readonly reasoningTokens?: number;
140
+ },
141
+ snapshot: PricingSnapshot = BUNDLED_SNAPSHOT,
142
+ ): { readonly amount: number; readonly currency: 'USD' } | null {
143
+ const price = lookupPrice(args, snapshot);
144
+ if (price === null) return null;
145
+ let amount = 0;
146
+ // `inputTokens` is the NON-cached prompt; cached reads are billed separately
147
+ // at their own (cheaper) rate, so they must be excluded from `inputTokens` to
148
+ // avoid double-counting (PS-19).
149
+ amount += price.inputUsdPerToken * args.inputTokens;
150
+ amount += price.outputUsdPerToken * args.outputTokens;
151
+ if (args.cachedReadTokens !== undefined && price.cachedReadUsdPerToken !== undefined) {
152
+ amount += price.cachedReadUsdPerToken * args.cachedReadTokens;
153
+ }
154
+ if (args.cacheWriteTokens !== undefined) {
155
+ amount += (price.cacheWriteUsdPerToken ?? price.inputUsdPerToken) * args.cacheWriteTokens;
156
+ }
157
+ if (args.reasoningTokens !== undefined) {
158
+ // PS-19: reasoning tokens follow completion (output) pricing unless the
159
+ // entry declares an explicit `reasoningUsdPerToken` - the documented
160
+ // contract that was previously billed at $0 for every bundled entry.
161
+ amount += (price.reasoningUsdPerToken ?? price.outputUsdPerToken) * args.reasoningTokens;
162
+ }
163
+ return { amount, currency: 'USD' };
164
+ }
package/src/missing.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `listMissingModels(...)` - scan a sequence of trace spans and return
3
+ * the unique (provider, model) pairs that the snapshot does not
4
+ * recognise.
5
+ *
6
+ * The function reads `gen_ai.system` + `gen_ai.request.model` (the
7
+ * canonical OpenTelemetry GenAI attributes) and falls back to the
8
+ * Graphorin-prefixed `graphorin.provider.id` /
9
+ * `graphorin.provider.model` pair when `gen_ai.*` is absent.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+ import { lookupPrice } from './lookup.js';
15
+ import { BUNDLED_SNAPSHOT } from './snapshot/bundled.js';
16
+ import type { PricingSnapshot, PricingTraceSpanLike } from './types.js';
17
+
18
+ /**
19
+ * Single missing-model row.
20
+ *
21
+ * @stable
22
+ */
23
+ export interface MissingModelEntry {
24
+ readonly provider: string;
25
+ readonly model: string;
26
+ readonly count: number;
27
+ }
28
+
29
+ /**
30
+ * Return one entry per (provider, model) pair that the snapshot does
31
+ * not recognise, sorted by descending occurrence count.
32
+ *
33
+ * @stable
34
+ */
35
+ export function listMissingModels(
36
+ spans: Iterable<PricingTraceSpanLike>,
37
+ snapshot: PricingSnapshot = BUNDLED_SNAPSHOT,
38
+ ): ReadonlyArray<MissingModelEntry> {
39
+ const seen = new Map<string, MissingModelEntry & { count: number }>();
40
+ for (const span of spans) {
41
+ // E8: `gen_ai.provider.name` is the current OTel GenAI attribute (what
42
+ // withTracing emits); `gen_ai.system` is its deprecated predecessor kept
43
+ // for older recorded traces.
44
+ const provider = stringAttr(span, [
45
+ 'gen_ai.provider.name',
46
+ 'gen_ai.system',
47
+ 'graphorin.provider.id',
48
+ ]);
49
+ const model = stringAttr(span, [
50
+ 'gen_ai.request.model',
51
+ 'gen_ai.response.model',
52
+ 'graphorin.provider.model',
53
+ ]);
54
+ if (provider === null || model === null) continue;
55
+ if (lookupPrice({ provider, model }, snapshot) !== null) continue;
56
+ const key = `${provider}/${model}`;
57
+ const existing = seen.get(key);
58
+ if (existing === undefined) {
59
+ seen.set(key, { provider, model, count: 1 });
60
+ } else {
61
+ seen.set(key, { provider, model, count: existing.count + 1 });
62
+ }
63
+ }
64
+ return [...seen.values()].sort((a, b) => b.count - a.count);
65
+ }
66
+
67
+ function stringAttr(span: PricingTraceSpanLike, keys: ReadonlyArray<string>): string | null {
68
+ for (const key of keys) {
69
+ const value = span.attributes[key];
70
+ if (typeof value === 'string' && value.length > 0) return value;
71
+ }
72
+ return null;
73
+ }