mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
feat(open-sse): add schema coercion and tool sanitization
- introduce open-sse/translator/helpers/schemaCoercion.ts to coerce numeric JSON Schema fields encoded as strings - wire coerceToolSchemas and sanitizeToolDescriptions into translator pipeline; ensure tool descriptions are sanitized - inject empty reasoning content for tool calls when target is OpenAI format - update qwen base URL to DashScope-compatible endpoint - extend antigravity static catalog with Gemini 3.1 pro preview models and update Gemini model specs with preview aliases - implement call log max cap caching with TTL; expose invalidateCallLogsMaxCache and invalidate on settings PATCH - add tests: call-log-cap.test.mjs and tool-request-sanitization.test.mjs; extend tests for Windsurf integration and gemini previews - update CLI runtime and tools to include Windsurf as a guide-only tool - add maxCallLogs to validation schemas (settings and updateSettings) - add Czech README (README.cs.md) to repository
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -112,6 +112,7 @@ app.log
|
||||
|
||||
# Backup directories
|
||||
app.__qa_backup/
|
||||
.app-build-backup-*/
|
||||
|
||||
# Production standalone build (created by scripts/prepublish.mjs)
|
||||
# Conflicts with Next.js App Router detection in dev (root app/ shadows src/app/)
|
||||
|
||||
2081
README.cs.md
Normal file
2081
README.cs.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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: {
|
||||
|
||||
207
open-sse/translator/helpers/schemaCoercion.ts
Normal file
207
open-sse/translator/helpers/schemaCoercion.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Shared sanitizers for tool payloads that arrive from IDEs/SDKs with
|
||||
* JSON Schema numeric constraints encoded as strings or invalid descriptions.
|
||||
*/
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const NUMERIC_SCHEMA_FIELDS = [
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
"multipleOf",
|
||||
] as const;
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonRecord {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function coerceNumericString(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return value;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}
|
||||
|
||||
function mapRecordValues(record: JsonRecord): JsonRecord {
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).map(([key, value]) => [key, coerceSchemaNumericFields(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeDescriptionValue(value: unknown): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return "";
|
||||
return typeof value === "string" ? value : String(value);
|
||||
}
|
||||
|
||||
export function coerceSchemaNumericFields(schema: unknown): unknown {
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map((entry) => coerceSchemaNumericFields(entry));
|
||||
}
|
||||
if (!isPlainObject(schema)) return schema;
|
||||
|
||||
const result: JsonRecord = { ...schema };
|
||||
|
||||
for (const field of NUMERIC_SCHEMA_FIELDS) {
|
||||
if (field in result) {
|
||||
result[field] = coerceNumericString(result[field]);
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlainObject(result.properties)) {
|
||||
result.properties = mapRecordValues(result.properties);
|
||||
}
|
||||
if (isPlainObject(result.patternProperties)) {
|
||||
result.patternProperties = mapRecordValues(result.patternProperties);
|
||||
}
|
||||
if (isPlainObject(result.definitions)) {
|
||||
result.definitions = mapRecordValues(result.definitions);
|
||||
}
|
||||
if (isPlainObject(result.$defs)) {
|
||||
result.$defs = mapRecordValues(result.$defs);
|
||||
}
|
||||
if (isPlainObject(result.dependentSchemas)) {
|
||||
result.dependentSchemas = mapRecordValues(result.dependentSchemas);
|
||||
}
|
||||
|
||||
if (result.items !== undefined) {
|
||||
result.items = coerceSchemaNumericFields(result.items);
|
||||
}
|
||||
if (result.additionalProperties && typeof result.additionalProperties === "object") {
|
||||
result.additionalProperties = coerceSchemaNumericFields(result.additionalProperties);
|
||||
}
|
||||
if (result.unevaluatedProperties && typeof result.unevaluatedProperties === "object") {
|
||||
result.unevaluatedProperties = coerceSchemaNumericFields(result.unevaluatedProperties);
|
||||
}
|
||||
if (Array.isArray(result.prefixItems)) {
|
||||
result.prefixItems = result.prefixItems.map((entry) => coerceSchemaNumericFields(entry));
|
||||
}
|
||||
if (Array.isArray(result.anyOf)) {
|
||||
result.anyOf = result.anyOf.map((entry) => coerceSchemaNumericFields(entry));
|
||||
}
|
||||
if (Array.isArray(result.oneOf)) {
|
||||
result.oneOf = result.oneOf.map((entry) => coerceSchemaNumericFields(entry));
|
||||
}
|
||||
if (Array.isArray(result.allOf)) {
|
||||
result.allOf = result.allOf.map((entry) => coerceSchemaNumericFields(entry));
|
||||
}
|
||||
if (isPlainObject(result.not)) {
|
||||
result.not = coerceSchemaNumericFields(result.not);
|
||||
}
|
||||
if (isPlainObject(result.if)) {
|
||||
result.if = coerceSchemaNumericFields(result.if);
|
||||
}
|
||||
if (isPlainObject(result.then)) {
|
||||
result.then = coerceSchemaNumericFields(result.then);
|
||||
}
|
||||
if (isPlainObject(result.else)) {
|
||||
result.else = coerceSchemaNumericFields(result.else);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeToolDescription(tool: unknown): unknown {
|
||||
if (!isPlainObject(tool)) return tool;
|
||||
|
||||
const result: JsonRecord = { ...tool };
|
||||
|
||||
if (isPlainObject(result.function) && "description" in result.function) {
|
||||
const description = sanitizeDescriptionValue(result.function.description);
|
||||
if (description !== undefined) {
|
||||
result.function = { ...result.function, description };
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPlainObject(result.function) && "description" in result) {
|
||||
const description = sanitizeDescriptionValue(result.description);
|
||||
if (description !== undefined) {
|
||||
result.description = description;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(result.functionDeclarations)) {
|
||||
result.functionDeclarations = result.functionDeclarations.map((declaration) => {
|
||||
if (!isPlainObject(declaration) || !("description" in declaration)) return declaration;
|
||||
const description = sanitizeDescriptionValue(declaration.description);
|
||||
return description === undefined ? declaration : { ...declaration, description };
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function coerceToolSchemas(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
|
||||
return tools.map((tool) => {
|
||||
if (!isPlainObject(tool)) return tool;
|
||||
|
||||
const result: JsonRecord = { ...tool };
|
||||
|
||||
if (isPlainObject(result.function) && "parameters" in result.function) {
|
||||
result.function = {
|
||||
...result.function,
|
||||
parameters: coerceSchemaNumericFields(result.function.parameters),
|
||||
};
|
||||
}
|
||||
|
||||
if (result.input_schema !== undefined) {
|
||||
result.input_schema = coerceSchemaNumericFields(result.input_schema);
|
||||
}
|
||||
|
||||
if ("parameters" in result && !isPlainObject(result.function)) {
|
||||
result.parameters = coerceSchemaNumericFields(result.parameters);
|
||||
}
|
||||
|
||||
if (Array.isArray(result.functionDeclarations)) {
|
||||
result.functionDeclarations = result.functionDeclarations.map((declaration) => {
|
||||
if (!isPlainObject(declaration) || !("parameters" in declaration)) return declaration;
|
||||
return {
|
||||
...declaration,
|
||||
parameters: coerceSchemaNumericFields(declaration.parameters),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeToolDescriptions(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
return tools.map((tool) => sanitizeToolDescription(tool));
|
||||
}
|
||||
|
||||
export function injectEmptyReasoningContentForToolCalls(
|
||||
messages: unknown,
|
||||
provider: unknown
|
||||
): unknown {
|
||||
if (!Array.isArray(messages) || String(provider || "").toLowerCase() !== "deepseek") {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages.map((message) => {
|
||||
if (!isPlainObject(message)) return message;
|
||||
if (
|
||||
message.role !== "assistant" ||
|
||||
!Array.isArray(message.tool_calls) ||
|
||||
message.tool_calls.length === 0 ||
|
||||
message.reasoning_content !== undefined
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return { ...message, reasoning_content: "" };
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { FORMATS } from "./formats.ts";
|
||||
import { ensureToolCallIds, fixMissingToolResponses } from "./helpers/toolCallHelper.ts";
|
||||
import { prepareClaudeRequest } from "./helpers/claudeHelper.ts";
|
||||
import { filterToOpenAIFormat } from "./helpers/openaiHelper.ts";
|
||||
import {
|
||||
coerceToolSchemas,
|
||||
injectEmptyReasoningContentForToolCalls,
|
||||
sanitizeToolDescriptions,
|
||||
} from "./helpers/schemaCoercion.ts";
|
||||
import { getRequestTranslator, getResponseTranslator } from "./registry.ts";
|
||||
import { bootstrapTranslatorRegistry } from "./bootstrap.ts";
|
||||
import { normalizeThinkingConfig } from "../services/provider.ts";
|
||||
@@ -171,6 +176,15 @@ export function translateRequest(
|
||||
);
|
||||
}
|
||||
|
||||
if (result.tools !== undefined) {
|
||||
result.tools = coerceToolSchemas(result.tools);
|
||||
result.tools = sanitizeToolDescriptions(result.tools);
|
||||
}
|
||||
|
||||
if (targetFormat === FORMATS.OPENAI && result.messages && Array.isArray(result.messages)) {
|
||||
result.messages = injectEmptyReasoningContentForToolCalls(result.messages, provider);
|
||||
}
|
||||
|
||||
// Ensure unique tool_call ids on final payload (translators may have introduced duplicates)
|
||||
ensureToolCallIds(result, { use9CharId });
|
||||
fixMissingToolResponses(result);
|
||||
|
||||
@@ -18,6 +18,11 @@ export default function SystemStorageTab() {
|
||||
const [importStatus, setImportStatus] = useState({ type: "", message: "" });
|
||||
const [confirmImport, setConfirmImport] = useState(false);
|
||||
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
|
||||
const [maxCallLogs, setMaxCallLogs] = useState(10000);
|
||||
const [maxCallLogsDraft, setMaxCallLogsDraft] = useState("10000");
|
||||
const [settingsLoading, setSettingsLoading] = useState(true);
|
||||
const [maxCallLogsSaving, setMaxCallLogsSaving] = useState(false);
|
||||
const [maxCallLogsStatus, setMaxCallLogsStatus] = useState({ type: "", message: "" });
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("settings");
|
||||
@@ -54,6 +59,27 @@ export default function SystemStorageTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
setSettingsLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const value =
|
||||
typeof data.maxCallLogs === "number" &&
|
||||
Number.isInteger(data.maxCallLogs) &&
|
||||
data.maxCallLogs > 0
|
||||
? data.maxCallLogs
|
||||
: 10000;
|
||||
setMaxCallLogs(value);
|
||||
setMaxCallLogsDraft(String(value));
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch settings:", err);
|
||||
} finally {
|
||||
setSettingsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualBackup = async () => {
|
||||
setManualBackupLoading(true);
|
||||
setManualBackupStatus({ type: "", message: "" });
|
||||
@@ -119,8 +145,47 @@ export default function SystemStorageTab() {
|
||||
|
||||
useEffect(() => {
|
||||
loadStorageHealth();
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const handleSaveMaxCallLogs = async () => {
|
||||
const parsed = Number.parseInt(maxCallLogsDraft, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
setMaxCallLogsStatus({
|
||||
type: "error",
|
||||
message: "Enter a positive integer for the call log limit.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setMaxCallLogsSaving(true);
|
||||
setMaxCallLogsStatus({ type: "", message: "" });
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ maxCallLogs: parsed }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to save call log limit");
|
||||
}
|
||||
setMaxCallLogs(parsed);
|
||||
setMaxCallLogsDraft(String(parsed));
|
||||
setMaxCallLogsStatus({
|
||||
type: "success",
|
||||
message: "Call log retention limit saved.",
|
||||
});
|
||||
} catch (err) {
|
||||
setMaxCallLogsStatus({
|
||||
type: "error",
|
||||
message: (err as Error).message || "Failed to save call log limit",
|
||||
});
|
||||
} finally {
|
||||
setMaxCallLogsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
@@ -276,6 +341,56 @@ export default function SystemStorageTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">Call log retention limit</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Keep only the most recent call log entries in SQLite. Older entries are pruned
|
||||
automatically after each new request log is saved.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="default" size="sm">
|
||||
{maxCallLogs.toLocaleString()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-3">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={maxCallLogsDraft}
|
||||
onChange={(e) => setMaxCallLogsDraft(e.target.value)}
|
||||
disabled={settingsLoading || maxCallLogsSaving}
|
||||
className="w-40 rounded-lg border border-border bg-bg-secondary px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
aria-label="Call log retention limit"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSaveMaxCallLogs}
|
||||
loading={maxCallLogsSaving}
|
||||
disabled={settingsLoading}
|
||||
>
|
||||
Save limit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{maxCallLogsStatus.message && (
|
||||
<div
|
||||
className={`mt-3 rounded-lg border px-3 py-2 text-sm ${
|
||||
maxCallLogsStatus.type === "success"
|
||||
? "border-green-500/20 bg-green-500/10 text-green-500"
|
||||
: "border-red-500/20 bg-red-500/10 text-red-500"
|
||||
}`}
|
||||
role="alert"
|
||||
>
|
||||
{maxCallLogsStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export / Import */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<Button variant="outline" size="sm" onClick={handleExport} loading={exportLoading}>
|
||||
|
||||
@@ -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,8 +59,8 @@ const OAUTH_TEST_CONFIG = {
|
||||
refreshable: true,
|
||||
},
|
||||
qwen: {
|
||||
// portal.qwen.ai/v1/models returns 404 — endpoint no longer exists.
|
||||
// Use checkExpiry instead — actual connectivity is validated via real requests.
|
||||
// Qwen OAuth is validated via token expiry; model import uses DashScope /models.
|
||||
// Use checkExpiry here — actual request connectivity is validated via real calls.
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
|
||||
@@ -114,6 +114,11 @@ export async function PATCH(request) {
|
||||
setCliCompatProviders(body.cliCompatProviders || []);
|
||||
}
|
||||
|
||||
if ("maxCallLogs" in body) {
|
||||
const { invalidateCallLogsMaxCache } = await import("@/lib/usage/callLogs");
|
||||
invalidateCallLogsMaxCache();
|
||||
}
|
||||
|
||||
const { password, ...safeSettings } = settings;
|
||||
return NextResponse.json(safeSettings);
|
||||
} catch (error) {
|
||||
|
||||
@@ -48,7 +48,8 @@ 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_CALL_LOGS_MAX = 10_000;
|
||||
const CALL_LOGS_MAX_CACHE_TTL_MS = 30_000;
|
||||
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();
|
||||
@@ -57,6 +58,55 @@ const CALL_LOG_PAYLOAD_MODE = (() => {
|
||||
const shouldLogPayloadInDb = CALL_LOG_PAYLOAD_MODE !== "none";
|
||||
const shouldLogPayloadOnDisk = CALL_LOG_PAYLOAD_MODE === "full";
|
||||
|
||||
let callLogsMaxCache = {
|
||||
value: resolveCallLogsMaxValue(process.env.CALL_LOGS_MAX) ?? DEFAULT_CALL_LOGS_MAX,
|
||||
expiresAt: 0,
|
||||
};
|
||||
|
||||
function resolveCallLogsMaxValue(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getMaxCallLogs(): Promise<number> {
|
||||
const now = Date.now();
|
||||
if (callLogsMaxCache.expiresAt > now) {
|
||||
return callLogsMaxCache.value;
|
||||
}
|
||||
|
||||
let value = resolveCallLogsMaxValue(process.env.CALL_LOGS_MAX) ?? DEFAULT_CALL_LOGS_MAX;
|
||||
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/localDb");
|
||||
const settings = await getSettings();
|
||||
const configured =
|
||||
resolveCallLogsMaxValue(settings.maxCallLogs) ??
|
||||
resolveCallLogsMaxValue(settings.MAX_CALL_LOGS);
|
||||
if (configured !== null) {
|
||||
value = configured;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to env/default cap when settings are unavailable.
|
||||
}
|
||||
|
||||
callLogsMaxCache = {
|
||||
value,
|
||||
expiresAt: now + CALL_LOGS_MAX_CACHE_TTL_MS,
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
export function invalidateCallLogsMaxCache(): void {
|
||||
callLogsMaxCache = {
|
||||
value: resolveCallLogsMaxValue(process.env.CALL_LOGS_MAX) ?? DEFAULT_CALL_LOGS_MAX,
|
||||
expiresAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fields that should always be redacted from logged payloads */
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
"api_key",
|
||||
@@ -212,17 +262,18 @@ export async function saveCallLog(entry: any) {
|
||||
`
|
||||
).run(logEntry);
|
||||
|
||||
// 2. Trim old entries beyond CALL_LOGS_MAX
|
||||
// 2. Trim old entries beyond the configured call log cap
|
||||
const maxCallLogs = 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 > maxCallLogs) {
|
||||
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 - maxCallLogs);
|
||||
}
|
||||
|
||||
// 3. Write full payload to disk file (untruncated)
|
||||
|
||||
@@ -98,6 +98,47 @@ export const CLI_TOOLS = {
|
||||
{ step: 6, title: "Select Model", type: "modelSelector" },
|
||||
],
|
||||
},
|
||||
windsurf: {
|
||||
id: "windsurf",
|
||||
name: "Windsurf",
|
||||
icon: "airwave",
|
||||
color: "#06B6D4",
|
||||
description: "Windsurf IDE guide with current official limitations",
|
||||
docsUrl: "https://docs.windsurf.com/windsurf/models",
|
||||
configType: "guide",
|
||||
notes: [
|
||||
{
|
||||
type: "warning",
|
||||
text: "Official Windsurf docs currently describe BYOK for select Claude models plus enterprise URL/token settings, not a generic custom OpenAI-compatible provider.",
|
||||
},
|
||||
{
|
||||
type: "warning",
|
||||
text: "The official proxy documentation is for network proxies. Verify the latest Windsurf docs before attempting to point it at OmniRoute as a model provider.",
|
||||
},
|
||||
],
|
||||
guideSteps: [
|
||||
{
|
||||
step: 1,
|
||||
title: "Open AI settings",
|
||||
desc: "Open Windsurf settings and review the current AI model or subscription configuration.",
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: "Review BYOK",
|
||||
desc: "Check the official BYOK/models page for the currently supported provider and model options.",
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
title: "Check enterprise setup",
|
||||
desc: "If your organization uses Windsurf enterprise features, follow the official enterprise URL/token flow from Windsurf docs.",
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
title: "Re-verify support",
|
||||
desc: "Only use OmniRoute directly if Windsurf ships official custom provider or base URL support in a later release.",
|
||||
},
|
||||
],
|
||||
},
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
@@ -59,6 +59,13 @@ const CLI_TOOLS: Record<string, any> = {
|
||||
state: ".cursor/agent-cli-state.json",
|
||||
},
|
||||
},
|
||||
windsurf: {
|
||||
defaultCommand: null,
|
||||
envBinKey: "CLI_WINDSURF_BIN",
|
||||
requiresBinary: false,
|
||||
healthcheckTimeoutMs: 4000,
|
||||
paths: {},
|
||||
},
|
||||
cline: {
|
||||
defaultCommand: "cline",
|
||||
envBinKey: "CLI_CLINE_BIN",
|
||||
|
||||
@@ -149,6 +149,7 @@ export const updateSettingsSchema = z.object({
|
||||
instanceName: z.string().max(100).optional(),
|
||||
corsOrigins: z.string().max(500).optional(),
|
||||
logRetentionDays: z.number().int().min(1).max(365).optional(),
|
||||
maxCallLogs: z.number().int().min(1).max(1_000_000).optional(),
|
||||
cloudUrl: z.string().max(500).optional(),
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
setupComplete: z.boolean().optional(),
|
||||
|
||||
@@ -18,6 +18,7 @@ export const updateSettingsSchema = z.object({
|
||||
instanceName: z.string().max(100).optional(),
|
||||
corsOrigins: z.string().max(500).optional(),
|
||||
logRetentionDays: z.number().int().min(1).max(365).optional(),
|
||||
maxCallLogs: z.number().int().min(1).max(1_000_000).optional(),
|
||||
cloudUrl: z.string().max(500).optional(),
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
setupComplete: z.boolean().optional(),
|
||||
|
||||
54
tests/unit/call-log-cap.test.mjs
Normal file
54
tests/unit/call-log-cap.test.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-calllogs-cap-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
const callLogs = await import("../../src/lib/usage/callLogs.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
callLogs.invalidateCallLogsMaxCache();
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("call logs respect the configurable maxCallLogs setting", async () => {
|
||||
await localDb.updateSettings({ maxCallLogs: 3 });
|
||||
callLogs.invalidateCallLogsMaxCache();
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await callLogs.saveCallLog({
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 200,
|
||||
model: `model-${i}`,
|
||||
provider: "openai",
|
||||
duration: i,
|
||||
requestBody: { index: i },
|
||||
responseBody: { ok: true, index: i },
|
||||
});
|
||||
}
|
||||
|
||||
const logs = await callLogs.getCallLogs({ limit: 10 });
|
||||
|
||||
assert.equal(logs.length, 3);
|
||||
assert.deepEqual(
|
||||
logs.map((entry) => entry.model),
|
||||
["model-5", "model-4", "model-3"]
|
||||
);
|
||||
});
|
||||
@@ -37,6 +37,7 @@ describe("CLI_TOOL_IDS", () => {
|
||||
"droid",
|
||||
"openclaw",
|
||||
"cursor",
|
||||
"windsurf",
|
||||
"cline",
|
||||
"kilo",
|
||||
"continue",
|
||||
@@ -160,6 +161,15 @@ describe("continue tool — no binary required", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("windsurf tool — guide-only integration", () => {
|
||||
it("should report installed=true without requiring a local binary", async () => {
|
||||
const result = await getCliRuntimeStatus("windsurf");
|
||||
assert.equal(result.installed, true);
|
||||
assert.equal(result.runnable, true);
|
||||
assert.equal(result.reason, "not_required");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveOpencodeConfigPath — cross-platform ─────────────────
|
||||
|
||||
const { resolveOpencodeConfigPath: resolveOpencodeConfigPathFn } =
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { getModelInfoCore } from "../../open-sse/services/model.ts";
|
||||
import { REGISTRY } from "../../open-sse/config/providerRegistry.ts";
|
||||
import { getStaticModelsForProvider } from "../../src/app/api/providers/[id]/models/route.ts";
|
||||
|
||||
test("T28: gemini catalog includes preview models from 9router", () => {
|
||||
const geminiIds = REGISTRY.gemini.models.map((m) => m.id);
|
||||
@@ -14,6 +15,20 @@ test("T28: gemini catalog includes preview models from 9router", () => {
|
||||
assert.ok(geminiCliIds.includes("gemini-3-flash-preview"));
|
||||
});
|
||||
|
||||
test("T28: antigravity static catalog includes Gemini 3.1 preview fallbacks", () => {
|
||||
const staticIds = (getStaticModelsForProvider("antigravity") || []).map((m) => m.id);
|
||||
|
||||
assert.ok(staticIds.includes("gemini-3.1-pro-preview"));
|
||||
assert.ok(staticIds.includes("gemini-3.1-flash-lite-preview"));
|
||||
});
|
||||
|
||||
test("T28: qwen registry uses DashScope-compatible base URL", () => {
|
||||
assert.equal(
|
||||
REGISTRY.qwen.baseUrl,
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
test("T28: vertex catalog includes partner models when vertex executor is available", () => {
|
||||
const vertexIds = REGISTRY.vertex.models.map((m) => m.id);
|
||||
|
||||
|
||||
@@ -47,7 +47,11 @@ test("T38: modelSpecs exposes centralized helpers with alias and prefix lookup",
|
||||
assert.equal(typeof MODEL_SPECS["gemini-3.1-pro-high"], "object");
|
||||
assert.equal(getModelSpec("gemini-3-pro-high").maxOutputTokens, 131072);
|
||||
assert.equal(getModelSpec("gemini-3-flash-preview").maxOutputTokens, 65536);
|
||||
assert.equal(getModelSpec("gemini-3.1-pro-preview").maxOutputTokens, 131072);
|
||||
assert.equal(getModelSpec("gemini-3.1-pro-preview-customtools").maxOutputTokens, 131072);
|
||||
assert.equal(resolveModelAlias("gemini-3-pro-low"), "gemini-3.1-pro-low");
|
||||
assert.equal(resolveModelAlias("gemini-3.1-pro-preview"), "gemini-3.1-pro-high");
|
||||
assert.equal(resolveModelAlias("gemini-3.1-pro-preview-customtools"), "gemini-3.1-pro-high");
|
||||
assert.equal(getDefaultThinkingBudget("gemini-3.1-pro-high"), 24576);
|
||||
assert.equal(capThinkingBudget("gemini-3.1-pro-low", 50000), 16000);
|
||||
});
|
||||
|
||||
@@ -65,3 +65,18 @@ test("T40: OpenCode config generator includes endpoint and selected API key", ()
|
||||
assert.equal(mergedConfig.providers.omniroute.baseURL, "http://localhost:20128/v1");
|
||||
assert.equal(mergedConfig.providers.omniroute.apiKey, "sk_test_opencode");
|
||||
});
|
||||
|
||||
test("T40: Windsurf card documents current official limitations honestly", () => {
|
||||
const windsurf = CLI_TOOLS.windsurf;
|
||||
assert.ok(windsurf, "Windsurf tool card must exist");
|
||||
assert.equal(windsurf.configType, "guide");
|
||||
|
||||
const notesText = (windsurf.notes || [])
|
||||
.map((note) => note?.text || "")
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
assert.match(notesText, /byok/);
|
||||
assert.match(notesText, /custom openai-compatible provider/);
|
||||
assert.match(notesText, /proxy documentation is for network proxies/);
|
||||
});
|
||||
|
||||
192
tests/unit/tool-request-sanitization.test.mjs
Normal file
192
tests/unit/tool-request-sanitization.test.mjs
Normal file
@@ -0,0 +1,192 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
coerceSchemaNumericFields,
|
||||
sanitizeToolDescription,
|
||||
coerceToolSchemas,
|
||||
sanitizeToolDescriptions,
|
||||
injectEmptyReasoningContentForToolCalls,
|
||||
} = await import("../../open-sse/translator/helpers/schemaCoercion.ts");
|
||||
const { translateRequest } = await import("../../open-sse/translator/index.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
test("tool sanitization: coerces numeric JSON Schema fields recursively", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", minimum: "1", maximum: "10" },
|
||||
items: {
|
||||
type: "array",
|
||||
minItems: "2",
|
||||
items: { type: "string", minLength: "3" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = coerceSchemaNumericFields(schema);
|
||||
assert.equal(result.properties.count.minimum, 1);
|
||||
assert.equal(result.properties.count.maximum, 10);
|
||||
assert.equal(result.properties.items.minItems, 2);
|
||||
assert.equal(result.properties.items.items.minLength, 3);
|
||||
});
|
||||
|
||||
test("tool sanitization: preserves non-numeric JSON Schema strings", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string", minimum: "abc" },
|
||||
},
|
||||
};
|
||||
|
||||
const result = coerceSchemaNumericFields(schema);
|
||||
assert.equal(result.properties.value.minimum, "abc");
|
||||
});
|
||||
|
||||
test("tool sanitization: normalizes descriptions across OpenAI, Claude, and Gemini shapes", () => {
|
||||
const openAITool = sanitizeToolDescription({
|
||||
type: "function",
|
||||
function: { name: "sum", description: null, parameters: {} },
|
||||
});
|
||||
const claudeTool = sanitizeToolDescription({
|
||||
name: "sum",
|
||||
description: 42,
|
||||
input_schema: { type: "object" },
|
||||
});
|
||||
const geminiTool = sanitizeToolDescription({
|
||||
functionDeclarations: [{ name: "sum", description: false, parameters: {} }],
|
||||
});
|
||||
|
||||
assert.equal(openAITool.function.description, "");
|
||||
assert.equal(claudeTool.description, "42");
|
||||
assert.equal(geminiTool.functionDeclarations[0].description, "false");
|
||||
});
|
||||
|
||||
test("tool sanitization: coerces schemas and descriptions in tool arrays", () => {
|
||||
const tools = sanitizeToolDescriptions(
|
||||
coerceToolSchemas([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "sum",
|
||||
description: 5,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", minimum: "1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
assert.equal(tools[0].function.description, "5");
|
||||
assert.equal(tools[0].function.parameters.properties.count.minimum, 1);
|
||||
});
|
||||
|
||||
test("translateRequest sanitizes tools before Claude output", () => {
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
"claude-sonnet-4-6",
|
||||
{
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "sum",
|
||||
description: null,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", minimum: "1", maximum: "9" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"claude"
|
||||
);
|
||||
|
||||
assert.equal(translated.tools[0].description, "");
|
||||
assert.equal(translated.tools[0].input_schema.properties.count.minimum, 1);
|
||||
assert.equal(translated.tools[0].input_schema.properties.count.maximum, 9);
|
||||
});
|
||||
|
||||
test("translateRequest sanitizes OpenAI tool payloads on passthrough", () => {
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI,
|
||||
"gpt-5.2",
|
||||
{
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "sum",
|
||||
description: 7,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", minimum: "2" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"openai"
|
||||
);
|
||||
|
||||
assert.equal(translated.tools[0].function.description, "7");
|
||||
assert.equal(translated.tools[0].function.parameters.properties.count.minimum, 2);
|
||||
});
|
||||
|
||||
test("tool sanitization: injects empty reasoning_content only for DeepSeek tool-call history", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "sum", arguments: "{}" } }],
|
||||
},
|
||||
];
|
||||
|
||||
const deepseekMessages = injectEmptyReasoningContentForToolCalls(messages, "deepseek");
|
||||
const openaiMessages = injectEmptyReasoningContentForToolCalls(messages, "openai");
|
||||
|
||||
assert.equal(deepseekMessages[1].reasoning_content, "");
|
||||
assert.equal(openaiMessages[1].reasoning_content, undefined);
|
||||
});
|
||||
|
||||
test("translateRequest injects reasoning_content for DeepSeek assistant tool calls", () => {
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI,
|
||||
"deepseek-reasoner",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{ id: "call_1", type: "function", function: { name: "sum", arguments: "{}" } },
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: "3" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"deepseek"
|
||||
);
|
||||
|
||||
assert.equal(translated.messages[1].reasoning_content, "");
|
||||
});
|
||||
Reference in New Issue
Block a user