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.
- package/.env.example +83 -4
- package/LICENSE +21 -21
- package/README.md +154 -15
- package/cache.js +133 -9
- package/cascade.js +206 -0
- package/coalescing.js +65 -0
- package/embeddings.js +78 -16
- package/failover.js +76 -76
- package/guardrails.js +80 -0
- package/metrics.js +93 -7
- package/package.json +12 -2
- package/pii.js +128 -0
- package/providers/anthropic.js +122 -122
- package/providers/deepseek.js +194 -0
- package/providers/index.js +62 -0
- package/providers/openai.js +122 -114
- package/providers/openrouter.js +233 -0
- package/public/dashboard.html +1120 -1119
- package/router.js +33 -4
- package/semanticCache.js +115 -3
- package/server.js +494 -99
- package/streaming.js +77 -77
- package/tracing.js +97 -0
|
@@ -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
|
+
};
|