mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat/fix: implement upstream sync tasks 1-7
This commit is contained in:
@@ -291,7 +291,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
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: {
|
||||
|
||||
@@ -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<string, number> = {
|
||||
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 = () =>
|
||||
|
||||
134
open-sse/translator/helpers/schemaCoercion.ts
Normal file
134
open-sse/translator/helpers/schemaCoercion.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
6
public/providers/windsurf.svg
Normal file
6
public/providers/windsurf.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4A90E2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
|
||||
<line x1="9" y1="9" x2="9.01" y2="9"/>
|
||||
<line x1="15" y1="9" x2="15.01" y2="9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 364 B |
@@ -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}`);
|
||||
|
||||
@@ -59,6 +59,8 @@ const STATIC_MODEL_PROVIDERS: Record<string, () => 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<string, ProviderModelsConfigEntry> = {
|
||||
})),
|
||||
},
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>)._truncated === true;
|
||||
}
|
||||
|
||||
const CALL_LOGS_MAX = parseInt(process.env.CALL_LOGS_MAX || "200", 10);
|
||||
const DEFAULT_MAX_CALL_LOGS = 10000;
|
||||
|
||||
async function getMaxCallLogs(): Promise<number> {
|
||||
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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -40,7 +40,7 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
114
tests/unit/schema-coercion.test.mjs
Normal file
114
tests/unit/schema-coercion.test.mjs
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user