From 8595964ab83dd3702381b41177efffcaa8be1809 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 28 Mar 2026 14:48:57 -0300 Subject: [PATCH] feat/fix: implement upstream sync tasks 1-7 --- open-sse/config/providerRegistry.ts | 2 +- open-sse/handlers/chatCore.ts | 20 +++ open-sse/translator/helpers/schemaCoercion.ts | 134 ++++++++++++++++++ open-sse/translator/index.ts | 23 +++ public/providers/windsurf.svg | 6 + scripts/system-info.mjs | 3 +- src/app/api/providers/[id]/models/route.ts | 4 +- src/app/api/providers/[id]/test/route.ts | 2 +- src/lib/db/settings.ts | 1 + src/lib/usage/callLogs.ts | 23 ++- src/shared/constants/cliTools.ts | 24 ++++ src/shared/constants/modelSpecs.ts | 2 +- tests/unit/schema-coercion.test.mjs | 114 +++++++++++++++ 13 files changed, 349 insertions(+), 9 deletions(-) create mode 100644 open-sse/translator/helpers/schemaCoercion.ts create mode 100644 public/providers/windsurf.svg create mode 100644 tests/unit/schema-coercion.test.mjs diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 69b018b2d2..0bbe2ffbc0 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -291,7 +291,7 @@ export const REGISTRY: Record = { alias: "qw", format: "openai", executor: "default", - baseUrl: "https://portal.qwen.ai/v1/chat/completions", + baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", authType: "oauth", authHeader: "bearer", headers: { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 914b8e3a11..bc044363e1 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -794,6 +794,26 @@ export async function handleChatCore({ } } + // Provider-specific max_tokens caps (#711) + // Some providers reject requests when max_tokens exceeds their API limit. + // Cap before sending to avoid upstream HTTP 400 errors. + const PROVIDER_MAX_OUTPUT_TOKENS: Record = { + groq: 16384, + cerebras: 8192, + }; + const providerCap = PROVIDER_MAX_OUTPUT_TOKENS[provider]; + if (providerCap) { + for (const field of ["max_tokens", "max_completion_tokens"] as const) { + if (typeof translatedBody[field] === "number" && translatedBody[field] > providerCap) { + log?.debug?.( + "PARAMS", + `Capping ${field} from ${translatedBody[field]} to ${providerCap} for ${provider}` + ); + translatedBody[field] = providerCap; + } + } + } + // Get executor for this provider const executor = getExecutor(provider); const getExecutionCredentials = () => diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts new file mode 100644 index 0000000000..9e70c077b1 --- /dev/null +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -0,0 +1,134 @@ +/** + * Coerce string-encoded numeric JSON Schema constraints to their proper types. + * Some clients (Cursor, Cline, etc.) send e.g. "minimum": "1" instead of "minimum": 1, + * which causes 400 errors on strict providers like Claude and OpenAI. + */ + +const NUMERIC_SCHEMA_FIELDS = [ + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "minLength", + "maxLength", + "minItems", + "maxItems", + "minProperties", + "maxProperties", + "multipleOf", +] as const; + +export function coerceSchemaNumericFields(schema: any): any { + if (!schema || typeof schema !== "object") return schema; + if (Array.isArray(schema)) return schema.map(coerceSchemaNumericFields); + + const result = { ...schema }; + + for (const field of NUMERIC_SCHEMA_FIELDS) { + if (field in result && typeof result[field] === "string") { + const num = Number(result[field]); + if (!isNaN(num) && isFinite(num)) { + result[field] = num; + } + } + } + + // Recurse into nested schema structures + if (result.properties && typeof result.properties === "object") { + result.properties = Object.fromEntries( + Object.entries(result.properties).map(([key, val]) => [key, coerceSchemaNumericFields(val)]) + ); + } + if (result.items) { + result.items = coerceSchemaNumericFields(result.items); + } + if (result.additionalProperties && typeof result.additionalProperties === "object") { + result.additionalProperties = coerceSchemaNumericFields(result.additionalProperties); + } + if (Array.isArray(result.anyOf)) { + result.anyOf = result.anyOf.map(coerceSchemaNumericFields); + } + if (Array.isArray(result.oneOf)) { + result.oneOf = result.oneOf.map(coerceSchemaNumericFields); + } + if (Array.isArray(result.allOf)) { + result.allOf = result.allOf.map(coerceSchemaNumericFields); + } + if (result.not && typeof result.not === "object") { + result.not = coerceSchemaNumericFields(result.not); + } + + return result; +} + +/** + * Apply schema coercion to all tools in a request body. + * Handles both OpenAI format (function.parameters) and Claude format (input_schema). + */ +export function coerceToolSchemas(tools: any[]): any[] { + if (!Array.isArray(tools)) return tools; + + return tools.map((tool) => { + if (!tool || typeof tool !== "object") return tool; + + const result = { ...tool }; + + // OpenAI format: tool.function.parameters + if (result.function?.parameters) { + result.function = { + ...result.function, + parameters: coerceSchemaNumericFields(result.function.parameters), + }; + } + + // Claude format: tool.input_schema + if (result.input_schema) { + result.input_schema = coerceSchemaNumericFields(result.input_schema); + } + + // Direct parameters (some formats) + if (result.parameters && !result.function) { + result.parameters = coerceSchemaNumericFields(result.parameters); + } + + return result; + }); +} + +/** + * Ensure tool.description is always a string. + * Some clients send null, undefined, or numeric descriptions. + */ +export function sanitizeToolDescription(tool: any): any { + if (!tool || typeof tool !== "object") return tool; + + const result = { ...tool }; + + // OpenAI format: tool.function.description + if (result.function && result.function.description !== undefined) { + if (result.function.description === null) { + result.function = { ...result.function, description: "" }; + } else if (typeof result.function.description !== "string") { + result.function = { ...result.function, description: String(result.function.description) }; + } + } + + // Claude format: tool.description (direct) + if ("description" in result && !result.function) { + if (result.description === null) { + result.description = ""; + } else if (typeof result.description !== "string") { + result.description = String(result.description); + } + } + + return result; +} + +/** + * Apply description sanitization to all tools in a request body. + */ +export function sanitizeToolDescriptions(tools: any[]): any[] { + if (!Array.isArray(tools)) return tools; + return tools.map(sanitizeToolDescription); +} diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 25f70900fe..80b2b3c860 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -1,5 +1,6 @@ import { FORMATS } from "./formats.ts"; import { ensureToolCallIds, fixMissingToolResponses } from "./helpers/toolCallHelper.ts"; +import { coerceToolSchemas, sanitizeToolDescriptions } from "./helpers/schemaCoercion.ts"; import { prepareClaudeRequest } from "./helpers/claudeHelper.ts"; import { filterToOpenAIFormat } from "./helpers/openaiHelper.ts"; import { getRequestTranslator, getResponseTranslator } from "./registry.ts"; @@ -175,6 +176,28 @@ export function translateRequest( ensureToolCallIds(result, { use9CharId }); fixMissingToolResponses(result); + if (result.tools) { + result.tools = coerceToolSchemas(result.tools); + result.tools = sanitizeToolDescriptions(result.tools); + } + + // Inject reasoning_content = "" for DeepSeek/Reasoning models assistant messages with tool_calls + // if omitted by the client, to avoid upstream 400 errors (e.g. "Messages with role 'assistant' that contain tool_calls must also include reasoning_content") + const isReasoner = + provider === "deepseek" || (typeof model === "string" && /r1|reason/i.test(model)); + if (isReasoner && result.messages && Array.isArray(result.messages)) { + for (const msg of result.messages) { + if ( + msg.role === "assistant" && + Array.isArray(msg.tool_calls) && + msg.tool_calls.length > 0 && + msg.reasoning_content === undefined + ) { + msg.reasoning_content = ""; + } + } + } + return result; } diff --git a/public/providers/windsurf.svg b/public/providers/windsurf.svg new file mode 100644 index 0000000000..10f84aaa39 --- /dev/null +++ b/public/providers/windsurf.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/scripts/system-info.mjs b/scripts/system-info.mjs index 6d4a004748..aefcaf71f1 100644 --- a/scripts/system-info.mjs +++ b/scripts/system-info.mjs @@ -15,7 +15,7 @@ */ import { execSync } from "child_process"; -import { readFileSync, writeFileSync, existsSync } from "fs"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import os from "os"; @@ -162,6 +162,7 @@ const outFile = outArg const outPath = join(ROOT, outFile); +mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, report); console.log(report); console.log(`\n✅ Report saved to: ${outPath}`); diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 228e57e64b..d7b419ffea 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -59,6 +59,8 @@ const STATIC_MODEL_PROVIDERS: Record Array<{ id: string; name: str antigravity: () => [ { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" }, { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, + { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, @@ -141,7 +143,7 @@ const PROVIDER_MODELS_CONFIG: Record = { })), }, qwen: { - url: "https://portal.qwen.ai/v1/models", + url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", method: "GET", headers: { "Content-Type": "application/json" }, authHeader: "Authorization", diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index f084466a1e..828856090b 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -59,7 +59,7 @@ const OAUTH_TEST_CONFIG = { refreshable: true, }, qwen: { - // portal.qwen.ai/v1/models returns 404 — endpoint no longer exists. + // DashScope (previously portal.qwen.ai) /v1/models might return 404 or auth issues. // Use checkExpiry instead — actual connectivity is validated via real requests. checkExpiry: true, refreshable: true, diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index e5ac9f6553..347a648a50 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -45,6 +45,7 @@ export async function getSettings() { cloudEnabled: false, stickyRoundRobinLimit: 3, requireLogin: true, + maxCallLogs: 10000, }; for (const row of rows) { const record = toRecord(row); diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 37ea125ac2..1fb4d6f6f3 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -10,6 +10,7 @@ import path from "path"; import fs from "fs"; import { getDbInstance } from "../db/core"; +import { getSettings } from "../db/settings"; import { shouldPersistToDisk, CALL_LOGS_DIR } from "./migrations"; import { getLoggedInputTokens, getLoggedOutputTokens } from "./tokenAccounting"; import { isNoLog } from "../compliance"; @@ -48,7 +49,20 @@ function hasTruncatedFlag(value: unknown): boolean { return (value as Record)._truncated === true; } -const CALL_LOGS_MAX = parseInt(process.env.CALL_LOGS_MAX || "200", 10); +const DEFAULT_MAX_CALL_LOGS = 10000; + +async function getMaxCallLogs(): Promise { + try { + const settings = await getSettings(); + const setting = settings.maxCallLogs; + if (setting) { + const num = parseInt(String(setting), 10); + if (!isNaN(num) && num > 0) return num; + } + } catch {} + return DEFAULT_MAX_CALL_LOGS; +} + const LOG_RETENTION_DAYS = parseInt(process.env.LOG_RETENTION_DAYS || "7", 10); const CALL_LOG_PAYLOAD_MODE = (() => { const value = (process.env.CALL_LOG_PAYLOAD_MODE || "full").toLowerCase(); @@ -212,17 +226,18 @@ export async function saveCallLog(entry: any) { ` ).run(logEntry); - // 2. Trim old entries beyond CALL_LOGS_MAX + // 2. Trim old entries beyond max + const maxLogs = await getMaxCallLogs(); const countRow = asRecord(db.prepare("SELECT COUNT(*) as cnt FROM call_logs").get()); const count = toNumber(countRow.cnt); - if (count > CALL_LOGS_MAX) { + if (count > maxLogs) { db.prepare( ` DELETE FROM call_logs WHERE id IN ( SELECT id FROM call_logs ORDER BY timestamp ASC LIMIT ? ) ` - ).run(count - CALL_LOGS_MAX); + ).run(count - maxLogs); } // 3. Write full payload to disk file (untruncated) diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index 39a1376121..95d23de13e 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -98,6 +98,30 @@ export const CLI_TOOLS = { { step: 6, title: "Select Model", type: "modelSelector" }, ], }, + windsurf: { + id: "windsurf", + name: "Windsurf", + image: "/providers/windsurf.svg", + color: "#4A90E2", + description: "Windsurf AI-first IDE by Codeium", + docsUrl: "https://windsurf.com/", + configType: "guide", + guideSteps: [ + { + step: 1, + title: "Open AI Settings", + desc: "Click the AI Settings icon in Windsurf or go to Settings", + }, + { + step: 2, + title: "Add Custom Provider", + desc: 'Select "Add custom provider" (OpenAI-compatible)', + }, + { step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true }, + { step: 4, title: "API Key", type: "apiKeySelector" }, + { step: 5, title: "Select Model", type: "modelSelector" }, + ], + }, cline: { id: "cline", name: "Cline", diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index ede662c788..4f5766b0a9 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -40,7 +40,7 @@ export const MODEL_SPECS: Record = { supportsThinking: true, supportsTools: true, supportsVision: true, - aliases: ["gemini-3-pro-high"], + aliases: ["gemini-3-pro-high", "gemini-3.1-pro-preview", "gemini-3.1-pro-preview-customtools"], }, // ── Gemini 3.1 Pro Low ────────────────────────────────────────── diff --git a/tests/unit/schema-coercion.test.mjs b/tests/unit/schema-coercion.test.mjs new file mode 100644 index 0000000000..5b6310d863 --- /dev/null +++ b/tests/unit/schema-coercion.test.mjs @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert"; +import { + coerceSchemaNumericFields, + coerceToolSchemas, + sanitizeToolDescription, + sanitizeToolDescriptions, +} from "../../open-sse/translator/helpers/schemaCoercion.ts"; + +test("coerceSchemaNumericFields converts string numbers to actual numbers", () => { + const schema = { + type: "object", + properties: { + items: { + type: "array", + minItems: "1", + maxItems: "2", + }, + }, + minimum: "5", + }; + + const result = coerceSchemaNumericFields(schema); + + assert.strictEqual(result.minimum, 5); + assert.strictEqual(result.properties.items.minItems, 1); + assert.strictEqual(result.properties.items.maxItems, 2); +}); + +test("coerceSchemaNumericFields ignores non-numeric strings", () => { + const schema = { + minimum: "abc", + maximum: "10.5", + }; + + const result = coerceSchemaNumericFields(schema); + + assert.strictEqual(result.minimum, "abc"); + assert.strictEqual(result.maximum, 10.5); +}); + +test("coerceToolSchemas applies coercion to OpenAI tools", () => { + const tools = [ + { + type: "function", + function: { + name: "test", + parameters: { + properties: { val: { minLength: "2" } }, + }, + }, + }, + ]; + + const result = coerceToolSchemas(tools); + assert.strictEqual(result[0].function.parameters.properties.val.minLength, 2); +}); + +test("coerceToolSchemas applies coercion to Claude tools", () => { + const tools = [ + { + name: "test", + input_schema: { + properties: { val: { maxLength: "10" } }, + }, + }, + ]; + + const result = coerceToolSchemas(tools); + assert.strictEqual(result[0].input_schema.properties.val.maxLength, 10); +}); + +test("sanitizeToolDescription converts null to empty string (OpenAI format)", () => { + const tool = { + type: "function", + function: { name: "test", description: null, parameters: {} }, + }; + const result = sanitizeToolDescription(tool); + assert.equal(result.function.description, ""); +}); + +test("sanitizeToolDescription converts number to string (OpenAI format)", () => { + const tool = { + type: "function", + function: { name: "test", description: 42, parameters: {} }, + }; + const result = sanitizeToolDescription(tool); + assert.equal(result.function.description, "42"); +}); + +test("sanitizeToolDescription handles Claude format", () => { + const tool = { name: "test", description: null, input_schema: {} }; + const result = sanitizeToolDescription(tool); + assert.equal(result.description, ""); +}); + +test("sanitizeToolDescription preserves valid string descriptions", () => { + const tool = { + type: "function", + function: { name: "test", description: "A useful tool", parameters: {} }, + }; + const result = sanitizeToolDescription(tool); + assert.equal(result.function.description, "A useful tool"); +}); + +test("sanitizeToolDescriptions works on arrays", () => { + const tools = [ + { name: "test1", description: null, input_schema: {} }, + { type: "function", function: { name: "test2", description: 42, parameters: {} } }, + ]; + const result = sanitizeToolDescriptions(tools); + assert.strictEqual(result[0].description, ""); + assert.strictEqual(result[1].function.description, "42"); +});