@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.
- package/CHANGELOG.md +30 -0
- package/README.md +1 -1
- package/dist/convert-genai-prices.d.ts +28 -0
- package/dist/convert-genai-prices.d.ts.map +1 -0
- package/dist/convert-genai-prices.js +90 -0
- package/dist/convert-genai-prices.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/lookup.d.ts +5 -0
- package/dist/lookup.d.ts.map +1 -1
- package/dist/lookup.js +5 -0
- package/dist/lookup.js.map +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/dist/refresh.d.ts +7 -0
- package/dist/refresh.d.ts.map +1 -1
- package/dist/refresh.js +35 -3
- package/dist/refresh.js.map +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -5
- package/src/config.ts +39 -0
- package/src/convert-genai-prices.ts +164 -0
- package/src/diff.ts +73 -0
- package/src/index.ts +61 -0
- package/src/lookup.ts +164 -0
- package/src/missing.ts +73 -0
- package/src/refresh.ts +197 -0
- package/src/snapshot/bundled.ts +306 -0
- package/src/snapshot/index.ts +7 -0
- package/src/types.ts +122 -0
|
@@ -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
|
+
}
|