Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"ai": "^6.0.142",
"better-sqlite3": "^12.8.0",
"ioredis": "^5.10.1",
"jsonrepair": "^3.13.3",
"next": "16.2.2",
"node-cron": "^4.2.1",
"react": "19.2.4",
Expand Down
159 changes: 156 additions & 3 deletions src/app/v1/chat/completions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { isProviderEnabledSync } from "@/lib/provider-toggle";
import { canFitRequest, parseLimitHeaders, parseLimitError, recordLimit } from "@/lib/provider-limits";
import { recordOutcomeLearning, recordFailStreak, canHandleTokens, getCategoryWinners, detectCategory, isModelUnhealthyForCategory } from "@/lib/learning";
import { upstreamAgent } from "@/lib/upstream-agent";
import { repairToolCallArguments, hasStructurallyBrokenToolCalls } from "@/lib/tool-repair";

// ── Category mapping: routing category → exam question category ──
const ROUTING_TO_EXAM_CAT: Record<string, string> = {
Expand Down Expand Up @@ -583,7 +584,66 @@ async function forwardToProvider(
headers["X-Title"] = "SMLGateway Gateway";
}

const requestBody: Record<string, unknown> = { ...body, model: actualModelId };
// Whitelist: only pass known OpenAI-compatible fields to upstream providers
// Fixes: Google rejects unknown fields like "store" (OpenClaw memory feature)
const ALLOWED_FIELDS = new Set([
"messages", "temperature", "top_p", "max_tokens", "stream",
"tools", "tool_choice", "response_format", "stop", "n", "seed", "user",
"presence_penalty", "frequency_penalty", "logprobs", "top_logprobs",
"parallel_tool_calls", "service_tier", "metadata",
]);
const requestBody: Record<string, unknown> = { model: actualModelId };
for (const [key, value] of Object.entries(body)) {
if (ALLOWED_FIELDS.has(key)) {
requestBody[key] = value;
}
}

// Strip unsupported message fields (reasoning, refusal, etc.)
// Groq/others reject messages with "reasoning" field from thinking models
if (Array.isArray(requestBody.messages)) {
for (const msg of requestBody.messages as Array<Record<string, unknown>>) {
delete msg.reasoning;
delete msg.refusal;
}
}

// Google: sanitize conversation — strip tool_calls and tool role messages
// Google's Gemini API requires strict ordering: user→assistant(tool_call)→tool→assistant
// OpenClaw conversations have complex tool call patterns that don't match this
if (provider === "google" && Array.isArray(requestBody.messages)) {
const msgs = requestBody.messages as Array<Record<string, unknown>>;
const sanitized: Array<Record<string, unknown>> = [];
for (const msg of msgs) {
// Skip tool role messages entirely
if (msg.role === "tool") continue;
// For assistant messages with tool_calls, convert to plain text
if (msg.role === "assistant" && msg.tool_calls) {
const textContent = (msg.content as string) || "";
if (textContent) {
sanitized.push({ role: "assistant", content: textContent });
}
continue;
}
// Pass through everything else
sanitized.push(msg);
}
// Ensure conversation doesn't have consecutive same-role messages
const merged: Array<Record<string, unknown>> = [];
for (const msg of sanitized) {
if (merged.length > 0 && merged[merged.length - 1].role === msg.role) {
// Merge consecutive same-role messages
const prev = merged[merged.length - 1];
const prevContent = String(prev.content || "");
const currContent = String(msg.content || "");
prev.content = prevContent + "\n" + currContent;
} else {
merged.push({ ...msg });
}
}
requestBody.messages = merged;
// Keep tools/tool_choice so model can make proper function calls
}

delete requestBody.store;
delete requestBody.stream_options;
Expand Down Expand Up @@ -801,6 +861,33 @@ async function isOllamaModelLoaded(modelId: string): Promise<boolean> {
}
}

// ── Pattern B+: Retry with a tools-capable model ──
interface ToolRetryResult { response: Response; provider: string; modelId: string; dbModelId: string; }

