cachegate 1.3.0 → 1.4.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,233 @@
1
+ // model-router/providers/openrouter.js
2
+ //
3
+ // OpenRouter is an aggregator: one OpenAI-compatible endpoint in front of many
4
+ // vendors, addressed by `vendor/model` ids (e.g. `deepseek/deepseek-chat`,
5
+ // `anthropic/claude-sonnet-4.5`). Two things make it different from the direct
6
+ // providers in this directory:
7
+ //
8
+ // 1. ATTRIBUTION HEADERS. Requests may carry HTTP-Referer and X-Title so the
9
+ // app shows up correctly in OpenRouter's own dashboard. They are optional
10
+ // on the wire, so they are only sent when configured - inventing a
11
+ // referer for someone's deployment would be worse than omitting it.
12
+ //
13
+ // 2. PRICING CANNOT BE A CONSTANT TABLE HERE. providers/openai.js hardcodes
14
+ // two rates and providers/deepseek.js a handful, which is defensible for a
15
+ // vendor with a handful of models. OpenRouter fronts hundreds, and their
16
+ // prices change without a release of this project - a hardcoded table
17
+ // would rot into wrong money silently, which is the one failure mode this
18
+ // project's cost tracking exists to prevent. So the catalog is fetched
19
+ // from OpenRouter's own /models endpoint and cached in-process.
20
+ //
21
+ // WHEN PRICING IS UNKNOWN, cost is null - NEVER 0. A zero would be read as
22
+ // "free" by anything that sorts candidates by cost, and this router's whole
23
+ // pitch is routing to the cheapest healthy provider; a silent zero would
24
+ // route everything to whichever model we failed to price. Callers must
25
+ // treat null as "unknown, do not compare" (see providers/index.js).
26
+ const { OpenAI } = require('openai');
27
+
28
+ const BASE_URL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
29
+ const MODELS_PATH = '/models';
30
+ const PRICING_TTL_MS = Number(process.env.OPENROUTER_PRICING_TTL_MS) || 6 * 60 * 60 * 1000;
31
+
32
+ // model id -> { input, output } in USD per 1M tokens. Populated by
33
+ // refreshPricing() (network) or setPricingTable() (tests / a pinned table).
34
+ let pricingTable = null;
35
+ let pricingFetchedAt = 0;
36
+
37
+ /**
38
+ * OpenRouter's documented optional attribution headers, from the environment.
39
+ * A pure function so the "only send what was configured" rule is testable
40
+ * without constructing an SDK client.
41
+ */
42
+ function attributionHeaders(env = process.env) {
43
+ const headers = {};
44
+ if (env.OPENROUTER_SITE_URL) headers['HTTP-Referer'] = env.OPENROUTER_SITE_URL;
45
+ if (env.OPENROUTER_SITE_NAME) headers['X-Title'] = env.OPENROUTER_SITE_NAME;
46
+ return headers;
47
+ }
48
+
49
+ function buildClient(apiKey) {
50
+ const headers = attributionHeaders();
51
+
52
+ return new OpenAI({
53
+ apiKey,
54
+ baseURL: BASE_URL,
55
+ defaultHeaders: Object.keys(headers).length ? headers : undefined,
56
+ timeout: Number(process.env.OPENROUTER_TIMEOUT_MS) || 60000
57
+ });
58
+ }
59
+
60
+ // `vendor/model`, optionally written as `openrouter/vendor/model` so a caller
61
+ // can be explicit. Anything else is not an OpenRouter id.
62
+ function isOpenRouterModel(model) {
63
+ if (typeof model !== 'string') return false;
64
+ if (model.startsWith('openrouter/')) return true;
65
+ return /^[a-z0-9][a-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(model);
66
+ }
67
+
68
+ function normalizeModel(model) {
69
+ return typeof model === 'string' && model.startsWith('openrouter/') ? model.slice('openrouter/'.length) : model;
70
+ }
71
+
72
+ /** OpenRouter reports per-token prices as strings; convert to per-1M USD. */
73
+ function toPerMillion(value) {
74
+ const n = Number(value);
75
+ return Number.isFinite(n) ? n * 1_000_000 : null;
76
+ }
77
+
78
+ function setPricingTable(entries) {
79
+ // setPricingTable(null) resets to "price unknown" - what a test, or a caller
80
+ // forcing a refresh, needs. An empty object would instead be a loaded-but-
81
+ // empty table, which reads as "every model is unpriced" rather than "not
82
+ // loaded yet" and would make the null-vs-zero distinction untestable.
83
+ if (!entries) { pricingTable = null; pricingFetchedAt = 0; return null; }
84
+ const table = {};
85
+ for (const e of entries || []) {
86
+ const id = e && (e.id || e.model);
87
+ if (!id || !e.pricing) continue;
88
+ const input = toPerMillion(e.pricing.prompt ?? e.pricing.input);
89
+ const output = toPerMillion(e.pricing.completion ?? e.pricing.output);
90
+ if (input === null) continue;
91
+ table[id] = { input, output: output === null ? input : output };
92
+ }
93
+ pricingTable = table;
94
+ pricingFetchedAt = Date.now();
95
+ return table;
96
+ }
97
+
98
+ async function refreshPricing({ fetchImpl, force = false } = {}) {
99
+ if (!force && pricingTable && (Date.now() - pricingFetchedAt) < PRICING_TTL_MS) return pricingTable;
100
+ const doFetch = fetchImpl || globalThis.fetch;
101
+ if (typeof doFetch !== 'function') return pricingTable;
102
+ try {
103
+ const res = await doFetch(`${BASE_URL}${MODELS_PATH}`);
104
+ if (!res || !res.ok) return pricingTable;
105
+ const body = await res.json();
106
+ setPricingTable(body && body.data);
107
+ } catch {
108
+ // A pricing fetch failure must never break dispatch - the request itself
109
+ // does not need prices. Cost comes back null and is reported as unknown.
110
+ }
111
+ return pricingTable;
112
+ }
113
+
114
+ /**
115
+ * USD for one call, or null when the model's price is not known yet.
116
+ * (null, not 0 - see the header.)
117
+ */
118
+ function estimateCost(model, inputTokens, outputTokens) {
119
+ if (!pricingTable) return null;
120
+ const rate = pricingTable[normalizeModel(model)];
121
+ if (!rate) return null;
122
+ return (((inputTokens || 0) * rate.input) + ((outputTokens || 0) * rate.output)) / 1_000_000;
123
+ }
124
+
125
+ function usageFrom(usage = {}) {
126
+ return {
127
+ input_tokens: usage.prompt_tokens || 0,
128
+ output_tokens: usage.completion_tokens || 0,
129
+ // Some upstreams behind OpenRouter report their own cache tiers. Passed
130
+ // through when present, never invented when absent.
131
+ ...(typeof usage.prompt_tokens_details?.cached_tokens === 'number'
132
+ && { cached_input_tokens: usage.prompt_tokens_details.cached_tokens })
133
+ };
134
+ }
135
+
136
+ async function chat(client, payload, options = {}) {
137
+ const request = {
138
+ model: normalizeModel(payload.model),
139
+ messages: payload.messages,
140
+ temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
141
+ max_tokens: payload.max_tokens || 1024,
142
+ ...(payload.tools && { tools: payload.tools }),
143
+ ...(payload.tool_choice && { tool_choice: payload.tool_choice }),
144
+ ...(payload.response_format && { response_format: payload.response_format }),
145
+ // Ask for the upstream provider's identity + cost accounting in the same
146
+ // response, so the dashboard can attribute spend per vendor.
147
+ usage: { include: true },
148
+ ...(options.requestLogprobs && { logprobs: true, top_logprobs: 1 })
149
+ };
150
+
151
+ const start = Date.now();
152
+ const response = await client.chat.completions.create(request);
153
+ const latencyMs = Date.now() - start;
154
+
155
+ const choice = response.choices[0];
156
+ const usage = usageFrom(response.usage);
157
+ // Prefer OpenRouter's own accounting when it sends it: it is the billed
158
+ // amount, including any provider-specific pricing we did not model.
159
+ const billed = typeof response.usage?.cost === 'number' ? response.usage.cost : null;
160
+ const costUsd = billed !== null ? billed : estimateCost(payload.model, usage.input_tokens, usage.output_tokens);
161
+
162
+ return {
163
+ provider: 'openrouter',
164
+ model: payload.model,
165
+ latency_ms: latencyMs,
166
+ usage,
167
+ cost_usd: costUsd,
168
+ content: choice.message.content || '',
169
+ tool_calls: choice.message.tool_calls,
170
+ // Which upstream actually served it (OpenRouter routes among several).
171
+ upstream_provider: response.provider || undefined,
172
+ raw: response
173
+ };
174
+ }
175
+
176
+ function applyStreamChunk(state, chunk, onDelta) {
177
+ const choice = chunk.choices && chunk.choices[0];
178
+ if (choice && choice.delta && choice.delta.content) {
179
+ state.content += choice.delta.content;
180
+ onDelta(choice.delta.content);
181
+ }
182
+ if (chunk.usage) {
183
+ state.inputTokens = chunk.usage.prompt_tokens || 0;
184
+ state.outputTokens = chunk.usage.completion_tokens || 0;
185
+ if (typeof chunk.usage.cost === 'number') state.billedCost = chunk.usage.cost;
186
+ if (typeof chunk.usage.prompt_tokens_details?.cached_tokens === 'number') {
187
+ state.cachedInputTokens = chunk.usage.prompt_tokens_details.cached_tokens;
188
+ }
189
+ }
190
+ }
191
+
192
+ async function chatStream(client, payload, { onDelta, signal } = {}) {
193
+ const request = {
194
+ model: normalizeModel(payload.model),
195
+ messages: payload.messages,
196
+ temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
197
+ max_tokens: payload.max_tokens || 1024,
198
+ stream: true,
199
+ stream_options: { include_usage: true },
200
+ usage: { include: true }
201
+ };
202
+
203
+ const start = Date.now();
204
+ const stream = await client.chat.completions.create(request, signal ? { signal } : undefined);
205
+
206
+ const state = { content: '', inputTokens: 0, outputTokens: 0, billedCost: null, cachedInputTokens: 0 };
207
+ for await (const chunk of stream) {
208
+ applyStreamChunk(state, chunk, onDelta || (() => {}));
209
+ }
210
+
211
+ const latencyMs = Date.now() - start;
212
+ const usage = usageFrom({
213
+ prompt_tokens: state.inputTokens,
214
+ completion_tokens: state.outputTokens,
215
+ ...(state.cachedInputTokens ? { prompt_tokens_details: { cached_tokens: state.cachedInputTokens } } : {})
216
+ });
217
+ const costUsd = state.billedCost !== null ? state.billedCost : estimateCost(payload.model, usage.input_tokens, usage.output_tokens);
218
+
219
+ return {
220
+ provider: 'openrouter',
221
+ model: payload.model,
222
+ latency_ms: latencyMs,
223
+ usage,
224
+ cost_usd: costUsd,
225
+ content: state.content,
226
+ tool_calls: undefined
227
+ };
228
+ }
229
+
230
+ module.exports = {
231
+ buildClient, chat, chatStream, applyStreamChunk, estimateCost,
232
+ isOpenRouterModel, normalizeModel, refreshPricing, setPricingTable, attributionHeaders, BASE_URL
233
+ };