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,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
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 };
|