diff --git a/lib/outside-voice-api.ts b/lib/outside-voice-api.ts index f5c6d17e2..a432cc439 100644 --- a/lib/outside-voice-api.ts +++ b/lib/outside-voice-api.ts @@ -11,9 +11,12 @@ import { sha256Hex, writeReceipt } from './egress-receipt'; import { modelFamily, type ModelFamily } from './outside-voice-runner'; -export interface ApiCallRequest { model: string; prompt: string; timeoutMs: number; env?: Record; signal?: AbortSignal; maxTokens?: number } +/** Who sends and on whose consent: replaces the outside-voice receipt for a caller with its own sink (lib/autoplan-shadow.ts). */ +export interface ApiReceipt { sink: string; consent: string; payloadClass?: string } +export interface ApiCallRequest { model: string; prompt: string; timeoutMs: number; env?: Record; signal?: AbortSignal; maxTokens?: number; receipt?: ApiReceipt } export interface ApiUsage { input_tokens?: number; output_tokens?: number } -export interface ApiCallResult { text: string; model: string; usage: ApiUsage; provider: 'openai' | 'anthropic'; status: number } +/** `stop_reason`: the provider's stop or finish reason when it sent one (Anthropic `max_tokens`, OpenAI `length`: the reply was cut at the output limit). */ +export interface ApiCallResult { text: string; model: string; usage: ApiUsage; provider: 'openai' | 'anthropic'; status: number; stop_reason?: string } export const API_FAMILIES: readonly ModelFamily[] = ['openai', 'anthropic']; @@ -23,11 +26,11 @@ export function apiKeyFor(family: ModelFamily, env: Record, body: string, req: ApiCallRequest, provider: string): Promise<{ status: number; json: any }> { - receipted(url, body, provider); + receipted(url, body, provider, req.receipt); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(new Error(`timeout after ${req.timeoutMs} ms`)), req.timeoutMs); req.signal?.addEventListener('abort', () => controller.abort(req.signal!.reason), { once: true }); @@ -61,13 +64,15 @@ export async function callModel(req: ApiCallRequest): Promise { const { status, json } = await post(`${base}/v1/messages`, { 'x-api-key': auth.key, 'anthropic-version': '2023-06-01' }, body, req, 'anthropic'); if (status < 200 || status >= 300) throw new Error(`anthropic ${status}: ${json?.error?.message ?? json?.raw ?? JSON.stringify(json)}`); const text = Array.isArray(json?.content) ? json.content.filter((c: any) => c?.type === 'text').map((c: any) => c.text).join('\n') : ''; - return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.input_tokens, output_tokens: json?.usage?.output_tokens }, provider: 'anthropic', status }; + const stop = typeof json?.stop_reason === 'string' ? json.stop_reason : undefined; + return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.input_tokens, output_tokens: json?.usage?.output_tokens }, provider: 'anthropic', status, ...(stop ? { stop_reason: stop } : {}) }; } const base = (env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1').replace(/\/$/, ''); - const body = JSON.stringify({ model, messages: [{ role: 'user', content: req.prompt }] }); + const body = JSON.stringify({ model, ...(req.maxTokens !== undefined ? { max_completion_tokens: req.maxTokens } : {}), messages: [{ role: 'user', content: req.prompt }] }); const { status, json } = await post(`${base}/chat/completions`, { authorization: `Bearer ${auth.key}` }, body, req, 'openai'); if (status < 200 || status >= 300) throw new Error(`openai ${status}: ${json?.error?.message ?? json?.raw ?? JSON.stringify(json)}`); const content = json?.choices?.[0]?.message?.content; const text = typeof content === 'string' ? content : Array.isArray(content) ? content.map((c: any) => c?.text ?? '').join('\n') : ''; - return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.prompt_tokens, output_tokens: json?.usage?.completion_tokens }, provider: 'openai', status }; + const finish = json?.choices?.[0]?.finish_reason; + return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.prompt_tokens, output_tokens: json?.usage?.completion_tokens }, provider: 'openai', status, ...(typeof finish === 'string' ? { stop_reason: finish } : {}) }; }