mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
outside-voice-api: caller receipts, stop_reason, OpenAI max_completion_tokens
A caller can write its own egress receipt (sink, consent, payload class); the default stays the outside-voice receipt, so existing callers send and record the same bytes. The result carries the provider's stop reason, and an OpenAI request gets max_completion_tokens only when the caller sets an output limit (GST-537 PR 3). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
9cf405191c
commit
91cc4faf48
1 file changed
+14
-9
@@ -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<string, string | undefined>; 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<string, string | undefined>; 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<string, string | unde
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function receipted(url: string, body: string, provider: string): void {
|
||||
function receipted(url: string, body: string, provider: string, receipt: ApiReceipt | undefined): void {
|
||||
try {
|
||||
writeReceipt({
|
||||
sink: 'outside-voice-api', host: new URL(url).host, payloadClass: 'review-prompt', bytes: Buffer.byteLength(body), sha256: sha256Hex(body),
|
||||
consent: `user ran gstack-outside-voice --runner api (${provider.toUpperCase()}_API_KEY configured)`,
|
||||
sink: receipt?.sink ?? 'outside-voice-api', host: new URL(url).host, payloadClass: receipt?.payloadClass ?? 'review-prompt', bytes: Buffer.byteLength(body), sha256: sha256Hex(body),
|
||||
consent: receipt?.consent ?? `user ran gstack-outside-voice --runner api (${provider.toUpperCase()}_API_KEY configured)`,
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`[outside-voice] egress receipt could not be written (${(err as Error).message}); proceeding (fail-open)\n`);
|
||||
@@ -35,7 +38,7 @@ function receipted(url: string, body: string, provider: string): void {
|
||||
}
|
||||
|
||||
async function post(url: string, headers: Record<string, string>, 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<ApiCallResult> {
|
||||
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 } : {}) };
|
||||
}
|
||||
Reference in new issue
Block a user