/** * Pi test-completion — single-shot, non-streaming completion call. * * Iteration 1 of the pi harness: just enough to verify the saved sub-provider * + credentials actually reach an LLM and return text. Replaces the full * pi-ai streaming stack until we vendor it alongside the agent loop. * * Supported API flavors: * - openai-completions → POST {baseUrl}/chat/completions * - anthropic-messages → POST {baseUrl}/messages * - google-gemini → POST {baseUrl}/models/{modelId}:generateContent */ import { getPiSubProvider, type PiApiFlavor } from './sub-providers.js'; import { streamProvider } from './providers/stream.js'; import { toolDefsForProvider } from './tools/registry.js'; export interface PiTestCompletionInput { subProvider: string; apiKey?: string; baseUrl?: string; modelId?: string; prompt: string; } export interface PiTestCompletionResult { ok: boolean; text?: string; error?: string; status?: number; modelId?: string; subProvider?: string; } const REQUEST_TIMEOUT_MS = 30_000; async function timedFetch(url: string, init: RequestInit): Promise { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS); try { return await fetch(url, { ...init, signal: ctl.signal }); } finally { clearTimeout(timer); } } function pickBaseUrl(input: PiTestCompletionInput): string | undefined { if (input.baseUrl?.trim()) return input.baseUrl.replace(/\/+$/, ''); const def = getPiSubProvider(input.subProvider)?.baseUrl; return def?.replace(/\/+$/, ''); } function pickModelId(input: PiTestCompletionInput): string | undefined { if (input.modelId?.trim()) return input.modelId.trim(); return getPiSubProvider(input.subProvider)?.defaultModel; } export async function runPiTestCompletion(input: PiTestCompletionInput): Promise { const provider = getPiSubProvider(input.subProvider); if (!provider) { return { ok: false, error: `Unknown sub-provider: ${input.subProvider}` }; } const baseUrl = pickBaseUrl(input); if (!baseUrl) return { ok: false, error: 'Missing base URL' }; const modelId = pickModelId(input); if (!modelId) return { ok: false, error: 'Missing model ID' }; if (provider.needsApiKey && !input.apiKey?.trim()) { return { ok: false, error: 'Missing API key' }; } try { const text = await callByFlavor(provider.flavor, { baseUrl, modelId, apiKey: input.apiKey?.trim() || '', prompt: input.prompt, maxTokensField: provider.maxTokensField, }); return { ok: true, text, modelId, subProvider: provider.id }; } catch (err: any) { return { ok: false, error: err?.message || String(err), status: err?.status, modelId, subProvider: provider.id, }; } } /** * Streaming + tools probe (audit C-4). The non-streaming, tool-less test above * validates a contract no real turn uses — free-form model ids (Ollama, LM * Studio, custom, OpenRouter) could pass it and then fail the first actual * message, which streams SSE with the full tool schema attached. This probe * exercises the REAL wire shape in one cheap request: success = any * text/tool-call event arrives before an error does. */ export async function runPiStreamProbe(input: PiTestCompletionInput): Promise { const provider = getPiSubProvider(input.subProvider); if (!provider) return { ok: false, error: `Unknown sub-provider: ${input.subProvider}` }; const baseUrl = pickBaseUrl(input); if (!baseUrl) return { ok: false, error: 'Missing base URL' }; const modelId = pickModelId(input); if (!modelId) return { ok: false, error: 'Missing model ID' }; const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS); try { const stream = streamProvider(provider.flavor, { modelId, baseUrl, apiKey: input.apiKey?.trim() || '', systemPrompt: 'You are a connectivity probe. Reply with the single word OK.', messages: [{ role: 'user', content: [{ type: 'text', text: input.prompt || 'Reply with the single word OK.' }] }], // withTask: the live conversation's schema is the superset every real // turn sends — probe with the same shape (review PI-D-4). tools: toolDefsForProvider({ withTask: true }), // Generous: reasoning models burn output budget on hidden thinking first. maxOutputTokens: 2048, maxTokensField: provider.maxTokensField, includeStreamUsage: provider.noStreamUsage ? false : undefined, signal: ctl.signal, }); for await (const evt of stream) { if (evt.type === 'text_delta' || evt.type === 'tool_use') { return { ok: true, text: 'stream OK', modelId, subProvider: provider.id }; } if (evt.type === 'error') { return { ok: false, error: evt.error, modelId, subProvider: provider.id }; } } if (ctl.signal.aborted) { return { ok: false, error: `Stream probe timed out after ${REQUEST_TIMEOUT_MS / 1000}s.`, modelId, subProvider: provider.id }; } return { ok: false, error: 'The stream ended without producing any output.', modelId, subProvider: provider.id }; } catch (err: any) { const msg = err?.name === 'AbortError' ? `Stream probe timed out after ${REQUEST_TIMEOUT_MS / 1000}s.` : err?.message || String(err); return { ok: false, error: msg, modelId, subProvider: provider.id }; } finally { clearTimeout(timer); } } interface DispatchArgs { baseUrl: string; modelId: string; apiKey: string; prompt: string; /** openai-completions only — gpt-5.x/o-series reject the legacy max_tokens (C-2). */ maxTokensField?: 'max_tokens' | 'max_completion_tokens'; } async function callByFlavor(flavor: PiApiFlavor, args: DispatchArgs): Promise { switch (flavor) { case 'openai-completions': return callOpenAICompletions(args); case 'anthropic-messages': return callAnthropicMessages(args); case 'google-gemini': return callGoogleGemini(args); } } /* ── OpenAI / OpenAI-compatible ── */ async function callOpenAICompletions({ baseUrl, modelId, apiKey, prompt, maxTokensField }: DispatchArgs): Promise { const headers: Record = { 'content-type': 'application/json' }; if (apiKey) headers['authorization'] = `Bearer ${apiKey}`; // Reasoning models (gpt-5.x/o-series) spend the budget on hidden reasoning // first — 256 would come back as an empty message, failing a valid key. const capField = maxTokensField ?? 'max_tokens'; const res = await timedFetch(`${baseUrl}/chat/completions`, { method: 'POST', headers, body: JSON.stringify({ model: modelId, messages: [{ role: 'user', content: prompt }], [capField]: capField === 'max_completion_tokens' ? 2048 : 256, stream: false, }), }); if (!res.ok) throw await httpError(res); const body: any = await res.json(); const text = body?.choices?.[0]?.message?.content; if (typeof text !== 'string' || !text.trim()) { throw new Error(`Empty response (${JSON.stringify(body).slice(0, 200)})`); } return text.trim(); } /* ── Anthropic Messages API ── */ async function callAnthropicMessages({ baseUrl, modelId, apiKey, prompt }: DispatchArgs): Promise { const res = await timedFetch(`${baseUrl}/messages`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ model: modelId, max_tokens: 256, messages: [{ role: 'user', content: prompt }], }), }); if (!res.ok) throw await httpError(res); const body: any = await res.json(); const block = Array.isArray(body?.content) ? body.content.find((b: any) => b?.type === 'text') : null; const text = block?.text; if (typeof text !== 'string' || !text.trim()) { throw new Error(`Empty response (${JSON.stringify(body).slice(0, 200)})`); } return text.trim(); } /* ── Google Gemini ── */ async function callGoogleGemini({ baseUrl, modelId, apiKey, prompt }: DispatchArgs): Promise { const url = `${baseUrl}/models/${encodeURIComponent(modelId)}:generateContent?key=${encodeURIComponent(apiKey)}`; const res = await timedFetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: prompt }] }], generationConfig: { maxOutputTokens: 256 }, }), }); if (!res.ok) throw await httpError(res); const body: any = await res.json(); const parts: any[] = body?.candidates?.[0]?.content?.parts || []; const text = parts.map((p) => p?.text).filter(Boolean).join('\n').trim(); if (!text) throw new Error(`Empty response (${JSON.stringify(body).slice(0, 200)})`); return text; } /* ── Helpers ── */ async function httpError(res: Response): Promise { let detail = ''; try { detail = await res.text(); } catch {} const trimmed = detail.length > 400 ? `${detail.slice(0, 400)}…` : detail; const err: any = new Error(`HTTP ${res.status} ${res.statusText}${trimmed ? `: ${trimmed}` : ''}`); err.status = res.status; return err; }