This commit is contained in:
@@ -88,6 +88,16 @@ describe('callReflectionLlm', () => {
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('reconstructs raw with the resolved tool_call + usage (#500)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okStream()));
|
||||
const result = await callReflectionLlm(cfg, 'system', 'user');
|
||||
const raw = result.raw as { usage?: unknown; choices?: Array<{ message?: { tool_calls?: Array<{ function?: { name?: string; arguments?: string } }> } }> };
|
||||
expect(raw.usage).toEqual({ prompt_tokens: 42, completion_tokens: 17 });
|
||||
const tc = raw.choices?.[0]?.message?.tool_calls?.[0]?.function;
|
||||
expect(tc?.name).toBe('submit_reflection');
|
||||
expect(JSON.parse(tc!.arguments!)).toMatchObject({ reasoning: 'x' });
|
||||
});
|
||||
|
||||
it('retries a 5xx (backend tool-call parse failure) and succeeds on resample', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(httpError(500, '{"error":{"message":"Failed to parse input at pos 41"}}'))
|
||||
|
||||
@@ -134,6 +134,7 @@ async function callOnce(
|
||||
let usage: { prompt_tokens: number; completion_tokens: number } | undefined;
|
||||
let errorMsg: string | null = null;
|
||||
let errorGatewayType: string | undefined;
|
||||
let backendId: string | undefined;
|
||||
|
||||
for await (const event of client.chat(
|
||||
messages,
|
||||
@@ -148,6 +149,8 @@ async function callOnce(
|
||||
}
|
||||
} else if (event.type === 'done') {
|
||||
usage = event.usage;
|
||||
} else if (event.type === 'backend') {
|
||||
backendId = event.backendId;
|
||||
} else if (event.type === 'error') {
|
||||
errorMsg = event.error;
|
||||
errorGatewayType = event.gatewayErrorType;
|
||||
@@ -173,6 +176,15 @@ async function callOnce(
|
||||
tokensIn: usage?.prompt_tokens ?? 0,
|
||||
tokensOut: usage?.completion_tokens ?? 0,
|
||||
durationMs: Date.now() - start,
|
||||
raw: { usage },
|
||||
// Reconstruct the OpenAI response shape so `raw` keeps debugging fidelity
|
||||
// after the move to the streaming client (issue #500): the resolved
|
||||
// tool_call, usage, and (proxy) backend id rather than just `{ usage }`.
|
||||
raw: {
|
||||
usage,
|
||||
backendId,
|
||||
choices: [
|
||||
{ message: { tool_calls: [{ function: { name: 'submit_reflection', arguments: JSON.stringify(parsed) } }] } },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ToolDef } from '../../llm/openai-compat.js';
|
||||
import type { ToolContext, ToolResult } from './core.js';
|
||||
import { resolveAndGuard, resolveOutputPathWithin } from './core.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { recordLlmUsage } from '../../llm/usage-recorder.js';
|
||||
|
||||
// --- Supported image extensions ---
|
||||
|
||||
@@ -310,7 +311,9 @@ export async function callVisionModel(
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
model?: string;
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
||||
};
|
||||
|
||||
const content = json.choices?.[0]?.message?.content;
|
||||
@@ -318,6 +321,20 @@ export async function callVisionModel(
|
||||
return { output: 'Vision API returned no content', isError: true };
|
||||
}
|
||||
|
||||
// This vision call is a raw fetch outside OpenAICompatClient, so it would
|
||||
// otherwise miss the per-user usage ledger. Record it directly (issue
|
||||
// #499). Vision uses its own endpoint directly (never the gateway).
|
||||
let route = 'unknown';
|
||||
try { route = new URL(visionBaseUrl).host || 'unknown'; } catch { /* keep 'unknown' */ }
|
||||
recordLlmUsage({
|
||||
userId: ctx.userId ?? 'local',
|
||||
source: 'direct',
|
||||
model: json.model || visionModel,
|
||||
route,
|
||||
tokensIn: json.usage?.prompt_tokens ?? 0,
|
||||
tokensOut: json.usage?.completion_tokens ?? 0,
|
||||
});
|
||||
|
||||
return { output: content, isError: false };
|
||||
} catch (e) {
|
||||
if ((e as Error).name === 'AbortError') {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* ReadImage vision call records to the per-user usage ledger (issue #499).
|
||||
* The vision call is a raw fetch outside OpenAICompatClient, so it records
|
||||
* directly via recordLlmUsage.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { callVisionModel } from './image.js';
|
||||
import type { ToolContext } from './core.js';
|
||||
import { setLlmUsageRecorder, type LlmUsageEvent } from '../../llm/usage-recorder.js';
|
||||
|
||||
function ctx(): ToolContext {
|
||||
return {
|
||||
workspacePath: '/tmp',
|
||||
userId: 'u1',
|
||||
toolsConfig: { visionBaseUrl: 'http://vision-host:11434/v1', visionModel: 'qwen2-vl' },
|
||||
} as unknown as ToolContext;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setLlmUsageRecorder(null);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('callVisionModel usage recording', () => {
|
||||
it('records a direct usage event with the vision host as route', async () => {
|
||||
const events: LlmUsageEvent[] = [];
|
||||
setLlmUsageRecorder((e) => events.push(e));
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
model: 'qwen2-vl',
|
||||
choices: [{ message: { content: 'a cat' } }],
|
||||
usage: { prompt_tokens: 250, completion_tokens: 12 },
|
||||
}),
|
||||
}));
|
||||
|
||||
const res = await callVisionModel('data:image/png;base64,xxx', 'describe', ctx());
|
||||
expect(res.isError).toBe(false);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toEqual({
|
||||
userId: 'u1', source: 'direct', model: 'qwen2-vl', route: 'vision-host:11434',
|
||||
tokensIn: 250, tokensOut: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it('records zero tokens when the vision response omits usage', async () => {
|
||||
const events: LlmUsageEvent[] = [];
|
||||
setLlmUsageRecorder((e) => events.push(e));
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ choices: [{ message: { content: 'x' } }] }),
|
||||
}));
|
||||
await callVisionModel('data:image/png;base64,xxx', '', ctx());
|
||||
expect(events[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, model: 'qwen2-vl' });
|
||||
});
|
||||
|
||||
it('does not record on a vision API error', async () => {
|
||||
const events: LlmUsageEvent[] = [];
|
||||
setLlmUsageRecorder((e) => events.push(e));
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' }));
|
||||
const res = await callVisionModel('data:image/png;base64,xxx', '', ctx());
|
||||
expect(res.isError).toBe(true);
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -452,6 +452,10 @@ export class OpenAICompatClient {
|
||||
// per attempt; only the attempt that reaches `done` records.
|
||||
let observedModel = '';
|
||||
let observedBackendId = '';
|
||||
// Whether we saw at least one real (non-error) SSE chunk. An EOF
|
||||
// that closes the stream without any chunk and without [DONE] is an
|
||||
// abnormal completion we must NOT record as a request (issue #498).
|
||||
let sawChunk = false;
|
||||
|
||||
try {
|
||||
response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
@@ -616,10 +620,15 @@ export class OpenAICompatClient {
|
||||
};
|
||||
return;
|
||||
}
|
||||
// Unknown error.type: keep parsing (it may be followed by real
|
||||
// content). It is NOT a real chunk, so it doesn't flip
|
||||
// `sawChunk` — an error-only stream that then EOFs without
|
||||
// [DONE] stays unrecorded (issue #498).
|
||||
}
|
||||
|
||||
// usage (stream_options で末尾チャンクに付く)
|
||||
if (chunk['usage'] != null) {
|
||||
sawChunk = true; // a real completion payload
|
||||
const u = chunk['usage'] as Record<string, unknown>;
|
||||
usage = {
|
||||
prompt_tokens: (u['prompt_tokens'] as number) ?? 0,
|
||||
@@ -641,6 +650,7 @@ export class OpenAICompatClient {
|
||||
|
||||
const choices = chunk['choices'] as Array<Record<string, unknown>> | undefined;
|
||||
if (!choices || choices.length === 0) continue;
|
||||
sawChunk = true; // a real content/finish chunk
|
||||
|
||||
const choice = choices[0] as Record<string, unknown>;
|
||||
const delta = choice['delta'] as Record<string, unknown> | undefined;
|
||||
@@ -737,9 +747,11 @@ export class OpenAICompatClient {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// [DONE] なしにストリームが終了した場合
|
||||
// [DONE] なしにストリームが終了した場合。チャンクを1つも受け取らずに
|
||||
// EOF した「不明完了」は requests に数えない (issue #498)。明示的な
|
||||
// [DONE] 経路は従来どおり常に記録する。
|
||||
yield* drainToolCalls(toolCallAccumulators);
|
||||
this.finalizeDone(usage, observedModel, observedBackendId, context);
|
||||
if (sawChunk) this.finalizeDone(usage, observedModel, observedBackendId, context);
|
||||
yield { type: 'done', usage };
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import { logger } from '../logger.js';
|
||||
* construction. This is the single chokepoint the design relies on to
|
||||
* avoid the propagation leaks this codebase has repeatedly hit.
|
||||
*
|
||||
* Exception: the ReadImage vision tool (engine/tools/image.ts) issues a
|
||||
* raw, non-streaming fetch to its own vision endpoint rather than going
|
||||
* through OpenAICompatClient, so it calls recordLlmUsage() directly.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||
*/
|
||||
export interface LlmUsageEvent {
|
||||
|
||||
@@ -123,3 +123,58 @@ describe('LLM usage recording', () => {
|
||||
await expect(drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' })).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- issue #498: don't record unknown completions ---
|
||||
|
||||
/** Raw SSE response with full control over whether [DONE] is sent. */
|
||||
function rawSse(chunks: unknown[], withDone: boolean): Response {
|
||||
const lines = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`);
|
||||
if (withDone) lines.push('data: [DONE]\n\n');
|
||||
const encoder = new TextEncoder();
|
||||
let i = 0;
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
body: { getReader: () => ({
|
||||
read: async () => (i < lines.length ? { done: false, value: encoder.encode(lines[i++]) } : { done: true, value: undefined }),
|
||||
releaseLock: () => {},
|
||||
}) },
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
async function collect(client: OpenAICompatClient, ctx?: { userId?: string }) {
|
||||
const out: string[] = [];
|
||||
for await (const e of client.chat([{ role: 'user', content: 'q' }], undefined, undefined, ctx)) out.push(e.type);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('LLM usage recording — unknown completions (#498)', () => {
|
||||
it('does NOT record an EOF with no chunks and no [DONE]', async () => {
|
||||
const events: LlmUsageEvent[] = [];
|
||||
setLlmUsageRecorder((e) => events.push(e));
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rawSse([], false)));
|
||||
const types = await collect(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' });
|
||||
expect(events).toHaveLength(0);
|
||||
expect(types[types.length - 1]).toBe('done'); // still terminates gracefully
|
||||
});
|
||||
|
||||
it('DOES record an EOF (no [DONE]) once a real chunk arrived', async () => {
|
||||
const events: LlmUsageEvent[] = [];
|
||||
setLlmUsageRecorder((e) => events.push(e));
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rawSse([textChunk('m'), usageChunk(3, 2)], false)));
|
||||
await drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' });
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ tokensIn: 3, tokensOut: 2 });
|
||||
});
|
||||
|
||||
it('does NOT record an error-only stream that EOFs without [DONE]', async () => {
|
||||
// An unknown error payload alone is not a real chunk; if the stream then
|
||||
// closes without [DONE], it must not be counted as a request.
|
||||
const events: LlmUsageEvent[] = [];
|
||||
setLlmUsageRecorder((e) => events.push(e));
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rawSse([{ error: { type: 'mystery', message: 'kaboom' } }], false)));
|
||||
const types = await collect(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' });
|
||||
expect(events).toHaveLength(0);
|
||||
expect(types[types.length - 1]).toBe('done'); // unknown error falls through, EOF → done
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user