diff --git a/open-sse/services/autoCombo/__tests__/autoCombo.test.ts b/open-sse/services/autoCombo/__tests__/autoCombo.test.ts index 23ffeb8e9d..b2e2b15876 100644 --- a/open-sse/services/autoCombo/__tests__/autoCombo.test.ts +++ b/open-sse/services/autoCombo/__tests__/autoCombo.test.ts @@ -8,6 +8,8 @@ import type { ProviderCandidate, ScoringWeights } from "../scoring"; import { getTaskFitness, getTaskTypes } from "../taskFitness"; import { SelfHealingManager } from "../selfHealing"; import { MODE_PACKS, getModePack, getModePackNames } from "../modePacks"; +import { getStrategy } from "../routerStrategy"; +import type { RoutingContext } from "../routerStrategy"; describe("Scoring", () => { const candidate: ProviderCandidate = { @@ -160,3 +162,71 @@ describe("Mode Packs", () => { expect(getModePack("nonexistent")).toBeUndefined(); }); }); + +describe("LKGP Strategy", () => { + const pool: ProviderCandidate[] = [ + { + provider: "anthropic", + model: "claude-sonnet", + quotaRemaining: 80, + quotaTotal: 100, + circuitBreakerState: "CLOSED", + costPer1MTokens: 3, + p95LatencyMs: 1200, + latencyStdDev: 120, + errorRate: 0.02, + }, + { + provider: "openai", + model: "gpt-4o", + quotaRemaining: 90, + quotaTotal: 100, + circuitBreakerState: "CLOSED", + costPer1MTokens: 5, + p95LatencyMs: 800, + latencyStdDev: 80, + errorRate: 0.01, + }, + ]; + + it("should fall back to rules strategy when lkgpEnabled is false", () => { + const context: RoutingContext = { + taskType: "coding", + lastKnownGoodProvider: "anthropic", + lkgpEnabled: false, + }; + const lkgpStrategy = getStrategy("lkgp"); + const rulesStrategy = getStrategy("rules"); + + const lkgpResult = lkgpStrategy.select(pool, context); + const rulesResult = rulesStrategy.select(pool, context); + + expect(lkgpResult.strategy).toBe("rules"); + expect(lkgpResult.provider).toBe(rulesResult.provider); + }); + + it("should use LKGP provider when lkgpEnabled is true", () => { + const context: RoutingContext = { + taskType: "coding", + lastKnownGoodProvider: "anthropic", + lkgpEnabled: true, + }; + const lkgpStrategy = getStrategy("lkgp"); + const result = lkgpStrategy.select(pool, context); + + expect(result.strategy).toBe("lkgp"); + expect(result.provider).toBe("anthropic"); + }); + + it("should use LKGP provider when lkgpEnabled is undefined (default)", () => { + const context: RoutingContext = { + taskType: "coding", + lastKnownGoodProvider: "openai", + }; + const lkgpStrategy = getStrategy("lkgp"); + const result = lkgpStrategy.select(pool, context); + + expect(result.strategy).toBe("lkgp"); + expect(result.provider).toBe("openai"); + }); +}); diff --git a/open-sse/services/autoCombo/routerStrategy.ts b/open-sse/services/autoCombo/routerStrategy.ts index ec60b72939..1580441ff7 100644 --- a/open-sse/services/autoCombo/routerStrategy.ts +++ b/open-sse/services/autoCombo/routerStrategy.ts @@ -17,6 +17,7 @@ export interface RoutingContext { requestHasVision?: boolean; estimatedInputTokens?: number; lastKnownGoodProvider?: string; + lkgpEnabled?: boolean; } export interface RoutingDecision { @@ -124,6 +125,10 @@ class LKGPStrategyImpl implements RouterStrategy { readonly description = "Tries last known good provider first, then falls back to rules"; select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision { + if (context.lkgpEnabled === false) { + return getStrategy("rules").select(pool, context); + } + if (context.lastKnownGoodProvider) { const best = pool.find( (c) => c.provider === context.lastKnownGoodProvider && c.circuitBreakerState !== "OPEN" diff --git a/open-sse/translator/helpers/__tests__/maxTokensHelper.test.ts b/open-sse/translator/helpers/__tests__/maxTokensHelper.test.ts new file mode 100644 index 0000000000..37a25cabe4 --- /dev/null +++ b/open-sse/translator/helpers/__tests__/maxTokensHelper.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { adjustMaxTokens } from "../maxTokensHelper.ts"; + +describe("adjustMaxTokens - negative values", () => { + it("clamps large negative max_tokens to 1", () => { + const result = adjustMaxTokens({ max_tokens: -36398 }); + expect(result).toBe(1); + }); + + it("clamps -1 max_tokens to 1", () => { + const result = adjustMaxTokens({ max_tokens: -1 }); + expect(result).toBe(1); + }); + + it("does not clamp positive values", () => { + const result = adjustMaxTokens({ max_tokens: 4096 }); + expect(result).toBe(4096); + }); + + it("uses DEFAULT_MAX_TOKENS when max_tokens is 0 or undefined", () => { + const result = adjustMaxTokens({}); + expect(result).toBeGreaterThan(0); + }); +}); diff --git a/open-sse/translator/helpers/__tests__/schemaCoercion.test.ts b/open-sse/translator/helpers/__tests__/schemaCoercion.test.ts new file mode 100644 index 0000000000..2e0bd9bcab --- /dev/null +++ b/open-sse/translator/helpers/__tests__/schemaCoercion.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeToolId } from "../schemaCoercion.ts"; + +describe("sanitizeToolId", () => { + it('sanitizes dots and colons to underscores: "call.abc:123" → "call_abc_123"', () => { + expect(sanitizeToolId("call.abc:123")).toBe("call_abc_123"); + }); + + it("generates a valid fallback ID for undefined input", () => { + const result = sanitizeToolId(undefined); + expect(result).toMatch(/^tool_[a-z0-9_]+$/); + }); + + it("generates a valid fallback ID for empty string input", () => { + const result = sanitizeToolId(""); + expect(result).toMatch(/^tool_[a-z0-9_]+$/); + }); + + it("preserves already-valid IDs", () => { + expect(sanitizeToolId("toolu_abc123")).toBe("toolu_abc123"); + expect(sanitizeToolId("call-xyz_789")).toBe("call-xyz_789"); + }); +}); diff --git a/open-sse/translator/helpers/maxTokensHelper.ts b/open-sse/translator/helpers/maxTokensHelper.ts index 8e32445230..b9d833e82b 100644 --- a/open-sse/translator/helpers/maxTokensHelper.ts +++ b/open-sse/translator/helpers/maxTokensHelper.ts @@ -16,5 +16,5 @@ export function adjustMaxTokens(body) { } } - return maxTokens; + return Math.max(1, maxTokens); } diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index 7255b9abe1..ecce28a0a7 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -183,6 +183,12 @@ export function sanitizeToolDescriptions(tools: unknown): unknown { return tools.map((tool) => sanitizeToolDescription(tool)); } +export function sanitizeToolId(id: string | undefined): string { + if (!id) return `tool_${crypto.randomUUID().replace(/-/g, "_")}`; + const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, "_"); + return sanitized || `tool_${crypto.randomUUID().replace(/-/g, "_")}`; +} + export function injectEmptyReasoningContentForToolCalls( messages: unknown, provider: unknown diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 169889d3d4..4eb6521c54 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -2,6 +2,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { CLAUDE_SYSTEM_PROMPT } from "../../config/constants.ts"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts"; +import { sanitizeToolId } from "../helpers/schemaCoercion.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; // Prefix for Claude OAuth tool names to avoid conflicts @@ -428,7 +429,12 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr // Tool name already has prefix from tool declarations, keep as-is // CRITICAL: Skip tool_use blocks with empty name (causes Claude 400 error) if (part.name && part.name.trim()) { - blocks.push({ type: "tool_use", id: part.id, name: part.name, input: part.input }); + blocks.push({ + type: "tool_use", + id: sanitizeToolId(part.id), + name: part.name, + input: part.input, + }); } } } @@ -450,7 +456,7 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr const toolName = disableToolPrefix ? fnName : CLAUDE_OAUTH_TOOL_PREFIX + fnName; blocks.push({ type: "tool_use", - id: tc.id, + id: sanitizeToolId(tc.id), name: toolName, input: tryParseJSON(tc.function.arguments), }); diff --git a/package.json b/package.json index 5bc5b88e48..7b1f07f720 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ "tailwindcss": "^4", "typescript": "^5.9.3", "typescript-eslint": "^8.56.0", - "vitest": "^4.0.18", + "vitest": "^4.1.2", "wait-on": "^9.0.4" }, "lint-staged": { diff --git a/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx index 60b218daa6..a63a3600a8 100644 --- a/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Card } from "@/shared/components"; import { useTranslations } from "next-intl"; @@ -29,18 +29,48 @@ export default function MemorySkillsTab() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [status, setStatus] = useState(""); + const [skillsmpApiKey, setSkillsmpApiKey] = useState(""); + const [skillsmpSaving, setSkillsmpSaving] = useState(false); + const [skillsmpStatus, setSkillsmpStatus] = useState(""); const t = useTranslations("settings"); useEffect(() => { - fetch("/api/settings/memory") - .then((res) => res.json()) - .then((data) => { - setConfig(data); + Promise.all([ + fetch("/api/settings/memory").then((res) => res.json()), + fetch("/api/settings").then((res) => res.json()), + ]) + .then(([memData, settingsData]) => { + setConfig(memData); + if (settingsData.skillsmpApiKey) { + setSkillsmpApiKey(settingsData.skillsmpApiKey); + } setLoading(false); }) .catch(() => setLoading(false)); }, []); + const saveSkillsmpApiKey = useCallback(async () => { + setSkillsmpSaving(true); + setSkillsmpStatus(""); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ skillsmpApiKey }), + }); + if (res.ok) { + setSkillsmpStatus("saved"); + setTimeout(() => setSkillsmpStatus(""), 2000); + } else { + setSkillsmpStatus("error"); + } + } catch { + setSkillsmpStatus("error"); + } finally { + setSkillsmpSaving(false); + } + }, [skillsmpApiKey]); + const save = async (updates: Partial) => { const newConfig = { ...config, ...updates }; setConfig(newConfig); @@ -246,6 +276,56 @@ export default function MemorySkillsTab() {

{t("skillsComingSoon")}

+ + {/* SkillsMP Marketplace API Key */} + +
+
+ +
+
+

SkillsMP Marketplace

+

+ Connect to SkillsMP to discover and install skills from the marketplace. +

+
+ {skillsmpStatus === "saved" && ( + + check_circle{" "} + {t("saved")} + + )} + {skillsmpStatus === "error" && ( + Failed to save + )} +
+ +
+ +
+ setSkillsmpApiKey(e.target.value)} + placeholder="sk_live_..." + className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-violet-500" + /> + +
+

+ Get your API key from skillsmp.com. Rate limit: + 500 requests/day. +

+
+
); } diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index f6b1fe35f7..8b5cadaf3b 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -25,6 +25,8 @@ export default function RoutingTab() { }); const [loading, setLoading] = useState(true); const [aliases, setAliases] = useState([]); + const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false); + const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" }); const [newPattern, setNewPattern] = useState(""); const [newTarget, setNewTarget] = useState(""); const t = useTranslations("settings"); @@ -186,6 +188,86 @@ export default function RoutingTab() { + {/* LKGP Toggle */} + +
+
+
+ +
+
+

