openvibers/openvibe.ai Commits

DeepSeek Flash: give reasoning room, score empty attempts as failures (#354)

OP OpenVibers committed 6 days ago · 84698a38c7 · 7 files, +189 −33

A real DeepSeek V4.1 Flash call shows the answer in choices[0].message.content
and the thinking in reasoning_content, with reasoning_tokens counted inside
completion_tokens. max_tokens is the whole allowance: a 48-token cap produced
finish_reason 'length', content '', 48 reasoning tokens and usage output 0 in
1.7s. The production 300-token generate runs did the same over a 10k prompt:
requests.status ok, tokens_out 0, run error output.empty.

The registry now carries reasoning_reserve_tokens: 1536 on both DeepSeek models.
The router adds it to the caller's text max_tokens before the credit hold and
sends the widened cap, so an answer has room after the thinking. It is per model,
not global. A fixture of the real response shape drives the adapter and router
tests.

The Exchange catalog no longer calls an empty attempt a success: an ok request
whose run ended output.empty, or a text attempt with tokens_out 0 and the empty
output hash, is a failure of that provider/model; its latency is not a speed
sample. Keyless runs still count against no offer.

Changes

ddiff --git a/server/exchange/observed.js b/server/exchange/observed.js
iindex d30ece1..2275890 100644
---- a/server/exchange/observed.js
++++ b/server/exchange/observed.js
@@@ -11,9 +11,10 @@
*
* Uptime is per real provider attempt, not per run status. A failed run counts against an offer only
* when an attempt row names that provider and model; a run refused before routing, over quota or by
-- * validation has no such row and counts against nothing. An attempt that answered (status ok) is a
-- * success even when the run afterwards failed validation (`output.empty` / `output.invalid`): the
-- * offer did answer. A skipped or cancelled row was never an answer and is not a sample.
++ * validation has no such row and counts against nothing. An attempt is a success only when it produced
++ * output: an ok row whose run ended `output.empty`, or a text attempt that spent every output token
++ * (tokens_out = 0, e.g. all of max_tokens went to reasoning), is a failure of that provider and model.
++ * A skipped or cancelled row was never an answer and is not a sample.
*
* The latency of an answer is its time to the first token when the receipt recorded one (a streamed
* answer), else the total provider latency of the attempt that answered. Uptime and speed need enough
@@@ -23,6 +24,8 @@
*/
const DAY_MS = 86400000;
const MIN_SAMPLES = 20;
++// sha256(''): what the router stores as a request's output_hash when the provider produced no content.
++const EMPTY_OUTPUT_HASH = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
// What an upstream appends to a registry name: -2025-08-07, -v3-0324, -4-5-20251001, :free, @rev.
@@@ -60,11 +63,19 @@ function percentile(values, p) {
}
function createObserved({ db, clock = { now: () => Date.now() } }) {
-- // One sample per real provider attempt: ok is a success, error or timeout is a failure. Skipped and
-- // cancelled rows are not samples (no provider was tried, or the caller left). The run's own provider
-- // and model are not used here: a fallback run can name several offers, and a failed run keeps none.
++ // One sample per real provider attempt: ok with output is a success; error, timeout, an empty run
++ // and a text attempt that spent every token are failures. Skipped and cancelled rows are not samples
++ // (no provider was tried, or the caller left). The run's own provider and model are not used here: a
++ // fallback run can name several offers, and a failed run keeps none.
const attemptRows = db.prepare(`SELECT to_char(r.created_at::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day,
-- CASE q.status WHEN 'ok' THEN 'succeeded' ELSE 'failed' END AS status,
++ CASE
++ WHEN q.status <> 'ok' THEN 'failed'
++ WHEN r.error_code = 'output.empty' THEN 'failed'
++ WHEN q.operation IN ('chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'complete', 'messages')
++ AND COALESCE(q.tokens_out, 0) = 0
++ AND (q.output_hash IS NULL OR q.output_hash = '${EMPTY_OUTPUT_HASH}') THEN 'failed'
++ ELSE 'succeeded'
++ END AS status,
q.provider_key AS provider_key, q.model_key AS model_key,
COUNT(*)::bigint AS n
FROM requests q
@@@ -92,6 +103,10 @@ function createObserved({ db, clock = { now: () => Date.now() } }) {
LEFT JOIN ai_receipts rc ON rc.run_id = r.id
WHERE q.status = 'ok' AND q.latency_ms IS NOT NULL
AND r.status IN ('succeeded', 'failed')
++ AND (r.error_code IS NULL OR r.error_code <> 'output.empty')
++ AND NOT (q.operation IN ('chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'complete', 'messages')
++ AND COALESCE(q.tokens_out, 0) = 0
++ AND (q.output_hash IS NULL OR q.output_hash = '${EMPTY_OUTPUT_HASH}'))
AND r.created_at::timestamptz >= ?::timestamptz AND r.created_at::timestamptz <= ?::timestamptz`);
/** The real attempts and cached answers of the window, and the UTC day that is "today". */
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 83ce0a8..ecc0c4c 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -1385,7 +1385,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (uncensored) { attemptFacts.content_policy = 'uncensored'; attemptFacts.label = tier.LABEL; }
// The hold needs the offer's retail card, whether it is prepaid, and the real output cap.
const attemptRow = p && model ? await registry.getModel(p.key, model) : null;
-- const attemptMeta = { provider: p ? p.key : null, model, maxTokens: req.maxTokens, paid: !isFreeProvider(p), retail: modelCatalog.retailRate(attemptRow) };
++ // A reasoning model spends the same max_tokens allowance thinking before it writes: a caller's
++ // 300-token cap can be all reasoning, leaving content empty (finish_reason 'length', output 0).
++ // The registry says how much room this model needs on top of the answer the caller asked for.
++ const reserve = Number(attemptRow && attemptRow.metadata && attemptRow.metadata.reasoning_reserve_tokens) || 0;
++ const requestedMax = Number(req.maxTokens);
++ const modelMax = reserve > 0 && Number.isSafeInteger(requestedMax) && requestedMax >= 1
++ && ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'complete', 'messages'].includes(operation)
++ ? requestedMax + reserve
++ : req.maxTokens;
++ const attemptMeta = { provider: p ? p.key : null, model, maxTokens: modelMax, paid: !isFreeProvider(p), retail: modelCatalog.retailRate(attemptRow) };
const routed = typeof ctx.onRoute === 'function' ? await ctx.onRoute(attemptFacts, attemptMeta) : null;
if (routed && routed.skip) {
statFor(c.provider).skipped++;
@@@ -1395,8 +1404,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
continue candidate;
}
// max_cost_usd, when it binds, comes back as the affordable output cap for this attempt.
-- const callMax = routed && Number.isSafeInteger(routed.maxTokens) && routed.maxTokens >= 1 && routed.maxTokens !== req.maxTokens ? routed.maxTokens : null;
-- let callReq = callMax == null ? req : { ...req, maxTokens: callMax };
++ const callMax = routed && Number.isSafeInteger(routed.maxTokens) && routed.maxTokens >= 1 && routed.maxTokens !== modelMax ? routed.maxTokens : null;
++ let callReq = modelMax !== req.maxTokens ? { ...req, maxTokens: modelMax } : req;
++ if (callMax != null) callReq = { ...callReq, maxTokens: callMax };
// The host's model writes a short answer: its own cap, never more, whatever the route asked for.
if (local && operation !== 'embed') {
const localMax = Math.max(1, (config.localLlm && config.localLlm.maxTokens) || 512);
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex 51e9053..77a9b42 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -35,14 +35,14 @@ const DEEPSEEK_MODELS = [
context_window: 1048576, max_output: 393216,
supports: { json: true, tools: true, streaming: true, vision: true },
cost: { in_per_mtok: 0.30, out_per_mtok: 1.20, cached_per_mtok: 0.006, source: 'https://api-docs.deepseek.com/quick_start/pricing', verified_at: '2026-11-06' },
-- metadata: { org: 'deepseek', model: 'deepseek-flash', family: 'DeepSeek', tier: 'fast', reasoning: true, configured_by: 'DEEPSEEK_API_KEY', pricing_note: 'Peak rates (01:00-04:00 and 06:00-10:00 UTC, Mon-Fri); off-peak is half: $0.15 in, $0.003 cached, $0.60 out per 1M.' },
++ metadata: { org: 'deepseek', model: 'deepseek-flash', family: 'DeepSeek', tier: 'fast', reasoning: true, reasoning_reserve_tokens: 1536, configured_by: 'DEEPSEEK_API_KEY', pricing_note: 'Peak rates (01:00-04:00 and 06:00-10:00 UTC, Mon-Fri); off-peak is half: $0.15 in, $0.003 cached, $0.60 out per 1M.' },
},
{
model_key: 'deepseek-v4-pro', display_name: 'DeepSeek V4 Pro', type: 'chat',
context_window: 1048576, max_output: 393216,
supports: { json: true, tools: true, streaming: true, vision: false },
cost: { in_per_mtok: 1.32, out_per_mtok: 3.96, cached_per_mtok: 0.044, source: 'https://api-docs.deepseek.com/quick_start/pricing', verified_at: '2026-11-06' },
-- metadata: { org: 'deepseek', model: 'deepseek-v4-pro', family: 'DeepSeek', tier: 'strong', reasoning: true, configured_by: 'DEEPSEEK_API_KEY', pricing_note: 'Peak rates (01:00-04:00 and 06:00-10:00 UTC, Mon-Fri); off-peak is half: $0.66 in, $0.022 cached, $1.98 out per 1M.' },
++ metadata: { org: 'deepseek', model: 'deepseek-v4-pro', family: 'DeepSeek', tier: 'strong', reasoning: true, reasoning_reserve_tokens: 1536, configured_by: 'DEEPSEEK_API_KEY', pricing_note: 'Peak rates (01:00-04:00 and 06:00-10:00 UTC, Mon-Fri); off-peak is half: $0.66 in, $0.022 cached, $1.98 out per 1M.' },
},
];
ddiff --git a/test/deepseek-flash.test.js b/test/deepseek-flash.test.js
nnew file mode 100644
iindex 0000000..5660f75
---- /dev/null
++++ b/test/deepseek-flash.test.js
@@@ -0,0 +1,93 @@
++'use strict';
++// DeepSeek V4.1 Flash is a reasoning model: `max_tokens` is the whole allowance, and reasoning is spent
++// from it before content. A 48-token cap can therefore be all thinking, finish_reason 'length', content
++// empty and usage output 0. The real response (2026-11-07) keeps the answer in choices[0].message.content
++// and the thinking in reasoning_content; usage counts reasoning_tokens inside completion_tokens. The
++// adapter must read content, and the registry says how much room this model needs beyond the caller's cap.
++const assert = require('assert');
++const fixture = require('./fixtures/deepseek-flash.json');
++const { boot, suite, seamServer } = require('./helpers');
++const { createOpenAiProvider } = require('../server/providers/openai');
++
++const t = suite('deepseek-flash');
++const completion = fixture.completion;
++const answer = completion.choices[0].message.content;
++const MESSAGES = [{ role: 'user', content: 'Pick the one moment worth clipping.' }];
++const empty = {
++ model: 'deepseek-flash',
++ choices: [{ index: 0, message: { role: 'assistant', content: '', reasoning_content: 'Still thinking about the one moment.' }, finish_reason: 'length' }],
++ usage: { prompt_tokens: 9556, completion_tokens: 48, total_tokens: 9604, completion_tokens_details: { reasoning_tokens: 48 } },
++};
++let h;
++let srv;
++let route;
++
++t.test('the real DeepSeek shape: content is the answer, reasoning is not, output excludes reasoning tokens', async () => {
++ const s = await seamServer(() => completion);
++ try {
++ const a = createOpenAiProvider({ key: 'deepseek', base_url: `${s.url}/v1`, capabilities: ['chat'] }, { apiKey: 'k' });
++ const r = await a.chat({ model: 'deepseek-flash', messages: MESSAGES, maxTokens: 300, timeoutMs: 3000 });
++ assert.strictEqual(r.text, answer);
++ assert.ok(!r.text.includes('repeats the same scene'), 'reasoning_content never becomes the answer');
++ assert.strictEqual(r.usage.input, 9556);
++ assert.strictEqual(r.usage.output, 28, 'completion_tokens 182 minus 154 reasoning');
++ assert.strictEqual(r.usage.reasoning, 154);
++ } finally { await s.close(); }
++});
++
++t.test('boot a DeepSeek Flash offer', async () => {
++ srv = await seamServer((body) => (body.max_tokens >= 1000 ? completion : empty));
++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
++ await h.registry.upsertProvider({
++ key: 'deepseek', display_name: 'DeepSeek', kind: 'openai', status: 'active', base_url: `${srv.url}/v1`,
++ auth_mode: 'none', capabilities: ['chat', 'generate', 'json'], default_model: 'deepseek-flash', timeout_ms: 3000,
++ }, { actor: 'test', origin: 'admin' });
++ await h.registry.upsertModel({
++ provider_key: 'deepseek', model_key: 'deepseek-flash', display_name: 'DeepSeek V4.1 Flash', type: 'chat',
++ supports: { json: true, tools: true, streaming: true }, cost: { in_per_mtok: 0.3, out_per_mtok: 1.2 },
++ metadata: { reasoning: true, reasoning_reserve_tokens: 1536 },
++ }, { actor: 'test' });
++ route = await h.registry.createRouteVersion('test.deepseek-flash', { capability: 'chat', timeout_ms: 3000 }, { actor: 'test' });
++});
++
++t.test('a 48-token caller cap gets the model\'s reasoning room and answers', async () => {
++ const out = await h.pool.execute(route, 'chat', { messages: MESSAGES, maxTokens: 48, timeoutMs: 3000 },
++ { routeKey: route.key, routeVersion: route.version });
++ assert.strictEqual(srv.last.max_tokens, 48 + 1536, 'the caller\'s 48 plus the registry reserve');
++ assert.strictEqual(out.provider, 'deepseek');
++ assert.strictEqual(out.result.text, answer);
++ assert.strictEqual(out.usage.output, 28);
++});
++
++t.test('the Exchange chat path (complete) gets the same reasoning room', async () => {
++ const out = await h.pool.execute(route, 'complete', { openai: { messages: MESSAGES }, maxTokens: 48, timeoutMs: 3000 },
++ { routeKey: route.key, routeVersion: route.version });
++ assert.strictEqual(srv.last.max_tokens, 48 + 1536);
++ assert.strictEqual(out.result.text, answer);
++});
++
++t.test('without the reserve the same 48 tokens are all reasoning and the answer is empty', async () => {
++ await h.registry.upsertModel({
++ provider_key: 'deepseek', model_key: 'deepseek-flash', display_name: 'DeepSeek V4.1 Flash', type: 'chat',
++ supports: { json: true, tools: true, streaming: true }, cost: { in_per_mtok: 0.3, out_per_mtok: 1.2 },
++ metadata: { reasoning: true },
++ }, { actor: 'test' });
++ const out = await h.pool.execute(route, 'chat', { messages: MESSAGES, maxTokens: 48, timeoutMs: 3000 },
++ { routeKey: route.key, routeVersion: route.version });
++ assert.strictEqual(srv.last.max_tokens, 48, 'no reserve means no extra room');
++ assert.strictEqual(out.result.text, '', 'the reproduction of the production run');
++ assert.strictEqual(out.usage.output, 0);
++});
++
++t.test('the seed puts the reasoning room on the model, not on every provider', async () => {
++ const b = await boot({ env: { AI_STUB_FALLBACK: 'false', DEEPSEEK_API_KEY: 'test-key', DEEPSEEK_KEY: 'test-key' } });
++ try {
++ const m = await b.registry.getModel('deepseek', 'deepseek-flash');
++ assert.ok(m, 'the seed creates DeepSeek Flash');
++ assert.strictEqual(m.metadata.reasoning_reserve_tokens, 1536);
++ } finally { await b.stop(); }
++});
++
++t.test('stop', async () => { await h.stop(); await srv.close(); });
++
++t.run();
ddiff --git a/test/exchange-observed.test.js b/test/exchange-observed.test.js
iindex 6f795c3..549f8be 100644
---- a/test/exchange-observed.test.js
++++ b/test/exchange-observed.test.js
@@@ -1,7 +1,7 @@
'use strict';
--// The catalog's uptime is per real provider attempt, not per run status: a failed run counts against an
--// offer only when an attempt row names it, and an ok attempt is a success even when the run afterwards
--// failed validation (output.empty). Uptime and speed need 20 samples; below that they are null.
++// The catalog's uptime is per real provider attempt, not per run status. A failed run counts against an
++// offer only when an attempt row names it; an ok attempt is a success only when it produced output (not an
++// output.empty run, and not a text attempt that spent every token on reasoning). 20 samples before a number.
const assert = require('assert');
const { boot, request, suite } = require('./helpers');
@@@ -15,16 +15,17 @@ const clock = { now: () => nowMs };
const at = (n) => new Date(nowMs - n * 86400000).toISOString();
const uid = (p) => `${p}_${Date.now()}_${seq++}`;
--async function insertRun({ id = uid('run'), status = 'succeeded', provider = null, model = null, created = at(0) } = {}) {
++async function insertRun({ id = uid('run'), status = 'succeeded', provider = null, model = null, errorCode = null, created = at(0) } = {}) {
await h.db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, status, requester_type, requester_id, input, input_hash,
-- provider_key, model_key, created_at, started_at, finished_at)
-- VALUES (?, 'ai.complete', 1, ?, 'service', 'test', '{}', 'input-hash', ?, ?, ?, ?, ?)`)
-- .run(id, status, provider, model, created, created, created);
++ provider_key, model_key, error_code, created_at, started_at, finished_at)
++ VALUES (?, 'ai.complete', 1, ?, 'service', 'test', '{}', 'input-hash', ?, ?, ?, ?, ?, ?)`)
++ .run(id, status, provider, model, errorCode, created, created, created);
return id;
}
--async function insertRequest({ runId, provider = 'deepseek', model = 'deepseek-flash', status = 'ok', latency = 100, created = at(0), seqNo = 0 }) {
-- await h.db.prepare(`INSERT INTO requests (run_id, seq, operation, provider_key, model_key, status, latency_ms, created_at)
-- VALUES (?, ?, 'chat', ?, ?, ?, ?, ?)`).run(runId, seqNo, provider, model, status, latency, created);
++async function insertRequest({ runId, provider = 'deepseek', model = 'deepseek-flash', status = 'ok', operation = 'chat',
++ tokensOut = 7, latency = 100, created = at(0), seqNo = 0 }) {
++ await h.db.prepare(`INSERT INTO requests (run_id, seq, operation, provider_key, model_key, status, tokens_out, latency_ms, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(runId, seqNo, operation, provider, model, status, tokensOut, latency, created);
}
async function reset() {
await h.db.prepare('DELETE FROM requests').run();
@@@ -72,7 +73,7 @@ t.test('a failed run that reached no provider counts against no offer', async ()
t.test('a failed run counts against the offer its failed attempt names', async () => {
await reset();
const failed = await insertRun({ status: 'failed' });
-- await insertRequest({ runId: failed, status: 'error', latency: 900 });
++ await insertRequest({ runId: failed, status: 'error', tokensOut: 0, latency: 900 });
for (let i = 0; i < 19; i++) {
const id = await insertRun();
await insertRequest({ runId: id });
@@@ -83,23 +84,29 @@ t.test('a failed run counts against the offer its failed attempt names', async (
assert.strictEqual(offer.uptime, 0.95, '19 answered attempts and 1 failed attempt');
});
--t.test('an ok attempt is a success even when its run failed validation', async () => {
++t.test('an ok attempt that produced no output is a failure, not a success', async () => {
await reset();
-- // Every run failed with output.empty, but every provider attempt answered ok (production's 8 Flash runs).
-- for (let i = 0; i < 20; i++) {
-- const id = await insertRun({ status: 'failed' });
-- await insertRequest({ runId: id });
++ // 10 runs ended output.empty; their provider answered but produced nothing usable.
++ for (let i = 0; i < 10; i++) {
++ const id = await insertRun({ status: 'failed', errorCode: 'output.empty' });
++ await insertRequest({ runId: id, tokensOut: 0 });
++ }
++ // 10 more spent every output token on reasoning: tokens_out 0 with no content.
++ for (let i = 0; i < 10; i++) {
++ const id = await insertRun();
++ await insertRequest({ runId: id, operation: 'generate', tokensOut: 0 });
}
const offer = flash(await catalog());
assert.strictEqual(offer.requests_7d, 20);
-- assert.strictEqual(offer.uptime, 1, '20 answered attempts, 0 failed attempts');
++ assert.strictEqual(offer.uptime, 0, '20 ok-but-empty Flash attempts are 20 failures');
++ assert.deepStrictEqual(offer.speed, { p50_ms: null, p95_ms: null }, 'an empty attempt is not a speed sample');
});
t.test('8 successes and 0 real failures are 100% only once 20 samples exist', async () => {
await reset();
for (let i = 0; i < 8; i++) {
-- const id = await insertRun({ status: 'failed' });
++ const id = await insertRun();
await insertRequest({ runId: id });
}
let offer = flash(await catalog());
ddiff --git a/test/exchange-read.test.js b/test/exchange-read.test.js
iindex d17d438..6ada329 100644
---- a/test/exchange-read.test.js
++++ b/test/exchange-read.test.js
@@@ -21,9 +21,9 @@ async function insertRun({ id = uid('run'), status = 'succeeded', provider = nul
.run(id, status, type, requester, provider, model, cachedFrom, tokensIn, tokensOut, cost, synthetic, created, created, created);
return id;
}
--async function insertRequest({ runId, provider, model, status = 'ok', latency = 100, created = at(0), seqNo = 0 }) {
-- await h.db.prepare(`INSERT INTO requests (run_id, seq, operation, provider_key, model_key, status, latency_ms, created_at)
-- VALUES (?, ?, 'chat', ?, ?, ?, ?, ?)`).run(runId, seqNo, provider, model, status, latency, created);
++async function insertRequest({ runId, provider, model, status = 'ok', tokensOut = 100, latency = 100, created = at(0), seqNo = 0 }) {
++ await h.db.prepare(`INSERT INTO requests (run_id, seq, operation, provider_key, model_key, status, tokens_out, latency_ms, created_at)
++ VALUES (?, ?, 'chat', ?, ?, ?, ?, ?, ?)`).run(runId, seqNo, provider, model, status, tokensOut, latency, created);
}
async function insertReceipt({ runId, body }) {
await h.db.prepare(`INSERT INTO ai_receipts (id, run_id, body) VALUES (?, ?, ?::jsonb)`)
ddiff --git a/test/fixtures/deepseek-flash.json b/test/fixtures/deepseek-flash.json
nnew file mode 100644
iindex 0000000..f9578d4
---- /dev/null
++++ b/test/fixtures/deepseek-flash.json
@@@ -0,0 +1,31 @@
++{
++ "_comment": "The shape DeepSeek V4.1 Flash returned on 2026-11-07 (one real call, 9.5k-token prompt, max_tokens 300): the answer is in choices[0].message.content and the thinking in reasoning_content, with reasoning_tokens counted inside completion_tokens. Identifiers are redacted.",
++ "completion": {
++ "id": "chatcmpl-redacted",
++ "object": "chat.completion",
++ "created": 0,
++ "model": "deepseek-flash",
++ "choices": [
++ {
++ "index": 0,
++ "message": {
++ "role": "assistant",
++ "content": "{\"at\":\"0:00\",\"why\":\"All repeated descriptions are identical, so the beginning is the only identifiable moment to clip.\"}",
++ "reasoning_content": "The prompt repeats the same scene; the only identifiable moment is the start. I should return strict JSON with one sentence."
++ },
++ "logprobs": null,
++ "finish_reason": "stop"
++ }
++ ],
++ "usage": {
++ "prompt_tokens": 9556,
++ "completion_tokens": 182,
++ "total_tokens": 9738,
++ "prompt_tokens_details": { "cached_tokens": 0 },
++ "completion_tokens_details": { "reasoning_tokens": 154 },
++ "prompt_cache_hit_tokens": 0,
++ "prompt_cache_miss_tokens": 9556
++ },
++ "system_fingerprint": "redacted"
++ }
++}