UNPKG

@jovan158/vantage

Version:

See and control what your coding agent does

329 lines (328 loc) • 13.1 kB
// OpenAI's two API shapes, as the agents send them: // // Responses POST …/responses — Codex (over HTTP or a WebSocket), // OpenCode and pi for OpenAI models, Copilot for GPT // Chat Completions POST …/chat/completions — the shape most other // providers copy (OpenRouter, Groq, DeepSeek, xAI, …) // // Both count cached input inside the input tokens; Vantage keeps them apart, // as Anthropic reports them, so prices and cache rates stay comparable. // Reasoning tokens are already inside the output tokens. import { MAX_INPUT, MAX_TEXT, contentToText, lastUserText, toolTarget, truncate, } from "../turn.js"; import { sseParser } from "./sse.js"; function emptyUsage(model = null) { return { model, input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }; } export function applyOpenAiUsage(u, from) { if (!from || typeof from !== "object") return; const input = from.input_tokens ?? from.prompt_tokens; const output = from.output_tokens ?? from.completion_tokens; const cached = from.input_tokens_details?.cached_tokens ?? from.prompt_tokens_details?.cached_tokens ?? from.cache_read_input_tokens ?? 0; const written = from.cache_creation_input_tokens ?? 0; if (typeof input === "number") u.input_tokens = Math.max(0, input - cached - written); if (typeof output === "number") u.output_tokens = output; u.cache_read_input_tokens = cached; u.cache_creation_input_tokens = written; } function parseArgs(raw) { if (typeof raw !== "string") return raw; try { return JSON.parse(raw); } catch { return raw; } } function toolCall(name, input) { const target = toolTarget(input); const preview = typeof input === "string" ? input : JSON.stringify(input ?? {}); return { name, inputPreview: truncate(preview, MAX_INPUT), ...(target ? { target } : {}) }; } // A tool call in the output, as Vantage names it. Hosted tools (web search) // and Codex's own shell item have no name of their own. export function responseItemCall(item) { switch (item.type) { case "function_call": return toolCall(item.name ?? "tool", parseArgs(item.arguments)); case "custom_tool_call": return toolCall(item.name ?? "tool", { input: item.input ?? "" }); case "local_shell_call": return toolCall("shell", { command: item.action?.command }); case "mcp_call": return toolCall(`mcp__${item.server_label ?? "server"}__${item.name ?? "tool"}`, parseArgs(item.arguments)); case "web_search_call": return toolCall("web_search", { query: item.action?.query, url: item.action?.url }); default: return null; } } export function createResponsesTurn() { const usage = emptyUsage(); let text = ""; let stopReason = null; const tools = []; let sawItems = false; const finish = (r) => { if (!r) return; if (r.model) usage.model = r.model; applyOpenAiUsage(usage, r.usage); stopReason = r.incomplete_details?.reason ?? r.status ?? stopReason; // A response without streamed items (some gateways) still lists them. if (!sawItems) for (const item of r.output ?? []) addItem(item); }; const addItem = (item) => { const call = responseItemCall(item); if (call) tools.push(call); else if (item.type === "message" && !text) { text = (item.content ?? []).map((c) => (c.type === "output_text" ? c.text ?? "" : "")).join(""); } }; return { handle(raw) { if (!raw || typeof raw !== "object") return false; const e = raw; switch (e.type) { case "response.created": case "response.in_progress": if (e.response?.model) usage.model = e.response.model; return false; case "response.output_text.delta": if (typeof e.delta === "string" && text.length < MAX_TEXT * 4) text += e.delta; return false; case "response.output_item.done": if (e.item) { sawItems = true; const call = responseItemCall(e.item); if (call) tools.push(call); } return false; case "response.completed": case "response.done": case "response.incomplete": case "response.failed": finish(e.response); return true; default: return false; } }, result() { return { model: usage.model, stopReason, text: truncate(text, MAX_TEXT), tools, usage }; }, }; } export function createResponsesExtractor() { const turn = createResponsesTurn(); const sse = sseParser((e) => void turn.handle(e)); return { feed: (chunk) => sse.feed(chunk), end() { sse.end(); return turn.result(); }, }; } export function responsesTurnFromJson(body) { let json; try { json = JSON.parse(body); } catch { return null; } if (!json || typeof json !== "object" || (!json.usage && !json.output)) return null; const turn = createResponsesTurn(); turn.handle({ type: "response.completed", response: json }); return turn.result(); } function inputItems(body) { const input = body?.input; if (typeof input === "string") return [{ type: "message", role: "user", content: input }]; return Array.isArray(input) ? input.filter((i) => i && typeof i === "object") : []; } // The tools a request offers: its "tools", or — Codex — an input item of // type "additional_tools". function offersTools(body) { const tools = body.tools; if (Array.isArray(tools) && tools.length > 0) return true; return inputItems(body).some((i) => i.type === "additional_tools" && Array.isArray(i.tools) && i.tools.length > 0); } export function responsesRequestInfo(body) { if (!body || typeof body !== "object") return { prompt: null, background: false }; const messages = inputItems(body).filter((i) => (i.type ?? "message") === "message"); // A request that continues an earlier response (Codex over a WebSocket) // sends only what is new; the tools came with the first one. const continues = typeof body.previous_response_id === "string"; return { prompt: lastUserText(messages), background: !offersTools(body) && !continues, }; } function outputText(output) { if (typeof output === "string") return output; return contentToText(output); } export function responsesParts(body, agent) { if (!body || typeof body !== "object") return []; const parts = []; const instructions = body.instructions; if (typeof instructions === "string") parts.push({ key: "system", text: instructions, source: "the system prompt" }); const calls = new Map(); for (const item of inputItems(body)) { const type = item.type ?? "message"; if (type === "message") { const text = contentToText(item.content); const role = item.role ?? "user"; if (role === "system" || role === "developer") parts.push({ key: `system:${role}`, text, source: "the system prompt" }); else if (role === "assistant") parts.push({ key: "text:assistant", text, source: `${agent}'s reply` }); else parts.push({ key: "text:user", text, source: "your message" }); } else if (type === "function_call" || type === "custom_tool_call" || type === "local_shell_call") { const call = responseItemCall(item); const label = call ? `${call.name}${call.target ? ` ${call.target}` : ""}` : "tool"; if (item.call_id) calls.set(item.call_id, label); const raw = type === "custom_tool_call" ? item.input ?? "" : type === "local_shell_call" ? JSON.stringify(item.action ?? {}) : item.arguments ?? ""; parts.push({ key: `use:${item.call_id ?? ""}`, text: stringValues(parseArgs(raw)), source: `${agent}'s ${label} call` }); } else if (type.endsWith("_output")) { parts.push({ key: `result:${item.call_id ?? ""}`, text: outputText(item.output), source: `the output of ${calls.get(item.call_id ?? "") ?? "a tool"}`, }); } } return parts; } export function createChatExtractor() { const usage = emptyUsage(); let text = ""; let stopReason = null; const pending = new Map(); const sse = sseParser((raw) => { const c = raw; if (!c || typeof c !== "object") return; if (c.model) usage.model = c.model; if (c.usage) applyOpenAiUsage(usage, c.usage); for (const choice of c.choices ?? []) { const d = choice.delta; if (typeof d?.content === "string" && text.length < MAX_TEXT * 4) text += d.content; for (const t of d?.tool_calls ?? []) { const idx = t.index ?? 0; const cur = pending.get(idx) ?? { name: "", args: "" }; if (t.function?.name) cur.name += t.function.name; if (t.function?.arguments) cur.args += t.function.arguments; pending.set(idx, cur); } if (choice.finish_reason) stopReason = choice.finish_reason; } }); return { feed: (chunk) => sse.feed(chunk), end() { sse.end(); const tools = [...pending.entries()].sort(([a], [b]) => a - b).map(([, t]) => toolCall(t.name || "tool", parseArgs(t.args))); return { model: usage.model, stopReason, text: truncate(text, MAX_TEXT), tools, usage }; }, }; } export function chatTurnFromJson(body) { let json; try { json = JSON.parse(body); } catch { return null; } if (!json || typeof json !== "object" || (!json.usage && !json.choices)) return null; const usage = emptyUsage(json.model ?? null); applyOpenAiUsage(usage, json.usage); const choice = json.choices?.[0]; const tools = (choice?.message?.tool_calls ?? []).map((t) => toolCall(t.function?.name ?? "tool", parseArgs(t.function?.arguments))); return { model: usage.model, stopReason: choice?.finish_reason ?? null, text: truncate(contentToText(choice?.message?.content), MAX_TEXT), tools, usage, }; } export function chatParts(body, agent) { const messages = body?.messages; if (!Array.isArray(messages)) return []; const parts = []; const calls = new Map(); for (const m of messages) { if (!m || typeof m !== "object") continue; const text = contentToText(m.content); switch (m.role) { case "system": case "developer": parts.push({ key: "system", text, source: "the system prompt" }); break; case "assistant": parts.push({ key: "text:assistant", text, source: `${agent}'s reply` }); for (const t of m.tool_calls ?? []) { const call = toolCall(t.function?.name ?? "tool", parseArgs(t.function?.arguments)); const label = `${call.name}${call.target ? ` ${call.target}` : ""}`; if (t.id) calls.set(t.id, label); parts.push({ key: `use:${t.id ?? ""}`, text: stringValues(parseArgs(t.function?.arguments)), source: `${agent}'s ${label} call` }); } break; case "tool": parts.push({ key: `result:${m.tool_call_id ?? ""}`, text, source: `the output of ${calls.get(m.tool_call_id ?? "") ?? "a tool"}` }); break; default: parts.push({ key: "text:user", text, source: "your message" }); } } return parts; } // The text values inside a tool input, with their real line breaks — JSON // would escape them. export function stringValues(value, out = []) { if (typeof value === "string") out.push(value); else if (Array.isArray(value)) for (const v of value) stringValues(v, out); else if (value && typeof value === "object") for (const v of Object.values(value)) stringValues(v, out); return out.join("\n"); }