async function retryWithToolsModel(
body: Record<string, unknown>,
caps: RequestCapabilities,
excludeModelId: string,
isStream: boolean
): Promise<ToolRetryResult | null> {
const candidates = await getAvailableModels({ ...caps, hasTools: true });
const filtered = candidates.filter(c => c.model_id !== excludeModelId);
if (!filtered.length) {
console.log("[ToolRepair] No tools-capable models available for retry");
return null;
}
const pick = filtered[0];
console.log(`[ToolRepair] Retry with ${pick.provider}/${pick.model_id}`);

try {
const resp = await forwardToProvider(pick.provider, pick.model_id, body, isStream, AbortSignal.timeout(15_000));
return { response: resp, provider: pick.provider, modelId: pick.model_id, dbModelId: pick.id };
} catch (err) {
console.log(`[ToolRepair] Retry failed: ${String(err)}`);
return null;
}
}

function isRetryableStatus(status: number): boolean {
return status === 413 || status === 429 || status === 410 || status >= 500;
}
Expand Down Expand Up @@ -1527,6 +1614,45 @@ export async function POST(req: NextRequest) {

const hasToolCalls = Array.isArray(firstMsg?.tool_calls) && (firstMsg!.tool_calls!.length > 0);

// ── Pattern B+: repair malformed tool_call arguments ──
if (hasToolCalls) {
const wasRepaired = repairToolCallArguments(json);
if (wasRepaired) {
console.log(`[PatternB+] Repaired tool_call arguments for ${provider}/${actualModelId}`);
}
}

// ── Pattern B+: detect structurally broken tool_calls → retry with another model ──
if (hasToolCalls) {
const structCheck = hasStructurallyBrokenToolCalls(json);
if (structCheck.broken) {
console.log(`[PatternB+] Structurally broken: ${structCheck.reason} — retrying`);
const retryResult = await retryWithToolsModel(body, caps, actualModelId, false);
if (retryResult && retryResult.response.ok) {
try {
const retryJson = await retryResult.response.json() as Record<string, unknown>;
repairToolCallArguments(retryJson);
const retryCheck = hasStructurallyBrokenToolCalls(retryJson);
if (!retryCheck.broken) {
console.log(`[PatternB+] Retry succeeded with ${retryResult.provider}/${retryResult.modelId}`);
ensureChatCompletionFields(retryJson, retryResult.provider, retryResult.modelId);
const retryHeaders = new Headers();
retryHeaders.set("Content-Type", "application/json");
retryHeaders.set("X-SMLGateway-Provider", retryResult.provider);
retryHeaders.set("X-SMLGateway-Model", retryResult.modelId);
retryHeaders.set("X-SMLGateway-ToolRepair", "retried");
retryHeaders.set("Access-Control-Allow-Origin", "*");
await logGateway(modelField, retryResult.modelId, retryResult.provider, 200, Date.now() - startTime,
0, 0, null, userMsg, `[tool-repair-retry from ${provider}/${actualModelId}]`);
return new Response(JSON.stringify(retryJson), { status: 200, headers: retryHeaders });
}
} catch { /* retry parse failed, continue with original */ }
}
console.log(`[PatternB+] Retry did not fix structural issue — returning original`);
}
}


const badReason = isResponseBad(content, caps.hasTools, hasToolCalls);
if (badReason) {
console.log(`[BAD-RESPONSE] ${provider}/${actualModelId} — ${badReason}: "${content.slice(0, 100)}"`);
Expand Down Expand Up @@ -1615,6 +1741,7 @@ export async function POST(req: NextRequest) {
}

const errText = await response.text().catch(() => "");
console.error(`[Gateway] ${provider}/${actualModelId} HTTP ${response.status}: ${errText.slice(0, 500)}`);
lastError = `${provider}/${actualModelId}: HTTP ${response.status}`;
lastProvider = provider;
lastModelId = actualModelId;
Expand Down Expand Up @@ -1842,19 +1969,45 @@ async function buildProxiedResponse(
}
}

// Pattern B+: repair tool_call arguments before numeric coercion
repairToolCallArguments(json);

if (json.choices) {
for (const choice of json.choices) {
const toolCalls = choice.message?.tool_calls;
if (Array.isArray(toolCalls)) {
for (const tc of toolCalls) {
if (tc.function?.arguments && typeof tc.function.arguments === "string") {
// Fix missing id/type
if (!tc.id) tc.id = uuidv4();
if (!tc.type) tc.type = "function";

// Fix arguments
if (tc.function?.arguments) {
let argsStr = typeof tc.function.arguments === "object"
? JSON.stringify(tc.function.arguments)
: String(tc.function.arguments);

// Strip markdown fences if present
argsStr = argsStr.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?\s*```\s*$/i, "");

try {
const args = JSON.parse(tc.function.arguments);
for (const [key, val] of Object.entries(args)) {
if (typeof val === "string" && /^\d+$/.test(val)) args[key] = Number(val);
}
tc.function.arguments = JSON.stringify(args);
} catch { /* keep original */ }
} catch {
// JSON.parse failed → try jsonrepair
try {
const repaired = jsonrepair(argsStr);
JSON.parse(repaired); // verify it's valid
tc.function.arguments = repaired;
console.log(`[ToolRepair] Repaired tool_call arguments for ${tc.function?.name}`);
} catch {
// jsonrepair also failed — keep original, client will handle
console.log(`[ToolRepair] Could not repair arguments for ${tc.function?.name}`);
}
}
}
}
}
Expand Down
99 changes: 99 additions & 0 deletions src/lib/tool-repair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Pattern B+: JSON repair + structurally broken tool_call detection + retry
*
* Ported from BCProxyAI local (SQLite) to SML Gateway (async PostgreSQL).
* Pure functions that can be used by the main route handler.
*/

import { jsonrepair } from "jsonrepair";

// ── JSON Repair for tool_call arguments ──

/**
* Attempt to repair malformed JSON in tool_call arguments.
* Returns true if any repair was made.
*/
export function repairToolCallArguments(json: Record<string, unknown>): boolean {
const choices = json.choices as Array<Record<string, unknown>> | undefined;
if (!choices) return false;

let repaired = false;

for (const choice of choices) {
const msg = choice.message as Record<string, unknown> | undefined;
if (!msg) continue;
const toolCalls = msg.tool_calls;
if (!Array.isArray(toolCalls)) continue;

for (const tc of toolCalls) {
const fn = (tc as Record<string, unknown>).function as Record<string, unknown> | undefined;
if (!fn?.arguments || typeof fn.arguments !== "string") continue;

try {
// Try normal parse first
JSON.parse(fn.arguments as string);
} catch {
// Parse failed — attempt repair
try {
const fixed = jsonrepair(fn.arguments as string);
JSON.parse(fixed); // validate the repair
console.log(`[ToolRepair] Fixed malformed arguments for ${fn.name}: ${(fn.arguments as string).length} → ${fixed.length} chars`);
fn.arguments = fixed;
repaired = true;
} catch {
console.log(`[ToolRepair] Could not repair arguments for ${fn.name}: ${(fn.arguments as string).slice(0, 100)}`);
}
}
}
}

return repaired;
}

// ── Structural break detection ──

interface StructuralCheckResult {
broken: boolean;
reason: string;
}

/**
* Check if tool_calls are structurally broken (needs retry, not just repair).
* Structurally broken means: missing function.name, null arguments, or oversized arguments.
*/
export function hasStructurallyBrokenToolCalls(json: Record<string, unknown>): StructuralCheckResult {
const choices = json.choices as Array<Record<string, unknown>> | undefined;
if (!choices?.[0]) return { broken: false, reason: "" };
const msg = (choices[0] as Record<string, unknown>).message as Record<string, unknown> | undefined;
if (!msg) return { broken: false, reason: "" };
const toolCalls = msg.tool_calls;
if (!toolCalls) return { broken: false, reason: "" };

// tool_calls must be array
if (!Array.isArray(toolCalls)) {
return { broken: true, reason: "tool_calls is not an array" };
}

for (let i = 0; i < toolCalls.length; i++) {
const tc = toolCalls[i] as Record<string, unknown>;
const fn = tc.function as Record<string, unknown> | undefined;

// Must have function.name
if (!fn?.name || typeof fn.name !== "string" || (fn.name as string).trim() === "") {
return { broken: true, reason: `tool_call[${i}] missing function.name` };
}

// Arguments too large → skip repair, needs retry
const args = fn.arguments;
if (typeof args === "string" && args.length > 100000) {
return { broken: true, reason: `tool_call[${i}] arguments too large (${args.length} chars)` };
}

// Null/undefined arguments with valid name → broken
if (args === null || args === undefined) {
return { broken: true, reason: `tool_call[${i}] missing arguments` };
}
}

return { broken: false, reason: "" };
}