Merge fix/issue-711: provider max_tokens cap + upstream sync tasks (#711)

This commit is contained in:
diegosouzapw
2026-03-28 16:08:12 -03:00
13 changed files with 353 additions and 9 deletions

View File

@@ -66,6 +66,15 @@ export const DEFAULT_MAX_TOKENS = 64000;
// Minimum max tokens for tool calling (to prevent truncated arguments)
export const DEFAULT_MIN_TOKENS = 32000;
export const PROVIDER_MAX_TOKENS: Record<string, number> = {
groq: 16384, // Groq strict per-model enforcement
openai: 16384, // GPT-4/4o standard
anthropic: 65536, // Claude models
gemini: 65536, // Gemini Studio
};
export const DEFAULT_PROVIDER_MAX_TOKENS = 32000;
// HTTP status codes
export const HTTP_STATUS = {
BAD_REQUEST: 400,

View File

@@ -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: {

View File

@@ -15,7 +15,7 @@ import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerMo
import { resolveModelAlias } from "../services/modelDeprecation.ts";
import { getUnsupportedParams } from "../config/providerRegistry.ts";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.ts";
import { HTTP_STATUS } from "../config/constants.ts";
import { HTTP_STATUS, PROVIDER_MAX_TOKENS } from "../config/constants.ts";
import { classifyProviderError, PROVIDER_ERROR_TYPES } from "../services/errorClassifier.ts";
import { updateProviderConnection } from "@/lib/db/providers";
import { logAuditEvent } from "@/lib/compliance";
@@ -794,6 +794,22 @@ 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 providerCap = PROVIDER_MAX_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 = () =>

View 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);
}

View File

@@ -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;
}

View 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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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);

View File

@@ -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)

View File

@@ -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",

View File

@@ -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 ──────────────────────────────────────────

View 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");
});