sync: update from private repo (0dd1a8e)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-11 11:50:39 +00:00
parent 3b1645cc91
commit c5be399fdd
15 changed files with 337 additions and 65 deletions
+10
View File
@@ -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"}}'))
+13 -1
View File
@@ -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) } }] } },
],
},
};
}
+17
View File
@@ -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);
});
});