This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-agent-runner/dist/llm/openai-compatible-chat.js

311 lines
11 KiB
JavaScript

"use strict";
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.callOpenAiCompatibleChat = callOpenAiCompatibleChat;
const DEFAULT_CHAT_URL = "https://api.deepseek.com/chat/completions";
function resolveApiKey() {
return (process.env.DEEPSEEK_API_KEY?.trim() ||
process.env.VIBN_OPENAI_COMPATIBLE_API_KEY?.trim() ||
"");
}
function resolveChatUrl() {
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() {
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) {
if (node === null || typeof node !== "object" || Array.isArray(node))
return node;
const n = node;
const out = {};
for (const [key, val] of Object.entries(n)) {
if (key === "type" && typeof val === "string") {
const map = {
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).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) {
if (!tools?.length)
return undefined;
return tools.map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description,
parameters: geminiStyleToJsonSchema(t.parameters),
},
}));
}
/**
* 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) {
const result = [];
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();
const bufferedUsers = [];
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, messages) {
const normalized = reorderMessagesForOpenAiToolPairs(messages);
const out = [{ 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 = {
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) {
const rawText = typeof message?.content === "string" ? message.content : "";
const thoughts = typeof message?.reasoning_content === "string"
? message.reasoning_content
: typeof message?.reasoning === "string"
? message.reasoning
: "";
const stripTags = (s) => 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 = [];
const rawCalls = message?.tool_calls;
if (Array.isArray(rawCalls)) {
for (const c of rawCalls) {
const call = c;
if (call.type !== "function")
continue;
const fn = call.function;
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 = {};
const argStr = typeof fn?.arguments === "string" ? fn.arguments : "{}";
try {
args = JSON.parse(argStr || "{}");
}
catch {
args = {};
}
if (name)
toolCalls.push({ id, name, args });
}
}
return { text, thoughts, toolCalls };
}
/**
* Non-streaming chat + tool calls — mirrors {@link callGeminiChat} return shape.
*/
async function callOpenAiCompatibleChat(opts) {
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 = {
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: 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) => 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) => m.role),
}));
// ───────────────────────────────────────────────────────────────────────
let res;
try {
res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
}
catch (e) {
return {
text: "",
thoughts: "",
toolCalls: [],
error: `Network error: ${e instanceof Error ? e.message : String(e)}`,
};
}
const data = (await res.json().catch(() => ({})));
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;
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?.[0];
const message = choice?.message;
const { text, thoughts, toolCalls } = parseAssistantMessage(message);
const finishReason = typeof choice?.finish_reason === "string"
? choice.finish_reason
: undefined;
return { text, thoughts, toolCalls, finishReason };
}