+ {t("lkgpToggleTitle") || "Last Known Good Provider (LKGP)"} +

+

+ {t("lkgpToggleDesc") || + "When enabled, the router remembers which provider last served a successful response and tries it first on subsequent requests."} +

+
+
+
+ +
+
+
+ + {lkgpCacheStatus.message && ( + + {lkgpCacheStatus.message} + + )} +
+
+ {/* Wildcard Aliases */}
diff --git a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx index 96c821dbcc..85b4002c68 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx @@ -18,6 +18,10 @@ export default function SystemStorageTab() { const [importStatus, setImportStatus] = useState({ type: "", message: "" }); const [confirmImport, setConfirmImport] = useState(false); const [pendingImportFile, setPendingImportFile] = useState(null); + const [clearCacheLoading, setClearCacheLoading] = useState(false); + const [clearCacheStatus, setClearCacheStatus] = useState({ type: "", message: "" }); + const [purgeLogsLoading, setPurgeLogsLoading] = useState(false); + const [purgeLogsStatus, setPurgeLogsStatus] = useState({ type: "", message: "" }); const fileInputRef = useRef(null); const locale = useLocale(); const t = useTranslations("settings"); @@ -463,6 +467,124 @@ export default function SystemStorageTab() {
)} + {/* Maintenance */} +
+
+ +

