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,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 };
@@ -1,114 +1,122 @@
1
- // model-router/providers/openai.js
2
- const { OpenAI } = require('openai');
3
-
4
- function buildClient(apiKey) {
5
- return new OpenAI({ apiKey });
6
- }
7
-
8
- function estimateCost(model, inputTokens, outputTokens) {
9
- // Approximate pricing per 1M tokens
10
- const rates = {
11
- 'gpt-4o-mini': { input: 0.15, output: 0.6 },
12
- 'gpt-4o': { input: 2.5, output: 10.0 }
13
- };
14
- const rate = rates[model] || { input: 2.5, output: 10.0 };
15
- return ((inputTokens * rate.input) + (outputTokens * rate.output)) / 1_000_000;
16
- }
17
-
18
- async function chat(client, payload) {
19
- const request = {
20
- model: payload.model,
21
- messages: payload.messages,
22
- temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
23
- max_tokens: payload.max_tokens || 1024,
24
- ...(payload.tools && { tools: payload.tools }),
25
- ...(payload.tool_choice && { tool_choice: payload.tool_choice }),
26
- ...(payload.response_format && { response_format: payload.response_format })
27
- };
28
-
29
- const start = Date.now();
30
- const response = await client.chat.completions.create(request);
31
- const latencyMs = Date.now() - start;
32
-
33
- const choice = response.choices[0];
34
- const inputTokens = response.usage.prompt_tokens;
35
- const outputTokens = response.usage.completion_tokens;
36
- const costUsd = estimateCost(payload.model, inputTokens, outputTokens);
37
-
38
- return {
39
- provider: 'openai',
40
- model: payload.model,
41
- latency_ms: latencyMs,
42
- usage: { input_tokens: inputTokens, output_tokens: outputTokens },
43
- cost_usd: costUsd,
44
- content: choice.message.content || '',
45
- tool_calls: choice.message.tool_calls,
46
- raw: response
47
- };
48
- }
49
-
50
- /**
51
- * Pure state-accumulation for one OpenAI streaming chunk - factored out
52
- * from chatStream() so the usage/cost extraction is directly
53
- * unit-testable with canned chunks, no live API needed. Mutates `state`
54
- * ({content, inputTokens, outputTokens}) and calls onDelta() with each
55
- * new piece of assistant text.
56
- *
57
- * OpenAI only includes `usage` on a final, choice-less chunk, and only
58
- * when the request explicitly asked for it (`stream_options:
59
- * {include_usage: true}`, set in chatStream() below) - without that
60
- * flag a streamed OpenAI response has NO usage data at all, which would
61
- * silently make cost_usd wrong (stuck at 0) for every streamed OpenAI
62
- * call. Requesting it explicitly is required, not optional, for the
63
- * cost tracking this whole project is built around to stay honest.
64
- */
65
- function applyStreamChunk(state, chunk, onDelta) {
66
- const choice = chunk.choices && chunk.choices[0];
67
- if (choice && choice.delta && choice.delta.content) {
68
- state.content += choice.delta.content;
69
- onDelta(choice.delta.content);
70
- }
71
- if (chunk.usage) {
72
- state.inputTokens = chunk.usage.prompt_tokens;
73
- state.outputTokens = chunk.usage.completion_tokens;
74
- }
75
- }
76
-
77
- /**
78
- * Streaming counterpart to chat(). Scope: plain text content only - no
79
- * tools/tool_choice forwarded (server.js rejects stream:true + tools
80
- * before this is ever called; see streaming.js for why).
81
- */
82
- async function chatStream(client, payload, { onDelta, signal } = {}) {
83
- const request = {
84
- model: payload.model,
85
- messages: payload.messages,
86
- temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
87
- max_tokens: payload.max_tokens || 1024,
88
- stream: true,
89
- stream_options: { include_usage: true }
90
- };
91
-
92
- const start = Date.now();
93
- const stream = await client.chat.completions.create(request, signal ? { signal } : undefined);
94
-
95
- const state = { content: '', inputTokens: 0, outputTokens: 0 };
96
- for await (const chunk of stream) {
97
- applyStreamChunk(state, chunk, onDelta || (() => {}));
98
- }
99
-
100
- const latencyMs = Date.now() - start;
101
- const costUsd = estimateCost(payload.model, state.inputTokens, state.outputTokens);
102
-
103
- return {
104
- provider: 'openai',
105
- model: payload.model,
106
- latency_ms: latencyMs,
107
- usage: { input_tokens: state.inputTokens, output_tokens: state.outputTokens },
108
- cost_usd: costUsd,
109
- content: state.content,
110
- tool_calls: undefined
111
- };
112
- }
113
-
114
- module.exports = { buildClient, chat, chatStream, applyStreamChunk, estimateCost };
1
+ // model-router/providers/openai.js
2
+ const { OpenAI } = require('openai');
3
+
4
+ function buildClient(apiKey) {
5
+ return new OpenAI({ apiKey });
6
+ }
7
+
8
+ function estimateCost(model, inputTokens, outputTokens) {
9
+ // Approximate pricing per 1M tokens
10
+ const rates = {
11
+ 'gpt-4o-mini': { input: 0.15, output: 0.6 },
12
+ 'gpt-4o': { input: 2.5, output: 10.0 }
13
+ };
14
+ const rate = rates[model] || { input: 2.5, output: 10.0 };
15
+ return ((inputTokens * rate.input) + (outputTokens * rate.output)) / 1_000_000;
16
+ }
17
+
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 = {}) {
26
+ const request = {
27
+ model: payload.model,
28
+ messages: payload.messages,
29
+ temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
30
+ max_tokens: payload.max_tokens || 1024,
31
+ ...(payload.tools && { tools: payload.tools }),
32
+ ...(payload.tool_choice && { tool_choice: payload.tool_choice }),
33
+ ...(payload.response_format && { response_format: payload.response_format }),
34
+ ...(options.requestLogprobs && { logprobs: true, top_logprobs: 1 })
35
+ };
36
+
37
+ const start = Date.now();
38
+ const response = await client.chat.completions.create(request);
39
+ const latencyMs = Date.now() - start;
40
+
41
+ const choice = response.choices[0];
42
+ const inputTokens = response.usage.prompt_tokens;
43
+ const outputTokens = response.usage.completion_tokens;
44
+ const costUsd = estimateCost(payload.model, inputTokens, outputTokens);
45
+
46
+ return {
47
+ provider: 'openai',
48
+ model: payload.model,
49
+ latency_ms: latencyMs,
50
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens },
51
+ cost_usd: costUsd,
52
+ content: choice.message.content || '',
53
+ tool_calls: choice.message.tool_calls,
54
+ raw: response
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Pure state-accumulation for one OpenAI streaming chunk - factored out
60
+ * from chatStream() so the usage/cost extraction is directly
61
+ * unit-testable with canned chunks, no live API needed. Mutates `state`
62
+ * ({content, inputTokens, outputTokens}) and calls onDelta() with each
63
+ * new piece of assistant text.
64
+ *
65
+ * OpenAI only includes `usage` on a final, choice-less chunk, and only
66
+ * when the request explicitly asked for it (`stream_options:
67
+ * {include_usage: true}`, set in chatStream() below) - without that
68
+ * flag a streamed OpenAI response has NO usage data at all, which would
69
+ * silently make cost_usd wrong (stuck at 0) for every streamed OpenAI
70
+ * call. Requesting it explicitly is required, not optional, for the
71
+ * cost tracking this whole project is built around to stay honest.
72
+ */
73
+ function applyStreamChunk(state, chunk, onDelta) {
74
+ const choice = chunk.choices && chunk.choices[0];
75
+ if (choice && choice.delta && choice.delta.content) {
76
+ state.content += choice.delta.content;
77
+ onDelta(choice.delta.content);
78
+ }
79
+ if (chunk.usage) {
80
+ state.inputTokens = chunk.usage.prompt_tokens;
81
+ state.outputTokens = chunk.usage.completion_tokens;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Streaming counterpart to chat(). Scope: plain text content only - no
87
+ * tools/tool_choice forwarded (server.js rejects stream:true + tools
88
+ * before this is ever called; see streaming.js for why).
89
+ */
90
+ async function chatStream(client, payload, { onDelta, signal } = {}) {
91
+ const request = {
92
+ model: payload.model,
93
+ messages: payload.messages,
94
+ temperature: typeof payload.temperature === 'number' ? payload.temperature : 0.0,
95
+ max_tokens: payload.max_tokens || 1024,
96
+ stream: true,
97
+ stream_options: { include_usage: true }
98
+ };
99
+
100
+ const start = Date.now();
101
+ const stream = await client.chat.completions.create(request, signal ? { signal } : undefined);
102
+
103
+ const state = { content: '', inputTokens: 0, outputTokens: 0 };
104
+ for await (const chunk of stream) {
105
+ applyStreamChunk(state, chunk, onDelta || (() => {}));
106
+ }
107
+
108
+ const latencyMs = Date.now() - start;
109
+ const costUsd = estimateCost(payload.model, state.inputTokens, state.outputTokens);
110
+
111
+ return {
112
+ provider: 'openai',
113
+ model: payload.model,
114
+ latency_ms: latencyMs,
115
+ usage: { input_tokens: state.inputTokens, output_tokens: state.outputTokens },
116
+ cost_usd: costUsd,
117
+ content: state.content,
118
+ tool_calls: undefined
119
+ };
120
+ }
121
+
122
+ module.exports = { buildClient, chat, chatStream, applyStreamChunk, estimateCost };