385 lines
12 KiB
TypeScript
385 lines
12 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 } 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;
|
|
}): 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),
|
|
});
|
|
} catch (e) {
|
|
return {
|
|
text: "",
|
|
thoughts: "",
|
|
toolCalls: [],
|
|
error: `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 };
|
|
}
|