This commit addresses the issue where DeepSeek's raw XML markup (like <tool_calls> and <think>) was leaking into chat history, causing hallucinations in subsequent turns. It also patches a vulnerability in the git commit tool where arbitrary shell injection was possible. Additionally, it includes UX copy and color contrast adjustments for the marketing homepage breadcrumbs.
378 lines
11 KiB
TypeScript
378 lines
11 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: any) => ({
|
|
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: any) => 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: any) => 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 };
|
|
}
|