cachegate 1.3.1 → 1.4.1
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/README.md +145 -15
- package/cache.js +133 -9
- package/cascade.js +206 -0
- package/coalescing.js +65 -0
- package/embeddings.js +78 -16
- package/guardrails.js +80 -0
- package/metrics.js +89 -5
- package/package.json +12 -2
- package/pii.js +128 -0
- package/providers/deepseek.js +194 -0
- package/providers/index.js +62 -0
- package/providers/openai.js +10 -2
- package/providers/openrouter.js +233 -0
- package/public/dashboard.html +1 -0
- package/router.js +33 -4
- package/semanticCache.js +115 -3
- package/server.js +494 -118
- package/tracing.js +97 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cachegate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Self-hostable, OpenAI-compatible LLM proxy: routes to the cheapest healthy provider, caches responses exactly and semantically, tracks cost and latency per call.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
|
@@ -10,13 +10,18 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"server.js",
|
|
12
12
|
"cache.js",
|
|
13
|
+
"cascade.js",
|
|
14
|
+
"coalescing.js",
|
|
13
15
|
"embeddings.js",
|
|
14
16
|
"failover.js",
|
|
17
|
+
"guardrails.js",
|
|
15
18
|
"metrics.js",
|
|
19
|
+
"pii.js",
|
|
16
20
|
"redisClient.js",
|
|
17
21
|
"router.js",
|
|
18
22
|
"semanticCache.js",
|
|
19
23
|
"streaming.js",
|
|
24
|
+
"tracing.js",
|
|
20
25
|
"providers/",
|
|
21
26
|
"public/",
|
|
22
27
|
".env.example"
|
|
@@ -26,10 +31,15 @@
|
|
|
26
31
|
},
|
|
27
32
|
"scripts": {
|
|
28
33
|
"start": "node server.js",
|
|
29
|
-
"test": "node --test"
|
|
34
|
+
"test": "node --test",
|
|
35
|
+
"eval:semantic-cache": "node eval/semantic-cache-eval.js"
|
|
30
36
|
},
|
|
31
37
|
"dependencies": {
|
|
32
38
|
"@anthropic-ai/sdk": "^0.115.0",
|
|
39
|
+
"@huggingface/transformers": "^3.0.0",
|
|
40
|
+
"@opentelemetry/api": "^1.9.0",
|
|
41
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
|
|
42
|
+
"@opentelemetry/sdk-node": "^0.222.0",
|
|
33
43
|
"dotenv": "^16.3.1",
|
|
34
44
|
"express": "^4.18.2",
|
|
35
45
|
"express-rate-limit": "^8.6.2",
|
package/pii.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// model-router/pii.js
|
|
2
|
+
//
|
|
3
|
+
// Pattern-based PII detection + redaction (roadmap step 25, PII track).
|
|
4
|
+
// Gated behind GUARDRAILS_PII_REDACTION (default OFF, same off-by-default
|
|
5
|
+
// convention as step 22's local embeddings) - ships fully inert until a
|
|
6
|
+
// deployment opts in. Standalone for now: NOT wired into server.js yet
|
|
7
|
+
// (see the step 25 kickoff directive) - this module only exposes the
|
|
8
|
+
// detection/redaction primitive; the pre-dispatch request-path wiring
|
|
9
|
+
// (and the coordination with the injection/policy track it needs to
|
|
10
|
+
// share a hook shape with) is a separate, joint follow-up change.
|
|
11
|
+
//
|
|
12
|
+
// Deliberately pattern/regex based, not a model call: PII redaction has
|
|
13
|
+
// to be synchronous, fast (it would run on every request, not just
|
|
14
|
+
// cache misses), and free of its own network dependency - a step whose
|
|
15
|
+
// whole job is stripping sensitive content out of a request shouldn't
|
|
16
|
+
// itself be a network hop that could leak that content to a third party.
|
|
17
|
+
//
|
|
18
|
+
// Redaction never surfaces the actual matched value anywhere, even in
|
|
19
|
+
// its own return value - only { type, count } counters. A false
|
|
20
|
+
// positive costs an unnecessary redaction (annoying); a leaked value in
|
|
21
|
+
// a log or a metrics row costs an actual PII exposure. This module is
|
|
22
|
+
// built to make the cheaper mistake.
|
|
23
|
+
|
|
24
|
+
// Order matters: more specific/longer patterns run first, so a token
|
|
25
|
+
// that could satisfy two shapes (e.g. a 16-digit run also containing a
|
|
26
|
+
// phone-shaped substring) is claimed by the more specific match before
|
|
27
|
+
// a looser pattern gets a chance to partially match what's left of it.
|
|
28
|
+
const PATTERNS = [
|
|
29
|
+
{
|
|
30
|
+
type: 'email',
|
|
31
|
+
regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
type: 'credit_card',
|
|
35
|
+
// 13-19 digits, optionally grouped by single spaces or dashes
|
|
36
|
+
// between digits (covers both "4111111111111111" and
|
|
37
|
+
// "4111 1111 1111 1111"/"4111-1111-1111-1111"). Matched by shape
|
|
38
|
+
// first, then narrowed by a Luhn checksum below - shape alone would
|
|
39
|
+
// false-positive on any long unrelated digit run (an order id, a
|
|
40
|
+
// padded invoice number).
|
|
41
|
+
regex: /\b(?:\d[ -]?){12,18}\d\b/g,
|
|
42
|
+
validate: (match) => luhnValid(match.replace(/[ -]/g, ''))
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: 'ssn',
|
|
46
|
+
// US SSN: NNN-NN-NNNN. Dashes required - a bare 9-digit run is too
|
|
47
|
+
// easy to confuse with an account/phone number to redact safely
|
|
48
|
+
// without a much higher false-positive rate.
|
|
49
|
+
regex: /\b\d{3}-\d{2}-\d{4}\b/g
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
type: 'phone',
|
|
53
|
+
// NA-style, requiring at least one separator (space/dash/dot/
|
|
54
|
+
// parens) - a bare 10-digit run is left to the credit-card pattern
|
|
55
|
+
// above (which needs 13+ digits, so no real overlap) rather than
|
|
56
|
+
// guessed at here. Uses digit lookaround, not \b, at both ends: a
|
|
57
|
+
// leading "(" is itself a non-word character, so a \b right before
|
|
58
|
+
// it never matches (word-boundary needs one word char and one
|
|
59
|
+
// non-word char either side) - \b would let the engine skip past a
|
|
60
|
+
// real "(555)" opening paren and leave it un-redacted outside the
|
|
61
|
+
// match. (?<!\d)/(?!\d) only cares that the run isn't glued to more
|
|
62
|
+
// digits, which is what actually needs guarding against here.
|
|
63
|
+
regex: /(?<!\d)(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]\d{3}[ .-]\d{4}(?!\d)/g
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: 'api_key',
|
|
67
|
+
// Common vendor secret shapes (OpenAI classic "sk-...", OpenAI
|
|
68
|
+
// project-scoped "sk-proj-...", Anthropic "sk-ant-...", AWS,
|
|
69
|
+
// GitHub, Slack, Google) - opaque tokens like these are exactly the
|
|
70
|
+
// kind of thing that ends up pasted into a prompt by accident. The
|
|
71
|
+
// "sk-" body allows dashes/underscores, not just alphanumerics, so
|
|
72
|
+
// it covers the dash-separated "-proj-"/"-ant-" variants too rather
|
|
73
|
+
// than needing one alternative per vendor prefix.
|
|
74
|
+
regex: /\b(?:sk-[a-zA-Z0-9_-]{20,}|AKIA[0-9A-Z]{16}|gh[pousr]_[a-zA-Z0-9]{20,}|xox[baprs]-[a-zA-Z0-9-]{10,}|AIza[0-9A-Za-z_-]{35})\b/g
|
|
75
|
+
}
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
function luhnValid(digits) {
|
|
79
|
+
let sum = 0;
|
|
80
|
+
let alternate = false;
|
|
81
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
82
|
+
let n = Number(digits[i]);
|
|
83
|
+
if (alternate) {
|
|
84
|
+
n *= 2;
|
|
85
|
+
if (n > 9) n -= 9;
|
|
86
|
+
}
|
|
87
|
+
sum += n;
|
|
88
|
+
alternate = !alternate;
|
|
89
|
+
}
|
|
90
|
+
return sum % 10 === 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isEnabled() {
|
|
94
|
+
return process.env.GUARDRAILS_PII_REDACTION === 'true';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// redact(text) -> { text, redactions: [{ type, count }] }.
|
|
98
|
+
// Side-effect free and safe to call unconditionally - when the flag is
|
|
99
|
+
// off, returns the text UNCHANGED (not an error, not a throw), same
|
|
100
|
+
// "always callable, flag decides" shape as embeddings.js's isEnabled().
|
|
101
|
+
function redact(text) {
|
|
102
|
+
if (!isEnabled() || typeof text !== 'string' || text.length === 0) {
|
|
103
|
+
return { text, redactions: [] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let result = text;
|
|
107
|
+
const counts = new Map();
|
|
108
|
+
|
|
109
|
+
for (const { type, regex, validate } of PATTERNS) {
|
|
110
|
+
// A fresh RegExp per pattern per call: the source patterns are
|
|
111
|
+
// global (/g), and a shared stateful regex's lastIndex would
|
|
112
|
+
// corrupt matching across concurrent/repeated calls in a
|
|
113
|
+
// long-running process handling many requests.
|
|
114
|
+
const re = new RegExp(regex.source, regex.flags);
|
|
115
|
+
result = result.replace(re, (match) => {
|
|
116
|
+
if (validate && !validate(match)) return match; // shape matched but failed validation - leave as-is
|
|
117
|
+
counts.set(type, (counts.get(type) || 0) + 1);
|
|
118
|
+
return `[REDACTED_${type.toUpperCase()}]`;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
text: result,
|
|
124
|
+
redactions: Array.from(counts.entries()).map(([type, count]) => ({ type, count }))
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { isEnabled, redact };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// model-router/providers/deepseek.js
|
|
2
|
+
//
|
|
3
|
+
// DeepSeek speaks the OpenAI wire format, so this is the `openai` SDK pointed
|
|
4
|
+
// at DeepSeek's own base URL - no second HTTP client, no hand-rolled fetch.
|
|
5
|
+
// What is NOT identical is the money: DeepSeek bills input in two tiers
|
|
6
|
+
// (cache hit vs cache miss) and every rate has a PEAK and an OFF-PEAK value,
|
|
7
|
+
// so a single flat rate table - the shape providers/openai.js can get away
|
|
8
|
+
// with - would misprice most requests. Both facts come from the vendor's own
|
|
9
|
+
// pages, read 2026-09-10:
|
|
10
|
+
// base URL + models + rates: api-docs.deepseek.com/quick_start/pricing
|
|
11
|
+
// usage fields: api-docs.deepseek.com/guides/kv_cache
|
|
12
|
+
//
|
|
13
|
+
// The review that prompted this file said "DeepSeek features incredibly cheap
|
|
14
|
+
// API calls and aggressive server-side prompt caching" and told the reader to
|
|
15
|
+
// "parse cached_tokens from DeepSeek's usage response blocks". The second half
|
|
16
|
+
// is wrong in a way that would have silently zeroed the savings: DeepSeek does
|
|
17
|
+
// not report OpenAI's nested prompt_tokens_details.cached_tokens, it reports a
|
|
18
|
+
// flat prompt_cache_hit_tokens / prompt_cache_miss_tokens pair. Cached input
|
|
19
|
+
// here is ~50x cheaper than a miss, so getting that mapping wrong is the
|
|
20
|
+
// difference between an accurate cost dashboard and a decorative one.
|
|
21
|
+
const { OpenAI } = require('openai');
|
|
22
|
+
|
|
23
|
+
const BASE_URL = process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com';
|
|
24
|
+
|
|
25
|
+
// USD per 1M tokens, PEAK rates. Off-peak is exactly half (vendor's own note:
|
|
26
|
+
// "Off-peak rates are half of the peak rates"). Peak = 01:00-04:00 and
|
|
27
|
+
// 06:00-10:00 UTC, Monday through Friday; everything else is off-peak.
|
|
28
|
+
const PEAK_RATES = {
|
|
29
|
+
'deepseek-flash': { hit: 0.006, miss: 0.30, output: 1.20 },
|
|
30
|
+
'deepseek-v4-pro': { hit: 0.044, miss: 1.32, output: 3.96 },
|
|
31
|
+
// Legacy names the API still accepts. The vendor states these are served by
|
|
32
|
+
// V4.1-Flash and BILLED AT THE FLASH PRICE, so they must not fall through to
|
|
33
|
+
// a "unknown model" default with different numbers.
|
|
34
|
+
'deepseek-v4-flash': { hit: 0.006, miss: 0.30, output: 1.20 },
|
|
35
|
+
'deepseek-v4-flash-vision-exp': { hit: 0.006, miss: 0.30, output: 1.20 }
|
|
36
|
+
};
|
|
37
|
+
// `deepseek-v4-pro` is being retired: from 2026-09-14 requests to it are routed
|
|
38
|
+
// to V4.1-Flash and billed as Flash. Until that date the pro rate is real, so
|
|
39
|
+
// both are priced and the switch happens on the vendor's side, not ours.
|
|
40
|
+
const DEFAULT_RATE = PEAK_RATES['deepseek-flash'];
|
|
41
|
+
|
|
42
|
+
function isPeak(now = new Date()) {
|
|
43
|
+
const day = now.getUTCDay(); // 0 = Sunday, 6 = Saturday
|
|
44
|
+
if (day === 0 || day === 6) return false;
|
|
45
|
+
const hour = now.getUTCHours();
|
|
46
|
+
return (hour >= 1 && hour < 4) || (hour >= 6 && hour < 10);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rateFor(model, now = new Date()) {
|
|
50
|
+
const base = PEAK_RATES[model] || DEFAULT_RATE;
|
|
51
|
+
return isPeak(now) ? base : { hit: base.hit / 2, miss: base.miss / 2, output: base.output / 2 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Cost for one call. Input tokens are SPLIT, not uniform: `cacheHitTokens` of
|
|
56
|
+
* them were served from DeepSeek's disk cache at the hit rate, the remainder at
|
|
57
|
+
* the miss rate. Getting this wrong is not a rounding error - at Flash's peak
|
|
58
|
+
* rates a fully-cached 1M-token input costs $0.006 instead of $0.30.
|
|
59
|
+
*
|
|
60
|
+
* The 3-argument form (no options) is kept working for callers that have no
|
|
61
|
+
* cache data: it prices everything as a miss, which is the conservative
|
|
62
|
+
* (never-understate) direction for a cost dashboard.
|
|
63
|
+
*
|
|
64
|
+
* `now` is injectable so tests can pin the billing window instead of passing
|
|
65
|
+
* or failing depending on what time of day the suite runs.
|
|
66
|
+
*/
|
|
67
|
+
function estimateCost(model, inputTokens, outputTokens, { cacheHitTokens = 0, now = new Date() } = {}) {
|
|
68
|
+
const rate = rateFor(model, now);
|
|
69
|
+
const hit = Math.max(0, Math.min(cacheHitTokens || 0, inputTokens || 0));
|
|
70
|
+
const miss = Math.max(0, (inputTokens || 0) - hit);
|
|
71
|
+
return ((hit * rate.hit) + (miss * rate.miss) + ((outputTokens || 0) * rate.output)) / 1_000_000;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildClient(apiKey) {
|
|
75
|
+
return new OpenAI({
|
|
76
|
+
apiKey,
|
|
77
|
+
baseURL: BASE_URL,
|
|
78
|
+
// DeepSeek requires no extra headers; the SDK's defaults are fine.
|
|
79
|
+
timeout: Number(process.env.DEEPSEEK_TIMEOUT_MS) || 60000
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function usageFrom(usage = {}) {
|
|
84
|
+
const input = usage.prompt_tokens || 0;
|
|
85
|
+
const hit = usage.prompt_cache_hit_tokens || 0;
|
|
86
|
+
const miss = typeof usage.prompt_cache_miss_tokens === 'number'
|
|
87
|
+
? usage.prompt_cache_miss_tokens
|
|
88
|
+
: Math.max(0, input - hit);
|
|
89
|
+
return { input_tokens: input, output_tokens: usage.completion_tokens || 0, cache_hit_tokens: hit, cache_miss_tokens: miss };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function chat(client, payload, options = {}) {
|
|
93
|
+
const request = {
|
|
94
|
+
model: payload.model,
|
|
95
|
+
messages: payload.messages,
|
|
96
|
+
temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
|
|
97
|
+
max_tokens: payload.max_tokens || 1024,
|
|
98
|
+
...(payload.tools && { tools: payload.tools }),
|
|
99
|
+
...(payload.tool_choice && { tool_choice: payload.tool_choice }),
|
|
100
|
+
...(payload.response_format && { response_format: payload.response_format })
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const start = Date.now();
|
|
104
|
+
const response = await client.chat.completions.create(request);
|
|
105
|
+
const latencyMs = Date.now() - start;
|
|
106
|
+
|
|
107
|
+
const choice = response.choices[0];
|
|
108
|
+
const usage = usageFrom(response.usage);
|
|
109
|
+
const costUsd = estimateCost(payload.model, usage.input_tokens, usage.output_tokens, { cacheHitTokens: usage.cache_hit_tokens });
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
provider: 'deepseek',
|
|
113
|
+
model: payload.model,
|
|
114
|
+
latency_ms: latencyMs,
|
|
115
|
+
usage,
|
|
116
|
+
cost_usd: costUsd,
|
|
117
|
+
content: choice.message.content || '',
|
|
118
|
+
// Thinking-mode models (the default for the current models) return their
|
|
119
|
+
// chain separately. Passed through instead of dropped so a caller can see
|
|
120
|
+
// it, but NOT concatenated into `content` - that would corrupt every
|
|
121
|
+
// consumer that treats content as the answer.
|
|
122
|
+
reasoning_content: choice.message.reasoning_content,
|
|
123
|
+
tool_calls: choice.message.tool_calls,
|
|
124
|
+
raw: response
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Pure state-accumulation for one streamed chunk, factored out for the same
|
|
130
|
+
* reason as providers/openai.js's: usage/cost extraction is then unit-testable
|
|
131
|
+
* against canned chunks with no live API.
|
|
132
|
+
*
|
|
133
|
+
* DeepSeek only sends `usage` on the final chunk, and only when the request
|
|
134
|
+
* asked for it (`stream_options.include_usage`, set in chatStream below) -
|
|
135
|
+
* without that flag a streamed call carries no usage at all and the cost
|
|
136
|
+
* tracking this project is built around would sit at zero while looking fine.
|
|
137
|
+
*/
|
|
138
|
+
function applyStreamChunk(state, chunk, onDelta) {
|
|
139
|
+
const choice = chunk.choices && chunk.choices[0];
|
|
140
|
+
if (choice && choice.delta) {
|
|
141
|
+
if (choice.delta.content) {
|
|
142
|
+
state.content += choice.delta.content;
|
|
143
|
+
onDelta(choice.delta.content);
|
|
144
|
+
}
|
|
145
|
+
if (choice.delta.reasoning_content) state.reasoningContent = (state.reasoningContent || '') + choice.delta.reasoning_content;
|
|
146
|
+
}
|
|
147
|
+
if (chunk.usage) {
|
|
148
|
+
state.inputTokens = chunk.usage.prompt_tokens || 0;
|
|
149
|
+
state.outputTokens = chunk.usage.completion_tokens || 0;
|
|
150
|
+
state.cacheHitTokens = chunk.usage.prompt_cache_hit_tokens || 0;
|
|
151
|
+
state.cacheMissTokens = chunk.usage.prompt_cache_miss_tokens;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function chatStream(client, payload, { onDelta, signal } = {}) {
|
|
156
|
+
const request = {
|
|
157
|
+
model: payload.model,
|
|
158
|
+
messages: payload.messages,
|
|
159
|
+
temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
|
|
160
|
+
max_tokens: payload.max_tokens || 1024,
|
|
161
|
+
stream: true,
|
|
162
|
+
stream_options: { include_usage: true }
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const start = Date.now();
|
|
166
|
+
const stream = await client.chat.completions.create(request, signal ? { signal } : undefined);
|
|
167
|
+
|
|
168
|
+
const state = { content: '', inputTokens: 0, outputTokens: 0, cacheHitTokens: 0, reasoningContent: '' };
|
|
169
|
+
for await (const chunk of stream) {
|
|
170
|
+
applyStreamChunk(state, chunk, onDelta || (() => {}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const latencyMs = Date.now() - start;
|
|
174
|
+
const usage = usageFrom({
|
|
175
|
+
prompt_tokens: state.inputTokens,
|
|
176
|
+
completion_tokens: state.outputTokens,
|
|
177
|
+
prompt_cache_hit_tokens: state.cacheHitTokens,
|
|
178
|
+
...(typeof state.cacheMissTokens === 'number' && { prompt_cache_miss_tokens: state.cacheMissTokens })
|
|
179
|
+
});
|
|
180
|
+
const costUsd = estimateCost(payload.model, usage.input_tokens, usage.output_tokens, { cacheHitTokens: usage.cache_hit_tokens });
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
provider: 'deepseek',
|
|
184
|
+
model: payload.model,
|
|
185
|
+
latency_ms: latencyMs,
|
|
186
|
+
usage,
|
|
187
|
+
cost_usd: costUsd,
|
|
188
|
+
content: state.content,
|
|
189
|
+
reasoning_content: state.reasoningContent || undefined,
|
|
190
|
+
tool_calls: undefined
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { buildClient, chat, chatStream, applyStreamChunk, estimateCost, isPeak, rateFor, BASE_URL };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// model-router/providers/index.js
|
|
2
|
+
//
|
|
3
|
+
// The provider registry. Before this file, "which provider serves this model?"
|
|
4
|
+
// was answered by an if/else chain repeated in eight places across server.js
|
|
5
|
+
// (detection, two dispatch forks, the streaming client + stream-function
|
|
6
|
+
// forks, the cascade grader, a second tier branch, and a metrics label). Every
|
|
7
|
+
// one of them knew exactly two providers, so adding a third meant finding and
|
|
8
|
+
// patching all eight - and missing one produced a weird failure far from the
|
|
9
|
+
// edit, not an error at the place that was forgotten.
|
|
10
|
+
//
|
|
11
|
+
// Adding a provider is now: write providers/<name>.js to the contract
|
|
12
|
+
// (buildClient/chat/chatStream/estimateCost), require it here, give it a model
|
|
13
|
+
// prefix and an env key. Everything else routes through this file.
|
|
14
|
+
const anthropic = require('./anthropic');
|
|
15
|
+
const openai = require('./openai');
|
|
16
|
+
const deepseek = require('./deepseek');
|
|
17
|
+
const openrouter = require('./openrouter');
|
|
18
|
+
|
|
19
|
+
const PROVIDERS = { anthropic, openai, deepseek, openrouter };
|
|
20
|
+
|
|
21
|
+
// The env var holding each provider's key. Used for both presence checks and
|
|
22
|
+
// the "not configured" error message, so those two can never disagree about
|
|
23
|
+
// which variable a provider actually needs.
|
|
24
|
+
const ENV_KEYS = {
|
|
25
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
26
|
+
openai: 'OPENAI_API_KEY',
|
|
27
|
+
deepseek: 'DEEPSEEK_API_KEY',
|
|
28
|
+
openrouter: 'OPENROUTER_API_KEY'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Which provider serves this model? Returns null when nothing claims it, which
|
|
33
|
+
* callers turn into a 400 "Unsupported model" - the same answer as before for
|
|
34
|
+
* anything unmatched.
|
|
35
|
+
*
|
|
36
|
+
* ORDER MATTERS: OpenRouter is checked first because its ids are
|
|
37
|
+
* `vendor/model`, and `deepseek/deepseek-chat` must not be captured by the
|
|
38
|
+
* direct DeepSeek prefix. (The direct DeepSeek ids are `deepseek-flash` /
|
|
39
|
+
* `deepseek-v4-pro` - no slash - so the two never actually collide, but the
|
|
40
|
+
* ordering is what guarantees that stays true if either vendor renames.)
|
|
41
|
+
*/
|
|
42
|
+
function detectProvider(model) {
|
|
43
|
+
if (typeof model !== 'string' || !model) return null;
|
|
44
|
+
if (openrouter.isOpenRouterModel(model)) return 'openrouter';
|
|
45
|
+
if (model.startsWith('claude-')) return 'anthropic';
|
|
46
|
+
if (model.startsWith('deepseek-')) return 'deepseek';
|
|
47
|
+
if (model.startsWith('gpt-') || model.startsWith('o1') || model.startsWith('o3')) return 'openai';
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function get(name) { return PROVIDERS[name] || null; }
|
|
52
|
+
|
|
53
|
+
function envKey(name) { return ENV_KEYS[name] || null; }
|
|
54
|
+
|
|
55
|
+
function names() { return Object.keys(PROVIDERS); }
|
|
56
|
+
|
|
57
|
+
/** Provider names that look usable given the current environment (presence only, never values). */
|
|
58
|
+
function configured(env = process.env) {
|
|
59
|
+
return names().filter((n) => !!env[ENV_KEYS[n]]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { PROVIDERS, ENV_KEYS, detectProvider, get, envKey, names, configured };
|
package/providers/openai.js
CHANGED
|
@@ -15,7 +15,14 @@ function estimateCost(model, inputTokens, outputTokens) {
|
|
|
15
15
|
return ((inputTokens * rate.input) + (outputTokens * rate.output)) / 1_000_000;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// `options.requestLogprobs` (cascade routing, step 34): ask the API for
|
|
19
|
+
// per-token logprobs so cascade.js can estimate confidence from the response.
|
|
20
|
+
// ONLY set by the router for an OpenAI candidate when cascade is active for
|
|
21
|
+
// that dispatch - a normal caller never sees this, and it's near-zero extra
|
|
22
|
+
// cost on the request it's attached to. top_logprobs: 1 keeps the payload
|
|
23
|
+
// small (one alternative per token) while still carrying the emitted token's
|
|
24
|
+
// own logprob, which is all the confidence math needs.
|
|
25
|
+
async function chat(client, payload, options = {}) {
|
|
19
26
|
const request = {
|
|
20
27
|
model: payload.model,
|
|
21
28
|
messages: payload.messages,
|
|
@@ -23,7 +30,8 @@ async function chat(client, payload) {
|
|
|
23
30
|
max_tokens: payload.max_tokens || 1024,
|
|
24
31
|
...(payload.tools && { tools: payload.tools }),
|
|
25
32
|
...(payload.tool_choice && { tool_choice: payload.tool_choice }),
|
|
26
|
-
...(payload.response_format && { response_format: payload.response_format })
|
|
33
|
+
...(payload.response_format && { response_format: payload.response_format }),
|
|
34
|
+
...(options.requestLogprobs && { logprobs: true, top_logprobs: 1 })
|
|
27
35
|
};
|
|
28
36
|
|
|
29
37
|
const start = Date.now();
|