Files
vibn-agent-runner/vibn-frontend/lib/ai/openai-compatible-chat.ts

538 lines
16 KiB
TypeScript

/**
* OpenAI Chat Completions-compatible backend (DeepSeek, etc.).
*
* DeepSeek: base URL + `/chat/completions`, Bearer key — see
* https://api-docs.deepseek.com/
*
* Tool schemas in Vibn are authored for Gemini (uppercase type enums).
* We normalize them to JSON Schema before sending.
*/
import type { ChatMessage, ToolCall, ToolDefinition, ChatChunk } from "./gemini-chat";
const DEFAULT_CHAT_URL = "https://api.deepseek.com/chat/completions";
function resolveApiKey(): string {
return (
process.env.DEEPSEEK_API_KEY?.trim() ||
process.env.VIBN_OPENAI_COMPATIBLE_API_KEY?.trim() ||
""
);
}
function resolveChatUrl(): string {
const raw = process.env.VIBN_OPENAI_COMPATIBLE_CHAT_URL?.trim();
if (raw) return raw.replace(/\/$/, "");
const base = process.env.VIBN_OPENAI_COMPATIBLE_BASE_URL?.trim().replace(
/\/$/,
"",
);
if (!base) return DEFAULT_CHAT_URL;
if (base.endsWith("/chat/completions")) return base;
return `${base}/chat/completions`;
}
function resolveModel(): string {
return (
process.env.VIBN_OPENAI_COMPATIBLE_MODEL?.trim() ||
process.env.DEEPSEEK_MODEL?.trim() ||
"deepseek-chat"
);
}
/** Gemini API Catalog-style schema → OpenAI JSON Schema */
function geminiStyleToJsonSchema(node: unknown): unknown {
if (node === null || typeof node !== "object" || Array.isArray(node))
return node;
const n = node as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(n)) {
if (key === "type" && typeof val === "string") {
const map: Record<string, string> = {
OBJECT: "object",
STRING: "string",
NUMBER: "number",
INTEGER: "integer",
BOOLEAN: "boolean",
ARRAY: "array",
};
const upper = val.toUpperCase();
out.type = map[upper] ?? val.toLowerCase();
continue;
}
if (
key === "properties" &&
val &&
typeof val === "object" &&
!Array.isArray(val)
) {
out.properties = Object.fromEntries(
Object.entries(val as object).map(([k, v]) => [
k,
geminiStyleToJsonSchema(v),
]),
);
continue;
}
if (key === "items") {
out.items = geminiStyleToJsonSchema(val);
continue;
}
out[key] =
val && typeof val === "object" && !Array.isArray(val)
? geminiStyleToJsonSchema(val)
: val;
}
return out;
}
function toOpenAiTools(
tools: ToolDefinition[] | undefined,
): object[] | undefined {
if (!tools?.length) return undefined;
return tools.map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description,
parameters: geminiStyleToJsonSchema(t.parameters) as Record<
string,
unknown
>,
},
}));
}
/**
* OpenAI Chat Completions forbid `user`/`assistant` between an assistant
* `tool_calls` block and the matching `tool` replies. Gemini-oriented code
* may inject recovery `user` rows between individual tool results — move
* those users to immediately after all tool rows for that assistant turn.
*/
function reorderMessagesForOpenAiToolPairs(
messages: ChatMessage[],
): ChatMessage[] {
const result: ChatMessage[] = [];
let i = 0;
while (i < messages.length) {
const m = messages[i]!;
if (m.role !== "assistant" || !m.toolCalls?.length) {
result.push(m);
i++;
continue;
}
const expectedIds = m.toolCalls.map((tc) => tc.id);
const pending = new Set(expectedIds);
result.push(m);
i++;
const toolById = new Map<string, ChatMessage>();
const bufferedUsers: ChatMessage[] = [];
while (i < messages.length && pending.size > 0) {
const n = messages[i]!;
if (n.role === "tool" && n.toolCallId && pending.has(n.toolCallId)) {
toolById.set(n.toolCallId, n);
pending.delete(n.toolCallId);
i++;
continue;
}
if (n.role === "user") {
bufferedUsers.push(n);
i++;
continue;
}
break;
}
for (const id of expectedIds) {
const t = toolById.get(id);
if (t) result.push(t);
}
result.push(...bufferedUsers);
}
return result;
}
function toOpenAiMessages(
systemPrompt: string,
messages: ChatMessage[],
): object[] {
const normalized = reorderMessagesForOpenAiToolPairs(messages);
const out: object[] = [{ role: "system", content: systemPrompt }];
for (const m of normalized) {
if (m.role === "user") {
out.push({ role: "user", content: m.content });
} else if (m.role === "assistant") {
const hasTools = Boolean(m.toolCalls?.length);
const text = typeof m.content === "string" ? m.content.trim() : "";
const msg: Record<string, unknown> = {
role: "assistant",
content: text.length > 0 ? m.content : hasTools ? null : "",
};
if (hasTools && m.toolCalls) {
msg.tool_calls = m.toolCalls.map((tc) => ({
id: tc.id,
type: "function",
function: {
name: tc.name,
arguments: JSON.stringify(tc.args ?? {}),
},
}));
}
out.push(msg);
} else if (m.role === "tool") {
const body =
typeof m.content === "string"
? m.content
: JSON.stringify(m.content ?? "");
out.push({
role: "tool",
tool_call_id: m.toolCallId ?? "",
content: body.length > 0 ? body : "(empty)",
});
}
}
return out;
}
function parseAssistantMessage(message: Record<string, unknown> | undefined): {
text: string;
thoughts: string;
toolCalls: ToolCall[];
} {
const rawText = typeof message?.content === "string" ? message.content : "";
const thoughts =
typeof message?.reasoning_content === "string"
? message.reasoning_content
: typeof (message as { reasoning?: string })?.reasoning === "string"
? (message as { reasoning: string }).reasoning
: "";
const stripTags = (s: string) =>
s
.replace(/<tool_calls>[\s\S]*?<\/tool_calls>/g, "")
.replace(/<think>[\s\S]*?<\/think>/g, "")
.trim();
// DeepSeek separates thinking from speaking — during tool loops it
// often puts everything in reasoning_content and leaves content empty.
// When that happens, surface the reasoning as the user-visible text
// so the user isn't staring at silent tool pills.
const text = stripTags(rawText || thoughts);
const toolCalls: ToolCall[] = [];
const rawCalls = message?.tool_calls;
if (Array.isArray(rawCalls)) {
for (const c of rawCalls) {
const call = c as Record<string, unknown>;
if (call.type !== "function") continue;
const fn = call.function as Record<string, unknown> | undefined;
const name = typeof fn?.name === "string" ? fn.name : "";
const id =
typeof call.id === "string"
? call.id
: `tc-${Date.now()}-${Math.random().toString(36).slice(2)}`;
let args: Record<string, unknown> = {};
const argStr = typeof fn?.arguments === "string" ? fn.arguments : "{}";
try {
args = JSON.parse(argStr || "{}") as Record<string, unknown>;
} catch {
args = {};
}
if (name) toolCalls.push({ id, name, args });
}
}
return { text, thoughts, toolCalls };
}
/**
* Non-streaming chat + tool calls — mirrors {@link callGeminiChat} return shape.
*/
export async function callOpenAiCompatibleChat(opts: {
systemPrompt: string;
messages: ChatMessage[];
tools?: ToolDefinition[];
temperature?: number;
/** Unused for OpenAI-compat; kept for call-site symmetry */
includeThoughts?: boolean;
/** Cancels the in-flight request when the user clicks Stop. */
signal?: AbortSignal;
}): Promise<{
text: string;
thoughts: string;
toolCalls: ToolCall[];
finishReason?: string;
error?: string;
}> {
const apiKey = resolveApiKey();
if (!apiKey) {
return {
text: "",
thoughts: "",
toolCalls: [],
error:
"No API key: set DEEPSEEK_API_KEY or VIBN_OPENAI_COMPATIBLE_API_KEY for OpenAI-compatible chat.",
};
}
const url = resolveChatUrl();
const model = resolveModel();
const tools = toOpenAiTools(opts.tools);
const oaiMessages = toOpenAiMessages(opts.systemPrompt, opts.messages);
const body: Record<string, unknown> = {
model,
messages: oaiMessages,
temperature: opts.temperature ?? 0.7,
max_tokens: 8192,
stream: false,
};
if (tools?.length) body.tools = tools;
// ── Request logging (DeepSeek 400 debug) ──────────────────────────────
const msgSummary = oaiMessages.map(
(m: {
role: string;
content?: unknown;
tool_calls?: Array<{ id: string }>;
tool_call_id?: string;
}) => ({
role: m.role,
has_tool_calls:
m.role === "assistant" ? Boolean(m.tool_calls?.length) : undefined,
tool_calls_ids:
m.role === "assistant" && m.tool_calls?.length
? m.tool_calls.map((tc: { id: string }) => tc.id)
: undefined,
tool_call_id: m.role === "tool" ? m.tool_call_id : undefined,
content_len: typeof m.content === "string" ? m.content.length : 0,
}),
);
console.error(
"[deepseek] request",
JSON.stringify({
url,
model,
msg_count: oaiMessages.length,
has_tools: Boolean(tools?.length),
tool_count: tools?.length ?? 0,
msg_summary: msgSummary,
last_5_roles: msgSummary.slice(-5).map((m: { role: string }) => m.role),
}),
);
// ───────────────────────────────────────────────────────────────────────
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal: opts.signal,
});
} catch (e) {
const aborted =
opts.signal?.aborted || (e instanceof Error && e.name === "AbortError");
return {
text: "",
thoughts: "",
toolCalls: [],
error: aborted
? "aborted"
: `Network error: ${e instanceof Error ? e.message : String(e)}`,
};
}
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
// ── Error logging (DeepSeek 400 debug) ───────────────────────────────
console.error(
"[deepseek] error response",
JSON.stringify(
{
status: res.status,
status_text: res.statusText,
headers: Object.fromEntries(res.headers.entries()),
body: data,
// include the last few messages sent so we can see the exact
// pattern that triggered the error
last_5_sent: msgSummary.slice(-5),
},
null,
2,
),
);
// ─────────────────────────────────────────────────────────────────────
const errObj = data?.error as Record<string, unknown> | undefined;
const msg =
(typeof errObj?.message === "string" && errObj.message) ||
JSON.stringify(data).slice(0, 280);
return {
text: "",
thoughts: "",
toolCalls: [],
error: `Chat API error ${res.status}: ${msg}`,
};
}
const choice = (data.choices as Record<string, unknown>[] | undefined)?.[0];
const message = choice?.message as Record<string, unknown> | undefined;
const { text, thoughts, toolCalls } = parseAssistantMessage(message);
const finishReason =
typeof choice?.finish_reason === "string"
? choice.finish_reason
: undefined;
return { text, thoughts, toolCalls, finishReason };
}
export async function* streamOpenAiCompatibleChat(opts: {
systemPrompt: string;
messages: ChatMessage[];
tools?: ToolDefinition[];
temperature?: number;
includeThoughts?: boolean;
signal?: AbortSignal;
}): AsyncGenerator<ChatChunk> {
const apiKey = resolveApiKey();
if (!apiKey) {
yield {
type: "error",
error: "No API key: set DEEPSEEK_API_KEY or VIBN_OPENAI_COMPATIBLE_API_KEY for OpenAI-compatible chat.",
};
return;
}
const url = resolveChatUrl();
const model = resolveModel();
const tools = toOpenAiTools(opts.tools);
const oaiMessages = toOpenAiMessages(opts.systemPrompt, opts.messages);
const body: Record<string, unknown> = {
model,
messages: oaiMessages,
temperature: opts.temperature ?? 0.7,
max_tokens: 8192,
stream: true,
};
if (tools?.length) body.tools = tools;
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal: opts.signal,
});
} catch (e) {
const aborted = opts.signal?.aborted || (e instanceof Error && e.name === "AbortError");
yield {
type: "error",
error: aborted ? "aborted" : `Network error: ${e instanceof Error ? e.message : String(e)}`,
};
return;
}
if (!res.ok) {
const text = await res.text().catch(() => "");
yield { type: "error", error: `Chat API error ${res.status}: ${text}` };
return;
}
const reader = res.body?.getReader();
if (!reader) {
yield { type: "error", error: "No response body stream." };
return;
}
const decoder = new TextDecoder("utf-8");
let buffer = "";
// Accumulated tool calls
const toolCallsAcc: Record<number, { id: string; name: string; argsStr: string }> = {};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const tLine = line.trim();
if (!tLine || !tLine.startsWith("data: ")) continue;
const dataStr = tLine.slice(6);
if (dataStr === "[DONE]") continue;
try {
const parsed = JSON.parse(dataStr);
const delta = parsed.choices?.[0]?.delta;
if (!delta) continue;
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
yield { type: "thinking_delta", text: delta.reasoning_content };
}
if (typeof delta.content === "string" && delta.content.length > 0) {
yield { type: "text_delta", text: delta.content };
}
if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) {
const idx = tc.index;
if (idx === undefined) continue;
if (!toolCallsAcc[idx]) {
toolCallsAcc[idx] = { id: "", name: "", argsStr: "" };
}
if (tc.id) toolCallsAcc[idx].id = tc.id;
if (tc.function?.name) toolCallsAcc[idx].name += tc.function.name;
if (tc.function?.arguments) toolCallsAcc[idx].argsStr += tc.function.arguments;
}
}
} catch (e) {
// ignore unparseable chunks
}
}
}
} catch (e) {
const aborted = opts.signal?.aborted || (e instanceof Error && e.name === "AbortError");
yield {
type: "error",
error: aborted ? "aborted" : `Stream read error: ${e instanceof Error ? e.message : String(e)}`,
};
}
const toolCalls: ToolCall[] = [];
for (const idx of Object.keys(toolCallsAcc).sort((a,b) => Number(a) - Number(b))) {
const acc = toolCallsAcc[Number(idx)];
let args = {};
try {
if (acc.argsStr) args = JSON.parse(acc.argsStr);
} catch {
// ignore bad json
}
if (acc.name) {
toolCalls.push({
id: acc.id || `tc-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: acc.name,
args
});
}
}
if (toolCalls.length > 0) {
yield { type: "tool_calls", toolCalls };
}
yield { type: "done" };
}