{t("maintenance") || "Maintenance"}

+
+
+ + +
+ {(clearCacheStatus.message || purgeLogsStatus.message) && ( +
+ {clearCacheStatus.message && ( +
+
+ + {clearCacheStatus.message} +
+
+ )} + {purgeLogsStatus.message && ( +
+
+ + {purgeLogsStatus.message} +
+
+ )} +
+ )} +
+ {/* Backup/Restore section */}
diff --git a/src/app/(dashboard)/dashboard/skills/page.tsx b/src/app/(dashboard)/dashboard/skills/page.tsx index 812adc5956..8c61703f7b 100644 --- a/src/app/(dashboard)/dashboard/skills/page.tsx +++ b/src/app/(dashboard)/dashboard/skills/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Card } from "@/shared/components"; import { useTranslations } from "next-intl"; @@ -26,7 +26,30 @@ export default function SkillsPage() { const [skills, setSkills] = useState([]); const [executions, setExecutions] = useState([]); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState<"skills" | "executions" | "sandbox">("skills"); + const [activeTab, setActiveTab] = useState<"skills" | "executions" | "sandbox" | "marketplace">( + "skills" + ); + const [showInstallModal, setShowInstallModal] = useState(false); + const [installJson, setInstallJson] = useState(""); + const [installStatus, setInstallStatus] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); + const [installing, setInstalling] = useState(false); + const fileInputRef = useRef(null); + const [mpQuery, setMpQuery] = useState(""); + const [mpResults, setMpResults] = useState< + { + name: string; + description: string; + skillMdContent?: string; + version?: string; + sourceUrl?: string; + }[] + >([]); + const [mpLoading, setMpLoading] = useState(false); + const [mpError, setMpError] = useState(""); + const [mpInstallingId, setMpInstallingId] = useState(null); const t = useTranslations("skills"); useEffect(() => { @@ -42,6 +65,11 @@ export default function SkillsPage() { .catch(() => setLoading(false)); }, []); + const refreshSkills = async () => { + const res = await fetch("/api/skills").then((r) => r.json()); + setSkills(res.skills || []); + }; + const toggleSkill = async (skillId: string, enabled: boolean) => { await fetch(`/api/skills/${skillId}`, { method: "PUT", @@ -51,6 +79,107 @@ export default function SkillsPage() { setSkills(skills.map((s) => (s.id === skillId ? { ...s, enabled: !enabled } : s))); }; + const deleteSkill = async (skillId: string) => { + const res = await fetch(`/api/skills/${skillId}`, { method: "DELETE" }); + if (res.ok) { + setSkills(skills.filter((s) => s.id !== skillId)); + } + }; + + const handleInstall = async () => { + setInstalling(true); + setInstallStatus(null); + try { + const manifest = JSON.parse(installJson); + const res = await fetch("/api/skills/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manifest), + }); + const data = await res.json(); + if (res.ok && data.success) { + setInstallStatus({ type: "success", message: `Skill installed (${data.id})` }); + setInstallJson(""); + await refreshSkills(); + } else { + setInstallStatus({ + type: "error", + message: data.error || data.message || "Install failed", + }); + } + } catch (err) { + setInstallStatus({ + type: "error", + message: err instanceof Error ? err.message : "Invalid JSON", + }); + } finally { + setInstalling(false); + } + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + setInstallJson((ev.target?.result as string) || ""); + }; + reader.readAsText(file); + }; + + const searchMarketplace = async () => { + setMpLoading(true); + setMpError(""); + setMpResults([]); + try { + const res = await fetch(`/api/skills/marketplace?q=${encodeURIComponent(mpQuery)}`); + const data = await res.json(); + if (!res.ok) { + setMpError(data.error || "Search failed"); + } else { + setMpResults(data.skills || []); + } + } catch (err) { + setMpError(err instanceof Error ? err.message : "Search failed"); + } finally { + setMpLoading(false); + } + }; + + const installFromMarketplace = async (skill: { + name: string; + description: string; + skillMdContent?: string; + version?: string; + sourceUrl?: string; + }) => { + setMpInstallingId(skill.name); + try { + const res = await fetch("/api/skills/marketplace/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: skill.name, + description: skill.description, + skillMdContent: skill.skillMdContent || skill.description, + version: skill.version || "1.0.0", + sourceUrl: skill.sourceUrl, + }), + }); + const data = await res.json(); + if (res.ok && data.success) { + await refreshSkills(); + setMpInstallingId(null); + } else { + setMpError(data.error || "Install failed"); + setMpInstallingId(null); + } + } catch (err) { + setMpError(err instanceof Error ? err.message : "Install failed"); + setMpInstallingId(null); + } + }; + if (loading) { return (
@@ -61,9 +190,17 @@ export default function SkillsPage() { return (
-
-

{t("title")}

-

{t("description")}

+
+
+

{t("title")}

+

{t("description")}

+
+
@@ -97,6 +234,16 @@ export default function SkillsPage() { > {t("sandboxTab")} +
{activeTab === "skills" && ( @@ -118,20 +265,28 @@ export default function SkillsPage() {

{skill.description}

- + + role="switch" + aria-checked={skill.enabled} + > + + +
)) @@ -225,6 +380,137 @@ export default function SkillsPage() {
)} + + {activeTab === "marketplace" && ( +
+ +

SkillsMP Marketplace

+
+ setMpQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchMarketplace()} + placeholder="Search skills..." + className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500" + /> + +
+ {mpError && ( +
+ {mpError} +
+ )} +
+ {mpResults.length > 0 && ( +
+ {mpResults.map((skill) => ( + +
+
+

{skill.name}

+

{skill.description}

+
+ +
+
+ ))} +
+ )} + {!mpLoading && mpResults.length === 0 && !mpError && ( + +
+ Configure your SkillsMP API key in Settings to browse the marketplace. +
+
+ )} +
+ )} + + {showInstallModal && ( +
+
+
+

Install Skill

+ +
+

+ Paste a skill manifest JSON or upload a .json file. +

+