From 87569d6a82549bcd37c89aa7bb767e2edb3ff664 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 29 May 2026 16:13:54 -0300 Subject: [PATCH 01/82] fix(usage): analytics route reads combo_name/requested_model from call_logs only The 3.8.6 variant of #2904 added SELECTs of combo_name/requested_model against usage_history, but those columns only exist in call_logs (no migration adds them to usage_history). This returned HTTP 500 on /api/usage/analytics. Restore the working query shape from the 3.8.7 variant. Fixes 18 failing usage-analytics-route tests. --- src/app/api/usage/analytics/route.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 6fd5475018..2d3f8f3c2d 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -404,9 +404,7 @@ export async function GET(request: Request) { latency_ms, connection_id, api_key_id, - api_key_name, - combo_name, - requested_model + api_key_name FROM usage_history ${rawWhere} UNION ALL @@ -424,22 +422,19 @@ export async function GET(request: Request) { 0 as latency_ms, NULL as connection_id, NULL as api_key_id, - NULL as api_key_name, - NULL as combo_name, - NULL as requested_model + NULL as api_key_name FROM daily_usage_summary ${aggWhere} - )` + )` : `(SELECT timestamp, provider, model, tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning, service_tier, success, latency_ms, - connection_id, api_key_id, api_key_name, - combo_name, requested_model + connection_id, api_key_id, api_key_name FROM usage_history ${whereClause} - )`; + )`; // When using the unified source the WHERE filters are already embedded inside. // For the original whereClause-based queries that still reference usage_history directly From a9bc0e86853a6665015427b5d2edf6b9d0be3fea Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 29 May 2026 16:32:16 -0300 Subject: [PATCH 02/82] fix(types,test): resolve noImplicitAny in progressiveAging + align semaphore test to #2903 gate pruning - progressiveAging: type compression results so messages[0].content is indexable (was TS7053 against {}); restores typecheck:noimplicit:core gate. - services-branch-hardening: #2903 (perf-ram) prunes idle rate-limit gates on zero; assert no-running/empty-queue without assuming the entry persists. --- open-sse/services/compression/progressiveAging.ts | 8 ++++++-- tests/unit/services-branch-hardening.test.ts | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/open-sse/services/compression/progressiveAging.ts b/open-sse/services/compression/progressiveAging.ts index be50599d07..3dbbdd6d77 100644 --- a/open-sse/services/compression/progressiveAging.ts +++ b/open-sse/services/compression/progressiveAging.ts @@ -12,6 +12,10 @@ function estimateTokens(text: string): number { type ChatMessage = ChatMessageLike; +type CompressedResult = { + body?: { messages?: Array<{ content?: ChatMessageLike["content"] }> }; +}; + function setContent(msg: ChatMessage, newContent: string): ChatMessage { return replaceTextContent(msg, newContent) as ChatMessage; } @@ -52,7 +56,7 @@ export function applyAging( if (distanceFromEnd <= t.verbatim) { result.push(msg); } else if (distanceFromEnd <= t.light) { - const compressed = applyLiteCompression({ messages: [msg] }); + const compressed = applyLiteCompression({ messages: [msg] }) as CompressedResult; if (compressed?.body?.messages?.[0]?.content) { const newContent = typeof compressed.body.messages[0].content === "string" @@ -65,7 +69,7 @@ export function applyAging( result.push(msg); } } else if (distanceFromEnd <= t.moderate) { - const compressed = cavemanCompress({ messages: [msg] as unknown as Parameters[0]["messages"] }); + const compressed = cavemanCompress({ messages: [msg] as unknown as Parameters[0]["messages"] }) as CompressedResult; if (compressed?.body?.messages?.[0]?.content) { const newContent = typeof compressed.body.messages[0].content === "string" diff --git a/tests/unit/services-branch-hardening.test.ts b/tests/unit/services-branch-hardening.test.ts index 76ebb95554..f02ec82824 100644 --- a/tests/unit/services-branch-hardening.test.ts +++ b/tests/unit/services-branch-hardening.test.ts @@ -193,7 +193,9 @@ test("rate limit semaphore covers immediate acquire, timeout, cooldown drain and release(); release(); - assert.equal(rateLimitSemaphore.getStats()["model-a"].running, 0); + // #2903 (perf-ram) prunes idle gates when they reach zero running/queued, so the + // entry may be absent here — assert "no running slots" without assuming it persists. + assert.equal(rateLimitSemaphore.getStats()["model-a"]?.running ?? 0, 0); const heldRelease = await rateLimitSemaphore.acquire("model-b", { maxConcurrency: 1 }); const timeoutPromise = rateLimitSemaphore.acquire("model-b", { @@ -214,7 +216,7 @@ test("rate limit semaphore covers immediate acquire, timeout, cooldown drain and assert.equal(rateLimitSemaphore.getStats()["model-c"].queued, 1); const secondRelease = await secondPromise; secondRelease(); - assert.equal(rateLimitSemaphore.getStats()["model-c"].queued, 0); + assert.equal(rateLimitSemaphore.getStats()["model-c"]?.queued ?? 0, 0); const blockingRelease = await rateLimitSemaphore.acquire("model-d", { maxConcurrency: 1 }); const queuedPromise = rateLimitSemaphore.acquire("model-d", { From d39d2719bb4694c0bd26adc4442ea2d334253776 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 29 May 2026 16:54:11 -0300 Subject: [PATCH 03/82] chore(release): sync v3.8.7 touchpoints + credit contributors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - llm.txt → 3.8.7 (Current version + Key Features header) - CHANGELOG: add Dmitry Kuznetsov & Nikolay Alafuzov to 3.8.6 Hall of Contributors - version already 3.8.7 across package.json/open-sse/electron/openapi (from #2909) --- CHANGELOG.md | 2 +- llm.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77af38b7db..e0d29e8cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,7 +134,7 @@ ### 🏆 Hall of Contributors A special thanks to everyone who contributed code, reviews, and tests for this release: -@akarray, @alltomatos, @androw, @apoapostolov, @Ardem2025, @dhaern, @disonjer, @gogones, @hartmark, @herjarsa, @InkshadeWoods, @jeferssonlemes, @leninejunior, @levonk, @marchlhw, @mugnimaestra, @nickwizard, @oyi77, @RajvardhanPatil07, @rdself, @soyelmismo, @Tushar49, @yunaamelia +@akarray, @alltomatos, @androw, @apoapostolov, @Ardem2025, @dhaern, @disonjer, @gogones, @hartmark, @herjarsa, @InkshadeWoods, @jeferssonlemes, @leninejunior, @levonk, @marchlhw, @mugnimaestra, @nickwizard, @oyi77, @RajvardhanPatil07, @rdself, @soyelmismo, @Tushar49, @yunaamelia, Dmitry Kuznetsov, Nikolay Alafuzov --- diff --git a/llm.txt b/llm.txt index 99c9c94b41..4d6320ec75 100644 --- a/llm.txt +++ b/llm.txt @@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.8.5 +**Current version:** 3.8.7 ## Tech Stack @@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.8.5) +## Key Features (v3.8.7) ### Core Proxy - **177 AI providers** with automatic format translation From 8eff0bda0160323f96d8af993b71a1a22e4f92dc Mon Sep 17 00:00:00 2001 From: dhaern Date: Sat, 30 May 2026 01:26:23 +0000 Subject: [PATCH 04/82] fix(antigravity): avoid visible signatureless tool history --- .../translator/request/openai-to-gemini.ts | 55 +++++++-- .../unit/translator-openai-to-gemini.test.ts | 105 ++++++++++++++---- 2 files changed, 128 insertions(+), 32 deletions(-) diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 3ad6aa7687..5959713867 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -108,7 +108,7 @@ type GeminiToolNameOptions = { stripNamespace?: boolean; functionResponseShape?: "result" | "output"; signatureNamespace?: string | null; - signaturelessToolCallMode?: "native" | "text"; + signaturelessToolCallMode?: "native" | "text" | "context"; }; type OpenAIToolCallLike = { @@ -209,6 +209,24 @@ function buildInertHistoricalToolResponseText(name: string, response: unknown): ].join("\n"); } +function escapeHistoricalContextAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function buildHistoricalToolResultContext(name: string, response: unknown): string { + const source = escapeHistoricalContextAttribute(name || "unknown"); + const result = typeof response === "string" ? response : stringifyHistoricalToolArguments(response); + return [ + ``, + result, + "", + ].join("\n"); +} + // Core: Convert OpenAI request to Gemini format (base for all variants) function openaiToGeminiBase( model: string, @@ -367,8 +385,10 @@ function openaiToGeminiBase( } let shouldUseEmbeddedSignature = !parts.some((p) => p.thoughtSignature); - const stringifySignaturelessToolCalls = - toolNameOptions.signaturelessToolCallMode === "text"; + const signaturelessToolCallMode = toolNameOptions.signaturelessToolCallMode; + const stringifySignaturelessToolCalls = signaturelessToolCallMode === "text"; + const contextualizeSignaturelessToolResponses = + signaturelessToolCallMode === "text" || signaturelessToolCallMode === "context"; for (const tc of toolCalls) { if (tc.type !== "function") continue; @@ -378,6 +398,9 @@ function openaiToGeminiBase( if (!fn) continue; const signatureForToolCall = resolvedSignatures.get(id); + if (!signatureForToolCall && contextualizeSignaturelessToolResponses) { + if (!toolCallIds.includes(id)) toolCallIds.push(id); + } if (!signatureForToolCall && stringifySignaturelessToolCalls) { const args = fn.arguments || "{}"; parts.push({ @@ -385,6 +408,9 @@ function openaiToGeminiBase( }); continue; } + if (!signatureForToolCall && signaturelessToolCallMode === "context") { + continue; + } const args = tryParseJSON(fn.arguments || "{}"); const embeddedThoughtSignature = shouldUseEmbeddedSignature @@ -405,7 +431,9 @@ function openaiToGeminiBase( }, }); - toolCallIds.push(id); + if (!contextualizeSignaturelessToolResponses || signatureForToolCall) { + toolCallIds.push(id); + } } if (parts.length > 0) { @@ -414,7 +442,7 @@ function openaiToGeminiBase( // Check if there are actual tool responses in the next messages const hasSignaturelessTextResponses = - stringifySignaturelessToolCalls && + contextualizeSignaturelessToolResponses && toolCalls.some((tc) => { const id = tc.id as string; return tc.type === "function" && !resolvedSignatures.has(id) && toolResponses[id]; @@ -426,6 +454,7 @@ function openaiToGeminiBase( const toolParts: GeminiPart[] = []; for (const fid of toolCallIds) { if (!toolResponses[fid]) continue; + if (contextualizeSignaturelessToolResponses && !resolvedSignatures.has(fid)) continue; let name = tcID2Name[fid]; if (!name) { @@ -458,10 +487,13 @@ function openaiToGeminiBase( }); } - if (stringifySignaturelessToolCalls) { + if (contextualizeSignaturelessToolResponses) { // Signature-less historical tool responses are represented as text // so strict Gemini/Antigravity endpoints don't reject them as native // functionResponse parts missing a matching thoughtSignature. + // In context mode the matching historical functionCall is omitted, + // avoiding pseudo tool-call records that Gemini Flash can repeat as + // the visible final answer. for (const tc of toolCalls) { const id = tc.id as string; if (tc.type !== "function" || !id) continue; @@ -470,7 +502,10 @@ function openaiToGeminiBase( const name = tcID2Name[id] || fn?.name || "unknown"; const resp = toolResponses[id]; toolParts.push({ - text: buildInertHistoricalToolResponseText(name, resp), + text: + signaturelessToolCallMode === "text" + ? buildInertHistoricalToolResponseText(name, resp) + : buildHistoricalToolResultContext(name, resp), }); } } @@ -551,7 +586,7 @@ export function openaiToGeminiRequest( stream: boolean, credentials: Record | null = null, options: { - signaturelessToolCallMode?: "native" | "text"; + signaturelessToolCallMode?: "native" | "text" | "context"; } = {} ) { // Thread the signature namespace so a thinking model's thoughtSignature (cached on the @@ -576,7 +611,7 @@ export function openaiToGeminiCLIRequest( options: { functionResponseShape?: "result" | "output"; signatureNamespace?: string | null; - signaturelessToolCallMode?: "native" | "text"; + signaturelessToolCallMode?: "native" | "text" | "context"; } = {} ) { return openaiToGeminiBase(model, body, stream, { @@ -697,7 +732,7 @@ export function openaiToAntigravityRequest(model, body, stream, credentials = nu : null; const geminiCLI = openaiToGeminiCLIRequest(model, body, stream, { signatureNamespace, - signaturelessToolCallMode: isThinkingGemini ? "text" : "native", + signaturelessToolCallMode: isThinkingGemini ? "context" : "native", }); if (isClaude) { diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index d08523baec..db592b07f1 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -14,9 +14,16 @@ const { tryParseJSON, } = await import("../../open-sse/translator/helpers/geminiHelper.ts"); const { ANTIGRAVITY_DEFAULT_SYSTEM } = await import("../../open-sse/config/constants.ts"); +const { clearGeminiThoughtSignatures } = await import( + "../../open-sse/services/geminiThoughtSignatureStore.ts" +); type UnknownRecord = Record; +test.beforeEach(() => { + clearGeminiThoughtSignatures(); +}); + function getFunctionCall(part: unknown) { assert.ok(part && typeof part === "object", "expected Gemini functionCall part"); const functionCall = (part as UnknownRecord).functionCall; @@ -629,7 +636,7 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () }); }); -test("OpenAI -> Antigravity Gemini preserves signature-less historical tool calls as inert text", () => { +test("OpenAI -> Antigravity Gemini omits signature-less historical tool calls and keeps response context", () => { const result = openaiToAntigravityRequest( "gemini-3.5-flash-low", { @@ -666,26 +673,23 @@ test("OpenAI -> Antigravity Gemini preserves signature-less historical tool call ); const modelTurn = result.request.contents.find((content) => content.role === "model"); - assert.ok(modelTurn, "expected a model turn"); assert.ok( - modelTurn.parts.some( - (part) => - typeof part.text === "string" && - part.text.includes("Historical tool-call record only") && - part.text.includes("Tool name: default_api:todowrite_ide") && - part.text.includes('Tool arguments JSON: {"todos":[]}') - ), - "expected signature-less tool call to be preserved as inert text" + !modelTurn || + !modelTurn.parts.some( + (part) => + typeof part.text === "string" && part.text.includes("Historical tool-call record only") + ), + "signature-less historical call must not be emitted as visible historical text" ); assert.equal( - modelTurn.parts.some( + modelTurn?.parts.some( (part) => typeof part.text === "string" && part.text.includes("[Tool call:") - ), + ) ?? false, false, "signature-less historical call must not use executable textual tool-call markers" ); assert.equal( - modelTurn.parts.some((part) => part.functionCall), + modelTurn?.parts.some((part) => part.functionCall) ?? false, false, "signature-less historical call must not be emitted as native functionCall" ); @@ -696,12 +700,11 @@ test("OpenAI -> Antigravity Gemini preserves signature-less historical tool call content.parts.some( (part) => typeof part.text === "string" && - part.text.includes("Historical tool-response record only") && - part.text.includes("Tool name: default_api:todowrite_ide") && - part.text.includes("Tool result: []") + part.text.includes('') && + part.text.includes("[]") ) ); - assert.ok(toolTurn, "expected signature-less tool response to be preserved as inert text"); + assert.ok(toolTurn, "expected signature-less tool response to be preserved as safe context"); assert.equal( toolTurn.parts.some( (part) => typeof part.text === "string" && part.text.includes("[Tool response:") @@ -716,7 +719,7 @@ test("OpenAI -> Antigravity Gemini preserves signature-less historical tool call ); }); -test("OpenAI -> Antigravity preserves multiple signature-less historical tool responses as text", () => { +test("OpenAI -> Antigravity preserves multiple signature-less historical tool responses as context", () => { const result = openaiToAntigravityRequest( "gemini-3.5-flash-low", { @@ -755,15 +758,27 @@ test("OpenAI -> Antigravity preserves multiple signature-less historical tool re ); const text = JSON.stringify(result.request.contents); - assert.ok(text.includes("Historical tool-call record only"), "expected signature-less calls as text"); - assert.ok(text.includes("Tool name: terminal"), "expected signature-less calls as text"); + assert.equal( + text.includes("Historical tool-call record only"), + false, + "signature-less calls must not be emitted as visible historical text" + ); + assert.equal( + text.includes("Tool arguments JSON"), + false, + "signature-less call arguments must not be emitted as visible text" + ); + assert.ok( + text.includes(''), + "expected signature-less responses as safe context" + ); assert.ok( text.includes("data/db.json: No such file"), - "expected first signature-less tool response as text" + "expected first signature-less tool response as context" ); assert.ok( text.includes("storage.sqlite"), - "expected second signature-less tool response as text" + "expected second signature-less tool response as context" ); assert.equal( result.request.contents.some((content) => content.parts.some((part) => part.functionResponse)), @@ -772,6 +787,52 @@ test("OpenAI -> Antigravity preserves multiple signature-less historical tool re ); }); +test("OpenAI -> Antigravity preserves signed Gemini tool calls in native form", async () => { + const { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature } = + await import("../../open-sse/services/geminiThoughtSignatureStore.ts"); + const ns = "conn-antigravity-signed"; + const toolId = "call_signed_history"; + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId), "SIG_AG_SIGNED_XYZ"); + + const result = openaiToAntigravityRequest( + "gemini-3.5-flash-low", + { + messages: [ + { role: "user", content: "Read status" }, + { + role: "assistant", + tool_calls: [ + { + id: toolId, + type: "function", + function: { name: "read_file", arguments: '{"path":"status.txt"}' }, + }, + ], + }, + { role: "tool", tool_call_id: toolId, content: "ready" }, + ], + }, + false, + { projectId: "proj-antigravity-gemini", _signatureNamespace: ns } as any + ); + + const text = JSON.stringify(result.request.contents); + assert.ok(text.includes("SIG_AG_SIGNED_XYZ"), "cached signature must be preserved"); + assert.equal( + text.includes("previous_tool_result_context"), + false, + "signed tool calls must stay native, not context text" + ); + assert.ok( + result.request.contents.some((content) => content.parts.some((part) => part.functionCall)), + "signed historical call must be emitted as native functionCall" + ); + assert.ok( + result.request.contents.some((content) => content.parts.some((part) => part.functionResponse)), + "signed historical response must be emitted as native functionResponse" + ); +}); + test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schema", () => { const result = openaiToAntigravityRequest( "claude-3-7-sonnet", From 5cb23b4e712ec2f1393df1c6266c238849a2a3b5 Mon Sep 17 00:00:00 2001 From: dhaern Date: Sat, 30 May 2026 01:41:24 +0000 Subject: [PATCH 05/82] fix(antigravity): escape signatureless history context --- .../translator/request/openai-to-gemini.ts | 7 +++- .../unit/translator-openai-to-gemini.test.ts | 40 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 5959713867..b7a5dd0666 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -217,9 +217,14 @@ function escapeHistoricalContextAttribute(value: string): string { .replaceAll(">", ">"); } +function escapeHistoricalContextContent(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + function buildHistoricalToolResultContext(name: string, response: unknown): string { const source = escapeHistoricalContextAttribute(name || "unknown"); - const result = typeof response === "string" ? response : stringifyHistoricalToolArguments(response); + const rawResult = typeof response === "string" ? response : stringifyHistoricalToolArguments(response); + const result = escapeHistoricalContextContent(rawResult); return [ ``, result, diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index db592b07f1..501f6d04ec 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -833,6 +833,46 @@ test("OpenAI -> Antigravity preserves signed Gemini tool calls in native form", ); }); +test("OpenAI -> Antigravity escapes signature-less tool response context content", () => { + const result = openaiToAntigravityRequest( + "gemini-3.5-flash-low", + { + messages: [ + { role: "user", content: "Inspect previous output" }, + { + role: "assistant", + tool_calls: [ + { + id: "call_breakout", + type: "function", + function: { name: 'reader">', arguments: "{}" }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_breakout", + content: "before after", + }, + ], + }, + false, + { projectId: "proj-antigravity-gemini" } as any + ); + + const text = JSON.stringify(result.request.contents); + assert.ok(text.includes("reader"><x>"), "source attribute must be escaped"); + assert.ok( + text.includes("before </previous_tool_result_context><evil> after"), + "context content must escape tag-like tool output" + ); + assert.equal( + text.includes("before after"), + false, + "raw context-closing content must not be emitted" + ); +}); + test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schema", () => { const result = openaiToAntigravityRequest( "claude-3-7-sonnet", From 54cec8898992aff00b69c98ead2485817ae5b2a9 Mon Sep 17 00:00:00 2001 From: guanbear Date: Sat, 30 May 2026 14:15:18 +0800 Subject: [PATCH 06/82] Improve self-service provider quota visibility --- docs/bdd/self-service-api-key-usage.feature | 47 ++-- .../self-service-api-key-usage/proposal.md | 4 +- .../specs/api-key-self-service-usage/spec.md | 40 +++- .../self-service-api-key-usage/tasks.md | 7 +- ...05-29-self-service-api-key-usage-design.md | 111 +++++++-- .../api-manager/ApiManagerPageClient.tsx | 37 ++- src/lib/usage/apiKeySelfService.ts | 201 +++++++++++++--- tests/e2e/combo-unification.spec.ts | 3 - tests/unit/api-key-self-service.test.ts | 217 +++++++++++++++++- tests/unit/api-manager-page-static.test.ts | 40 ++++ tests/unit/chatcore-translation-paths.test.ts | 3 +- 11 files changed, 603 insertions(+), 107 deletions(-) create mode 100644 tests/unit/api-manager-page-static.test.ts diff --git a/docs/bdd/self-service-api-key-usage.feature b/docs/bdd/self-service-api-key-usage.feature index 2b976c79fa..baad4c4faf 100644 --- a/docs/bdd/self-service-api-key-usage.feature +++ b/docs/bdd/self-service-api-key-usage.feature @@ -61,36 +61,51 @@ Feature: Self-service API key usage and account quota visibility Then the response status should be 200 And the response should not include shared account quota details - Scenario: Shared Codex account quota is visible with explicit permission + Scenario: Shared provider account quotas are visible with explicit permission + Given an API key named "team-a" has the scope "self:usage" + And "team-a" has the scope "self:account-quota" + And "team-a" is restricted to a Codex connection and a Claude connection + And Codex reports a session quota with 1 percent used + And Claude reports a daily quota with 35 percent used + When "team-a" calls GET "/api/v1/me/status" with its Bearer token + Then the response status should be 200 + And the response accountQuotas should contain 2 entries + And the first response accountQuotas entry provider should be "codex" + And the first response accountQuotas entry quotas.session.remainingPercentage should be 99 + And the second response accountQuotas entry provider should be "claude" + And the second response accountQuotas entry quotas.daily.remainingPercentage should be 65 + + Scenario: A single allowed provider also keeps the compatibility accountQuota field Given an API key named "team-a" has the scope "self:usage" And "team-a" has the scope "self:account-quota" And "team-a" is restricted to exactly one Codex connection - And Codex reports a session quota with 1 percent used And Codex reports a weekly quota with 97 percent used When "team-a" calls GET "/api/v1/me/status" with its Bearer token Then the response status should be 200 + And the response accountQuotas should contain 1 entry And the response accountQuota.provider should be "codex" - And the response accountQuota.shared should be true - And the response accountQuota.quotas.session.remainingPercentage should be 99 And the response accountQuota.quotas.weekly.remainingPercentage should be 3 - Scenario: Account quota is not guessed for multi-connection keys - Given an API key named "team-a" has the scope "self:usage" - And "team-a" has the scope "self:account-quota" - And "team-a" is allowed to use two provider connections - When "team-a" calls GET "/api/v1/me/status" with its Bearer token - Then the response status should be 200 - And the response accountQuota.available should be false - And the response accountQuota.reason should be "ambiguous_connection" - - Scenario: Account quota is not guessed for unrestricted connection keys + Scenario: Unrestricted keys can see all active provider account quotas Given an API key named "team-a" has the scope "self:usage" And "team-a" has the scope "self:account-quota" And "team-a" has no explicit allowed connection restrictions + And OmniRoute has active Codex and Cursor provider connections with quota data When "team-a" calls GET "/api/v1/me/status" with its Bearer token Then the response status should be 200 - And the response accountQuota.available should be false - And the response accountQuota.reason should be "ambiguous_connection" + And the response accountQuotas should contain the Codex account quota + And the response accountQuotas should contain the Cursor account quota + + Scenario: Provider connection lookup failures do not hide own usage + Given an API key named "team-a" has the scope "self:usage" + And "team-a" has the scope "self:account-quota" + And "team-a" is restricted to a Codex connection and another provider connection + And OmniRoute cannot resolve the other provider connection metadata + When "team-a" calls GET "/api/v1/me/status" with its Bearer token + Then the response status should be 200 + And the response should still include own cost and token usage + And the unresolved response accountQuotas entry should have available false + And the unresolved response accountQuotas entry reason should be "connection_lookup_failed" Scenario: Existing budget endpoint stays management-only Given an API key named "team-a" has the scope "self:usage" diff --git a/docs/openspec/changes/self-service-api-key-usage/proposal.md b/docs/openspec/changes/self-service-api-key-usage/proposal.md index 1d8708d0ad..1c9a36e164 100644 --- a/docs/openspec/changes/self-service-api-key-usage/proposal.md +++ b/docs/openspec/changes/self-service-api-key-usage/proposal.md @@ -21,7 +21,7 @@ In scope: - New `GET /api/v1/me/status` endpoint authenticated by normal Bearer API key. - New self-service API key scopes: `self:usage` and `self:account-quota`. - Per-key cost and token aggregation for the calling key. -- Optional normalized provider account quota for unambiguous single-connection keys. +- Optional normalized provider account quotas for all provider-limit connections the key may use. - API Manager create/edit controls for visibility scopes. - Reuse the existing budget configuration surface for USD limits. - i18n message keys for all new dashboard text. @@ -46,7 +46,7 @@ The new scopes must not grant management access. Only `manage` and `admin` remai - Scope editing in the current dashboard can collapse scopes to only management access; implementation must preserve unrelated scopes. - Shared account quota can reveal account exhaustion; it must remain disabled by default. -- Multi-connection and unrestricted-connection keys are ambiguous; first implementation should decline account quota rather than guessing. +- Unrestricted keys can use all active provider connections, so account quota visibility enumerates all active provider-limit connections when explicitly permitted. - Backfill must be idempotent so upgrades do not repeatedly rewrite API keys or re-enable a permission an operator later disabled. - New UI text can regress non-English dashboards if translation keys are not added consistently. - The current scope validation cap is 16 entries; adding self-service scopes may require raising that cap. diff --git a/docs/openspec/changes/self-service-api-key-usage/specs/api-key-self-service-usage/spec.md b/docs/openspec/changes/self-service-api-key-usage/specs/api-key-self-service-usage/spec.md index 2fe7620689..f704c08130 100644 --- a/docs/openspec/changes/self-service-api-key-usage/specs/api-key-self-service-usage/spec.md +++ b/docs/openspec/changes/self-service-api-key-usage/specs/api-key-self-service-usage/spec.md @@ -119,30 +119,48 @@ The self-service endpoint SHALL include shared account quota only when the authe - WHEN it calls the self-service endpoint - THEN the response SHALL NOT include shared account quota details -#### Scenario: Codex quota shown with explicit permission +#### Scenario: Allowed provider quotas shown with explicit permission - GIVEN a valid API key has `self:account-quota` -- AND it is restricted to exactly one Codex connection -- AND Codex quota data is available +- AND it is allowed to use Codex and Claude provider-limit connections +- AND quota data is available for both connections - WHEN it calls the self-service endpoint -- THEN the response SHALL include normalized `session` and `weekly` quota windows +- THEN the response SHALL include an `accountQuotas` entry for each allowed provider-limit connection - AND each window SHALL include used percentage, remaining percentage, and reset timestamp when known -#### Scenario: Multiple connections are ambiguous +#### Scenario: Single connection compatibility field - GIVEN a valid API key has `self:account-quota` -- AND it is allowed to use more than one connection +- AND it is allowed to use exactly one provider-limit connection - WHEN it calls the self-service endpoint -- THEN `accountQuota.available` SHALL be `false` -- AND `accountQuota.reason` SHALL be `ambiguous_connection` +- THEN the response SHALL include exactly one `accountQuotas` entry +- AND the response SHALL also include `accountQuota` with the same entry for backwards compatibility -#### Scenario: Unrestricted connections are ambiguous +#### Scenario: Unrestricted connections include active provider-limit connections - GIVEN a valid API key has `self:account-quota` - AND its `allowedConnections` list is empty, meaning all connections are allowed - WHEN it calls the self-service endpoint -- THEN `accountQuota.available` SHALL be `false` -- AND `accountQuota.reason` SHALL be `ambiguous_connection` +- THEN the response SHALL include `accountQuotas` entries for active provider-limit connections + +#### Scenario: Per-connection quota failure is isolated + +- GIVEN a valid API key has `self:account-quota` +- AND it is allowed to use two provider-limit connections +- AND one provider quota fetch fails +- WHEN it calls the self-service endpoint +- THEN the successful provider SHALL remain in `accountQuotas` +- AND the failed provider SHALL be represented with `available: false` and `reason: "fetch_failed"` + +#### Scenario: Provider connection lookup failure is isolated + +- GIVEN a valid API key has `self:account-quota` +- AND it is explicitly allowed to use two provider-limit connections +- AND one provider connection lookup fails before quota fetching +- WHEN it calls the self-service endpoint +- THEN the successful provider SHALL remain in `accountQuotas` +- AND the unresolved connection SHALL be represented with `available: false` and `reason: "connection_lookup_failed"` +- AND the response SHALL still include the key's own cost and token usage ### Requirement: Dashboard configuration diff --git a/docs/openspec/changes/self-service-api-key-usage/tasks.md b/docs/openspec/changes/self-service-api-key-usage/tasks.md index b7cb88ed5e..21f0848897 100644 --- a/docs/openspec/changes/self-service-api-key-usage/tasks.md +++ b/docs/openspec/changes/self-service-api-key-usage/tasks.md @@ -18,9 +18,10 @@ ## 3. Account Quota - [ ] Resolve account quota only when the key has `self:account-quota`. -- [ ] Use exactly one explicit allowed connection; treat unrestricted or multiple connections as ambiguous. -- [ ] Normalize Codex quota windows to `session` and `weekly`. -- [ ] Add tests for no scope, one connection, multiple connections, unsupported provider, and fetch failure. +- [ ] Enumerate all explicit allowed connections, or all active connections when `allowedConnections` is empty. +- [ ] Normalize quota windows for every provider-limit connection that returns quota data. +- [ ] Preserve the legacy `accountQuota` field when exactly one quota entry is returned. +- [ ] Add tests for no scope, one connection, multiple connections, unrestricted connections, unsupported provider, and fetch failure. ## 4. API Endpoint diff --git a/docs/specs/2026-05-29-self-service-api-key-usage-design.md b/docs/specs/2026-05-29-self-service-api-key-usage-design.md index 5a3966f271..64522ed1b8 100644 --- a/docs/specs/2026-05-29-self-service-api-key-usage-design.md +++ b/docs/specs/2026-05-29-self-service-api-key-usage-design.md @@ -4,7 +4,7 @@ Operators often share one upstream coding account, such as Codex, across multiple OmniRoute API keys. OmniRoute already records per-key usage and supports per-key USD budgets, but a normal client API key cannot query its own spend or token totals. The existing usage APIs are management endpoints, so exposing them to each API key would disclose other keys, account metadata, and operational settings. -Operators also need a way to decide whether a key may see the shared upstream account quota. For Codex this includes the short session window and weekly window fetched from ChatGPT usage APIs. That quota is account-level state, not key-level state, so it should not be visible by default. +Operators also need a way to decide whether a key may see shared upstream account quotas. For Codex this includes the short session window and weekly window fetched from ChatGPT usage APIs, and other subscription providers can expose their own normalized provider-limit windows. That quota is account-level state, not key-level state, so it should not be visible by default. The goal is to add a small self-service status API and matching dashboard controls so a delegated API key can see: @@ -29,7 +29,7 @@ Relevant current implementation: - `/api/v1/*` routes are public from the route classifier perspective, but individual handlers still validate Bearer API keys. - Per-key USD budgets already exist through `domain_budgets`, `domain_cost_history`, `getCostSummary(apiKeyId)`, and `checkBudget(apiKeyId)`. - Token usage is already recorded per key in `usage_history.api_key_id` with input, output, cache read, cache creation, and reasoning token columns. -- Provider quota data is fetched through `src/lib/usage/providerLimits.ts` and Codex quota support in `open-sse/services/codexQuotaFetcher.ts` / `open-sse/services/usage.ts`. +- Provider quota data is fetched through `src/lib/usage/providerLimits.ts` and provider usage support in `open-sse/services/usage.ts`. - The API Manager UI currently has a management-access toggle on create/edit and sends `scopes: ["manage"]` or `[]`; the edit modal must be changed before adding more scope types so it does not discard unrelated scopes. ## Goals @@ -101,16 +101,45 @@ The response contains only the caller's own API key identity, budget usage, toke "totalTokens": 1067000 } }, + "accountQuotas": [ + { + "provider": "codex", + "connectionId": "conn_123", + "shared": true, + "plan": "ChatGPT Plus", + "quotas": { + "session": { + "remainingPercentage": 99, + "usedPercentage": 1, + "resetAt": "2026-05-29T18:11:44.000Z" + }, + "weekly": { + "remainingPercentage": 3, + "usedPercentage": 97, + "resetAt": "2026-05-31T01:23:38.000Z" + } + } + }, + { + "provider": "claude", + "connectionId": "conn_456", + "shared": true, + "plan": "Claude Max", + "quotas": { + "daily": { + "remainingPercentage": 65, + "usedPercentage": 35, + "resetAt": "2026-05-30T00:00:00.000Z" + } + } + } + ], "accountQuota": { "provider": "codex", "connectionId": "conn_123", "shared": true, + "plan": "ChatGPT Plus", "quotas": { - "session": { - "remainingPercentage": 99, - "usedPercentage": 1, - "resetAt": "2026-05-29T18:11:44.000Z" - }, "weekly": { "remainingPercentage": 3, "usedPercentage": 97, @@ -121,25 +150,53 @@ The response contains only the caller's own API key identity, budget usage, toke } ``` -`accountQuota` is omitted unless the key has the account quota scope. If the scope is present but the connection cannot be resolved safely, return: +`accountQuotas` is omitted unless the key has the account quota scope. `accountQuota` is retained as a compatibility alias only when exactly one account quota entry is returned. If a specific allowed connection cannot fetch quota data, include a per-connection unavailable entry: ```json { + "accountQuotas": [ + { + "provider": "cursor", + "connectionId": "conn_789", + "shared": true, + "available": false, + "reason": "fetch_failed" + } + ], "accountQuota": { + "provider": "cursor", + "connectionId": "conn_789", + "shared": true, "available": false, - "reason": "ambiguous_connection" + "reason": "fetch_failed" } } ``` -Use stable reason strings: `not_supported`, `ambiguous_connection`, `no_allowed_connection`, `not_available`, and `fetch_failed`. +If explicit connection metadata lookup fails before the provider is known, return the unresolved connection as unavailable without failing the whole status response: + +```json +{ + "accountQuotas": [ + { + "provider": "unknown", + "connectionId": "conn_789", + "shared": true, + "available": false, + "reason": "connection_lookup_failed" + } + ] +} +``` + +Use stable reason strings: `not_supported`, `not_available`, `fetch_failed`, and `connection_lookup_failed`. ## Scopes Add self-service scopes that do not grant management access: - `self:usage`: allows a key to query its own spend, budget percent, and token totals. -- `self:account-quota`: allows a key to see shared upstream account quota for its resolved connection. +- `self:account-quota`: allows a key to see shared upstream account quotas for provider-limit connections it may use. `self:usage` should be enabled by default for newly created ordinary API keys. The UI should show it checked by default and persist the scope when the control is enabled. For backwards compatibility, the implementation should backfill `self:usage` onto existing ordinary keys during migration or first startup after upgrade. After that compatibility step, absence of `self:usage` means own-usage visibility is disabled and the self-service endpoint returns `403`. @@ -181,19 +238,21 @@ WHERE api_key_id = ? Account quota is shared provider state. The self-service endpoint may include it only when: - The API key has `self:account-quota`. -- A single provider connection can be resolved without ambiguity. -- The provider supports quota fetching. +- Provider connections can be resolved from the key's allowed connection policy. +- The providers support quota fetching through the provider limits path. Connection resolution must follow the source semantics for `allowedConnections`: an empty array means unrestricted access to all connections, not "no connections". -- If exactly one explicit allowed connection exists and it resolves to a quota-supported provider, use that connection. -- If `allowedConnections` is empty, treat the connection scope as ambiguous and return `available: false` with `ambiguous_connection`. This avoids exposing shared quota for a broad/unrestricted key. -- If explicit allowed connection ids are present but none resolve, return `available: false` with `no_allowed_connection`. -- If multiple explicit allowed connections exist, return `available: false` with `ambiguous_connection`. +- If explicit allowed connection ids are present, fetch quota data for those active provider-limit connections. +- If `allowedConnections` is empty, fetch quota data for all active provider-limit connections because the key may use all of them. +- If an allowed connection is inactive, missing, or unsupported, skip it or return a per-connection `not_supported` entry when the connection identity is known. +- If explicit connection lookup fails, keep the rest of the response and return that connection as `available: false` with `connection_lookup_failed`. +- If unrestricted connection listing fails, keep the rest of the response and return an empty `accountQuotas` array because no allowed connection identities can be resolved. +- If a provider quota fetch fails, keep the rest of the response and return that connection as `available: false` with `fetch_failed`. -This conservative rule avoids accidentally exposing quota for an account the key may not actually use. A later change can add an explicitly authorized `?connectionId=` flow if there is demand for multi-connection keys. +This rule matches routing permissions: the endpoint exposes only account quotas for connections the key is allowed to use, and only when the operator explicitly grants `self:account-quota`. -For Codex, reuse the existing provider limits / Codex quota path. Normalize Codex windows to `session` and `weekly` and return used/remaining percentages plus reset timestamps. Do not return raw upstream payloads. +Reuse the existing provider limits path. Normalize every returned quota window to used/remaining percentages plus reset timestamps. Do not return raw upstream payloads. ## Dashboard UX @@ -218,7 +277,7 @@ Usage display: - In the key list or details panel, show USD used, active USD limit, and used percent when a budget exists. - Show token totals in a compact details view. -- Show shared account quota only for keys with `self:account-quota`, clearly labeled as shared account quota, not per-key quota. +- Show shared account quotas only for keys with `self:account-quota`, clearly labeled as shared account quota, not per-key quota. - When no USD budget is configured, show usage normally and render the limit, remaining amount, and percent as unset/not configured rather than `0%`. ## Internationalization @@ -287,7 +346,7 @@ Never include: - Upstream access tokens or refresh tokens. - Provider account email unless that email is already visible to this key through another client API. - Other keys' spend, token totals, names, or budgets. -- Raw ChatGPT/Codex usage payloads. +- Raw upstream usage payloads. Account quota should be treated as sensitive because it lets delegated users infer shared account exhaustion. The default remains off. @@ -297,7 +356,7 @@ Account quota should be treated as sensitive because it lets delegated users inf - Valid key without `self:usage`: `403`. - Budget missing: `200` with null limit and percent fields. - Usage aggregation failure: `500` with generic message; log server-side details. -- Quota fetch unsupported or unavailable: `200` with `accountQuota.available: false`. +- Quota fetch unsupported or unavailable: `200` with per-connection unavailable entries in `accountQuotas`. - Quota fetch auth failure: do not leak provider auth details; return `not_available` or `fetch_failed` and log details server-side. ## Testing @@ -310,9 +369,11 @@ Add focused tests: - A normal key with `self:usage` can query its own cost and token totals without `manage`. - The endpoint never accepts an `apiKeyId` override. - Key A cannot see Key B usage. -- A key without account quota scope does not receive `accountQuota`. -- A key with account quota scope and one allowed Codex connection receives normalized session and weekly quota. -- Unrestricted or multiple allowed connections return `ambiguous_connection`. +- A key without account quota scope does not receive `accountQuotas`. +- A key with account quota scope and one allowed Codex connection receives normalized session and weekly quota plus the compatibility `accountQuota` field. +- A key with account quota scope and multiple allowed provider-limit connections receives multiple `accountQuotas` entries. +- A key with account quota scope and unrestricted connection access receives all active provider-limit connection quotas. +- A failed provider quota fetch returns an unavailable entry without hiding successful provider quota entries. - Create UI defaults own usage on and shared quota off. - Edit UI preserves unrelated scopes. - UI renders the no-budget state as not configured, with usage and token totals still visible. diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index f9480e52c9..8bf72ba25c 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useCallback, memo } from "react"; +import { useState, useEffect, useMemo, useCallback, useId, useRef, memo } from "react"; import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useTranslations } from "next-intl"; @@ -130,6 +130,8 @@ type ProviderGroup = [provider: string, models: Model[]]; export default function ApiManagerPageClient() { const t = useTranslations("apiManager"); const tc = useTranslations("common"); + const newKeyNameInputId = useId(); + const createKeyFormRef = useRef(null); const [keys, setKeys] = useState([]); const [allModels, setAllModels] = useState([]); const [allCombos, setAllCombos] = useState([]); @@ -159,6 +161,17 @@ export default function ApiManagerPageClient() { const { copied, copy } = useCopyToClipboard(); + const scrollCreateKeyFormToTop = useCallback(() => { + const scrollContainer = createKeyFormRef.current?.parentElement; + if (scrollContainer instanceof HTMLElement) { + scrollContainer.scrollTop = 0; + } + + const input = document.getElementById(newKeyNameInputId); + input?.scrollIntoView({ block: "nearest", inline: "nearest" }); + input?.focus({ preventScroll: true }); + }, [newKeyNameInputId]); + useEffect(() => { fetchData(); fetchModels(); @@ -174,6 +187,16 @@ export default function ApiManagerPageClient() { writeActiveOnlyPreference(activeOnly); }, [activeOnly]); + useEffect(() => { + if (!showAddModal || !nameError) return; + + const timeout = window.setTimeout(() => { + scrollCreateKeyFormToTop(); + }, 0); + + return () => window.clearTimeout(timeout); + }, [showAddModal, nameError, scrollCreateKeyFormToTop]); + const fetchModels = async () => { try { const res = await fetch("/v1/models"); @@ -346,6 +369,7 @@ export default function ApiManagerPageClient() { // Validate raw input first, then sanitize const validation = validateKeyName(newKeyName, t); if (!validation.valid) { + scrollCreateKeyFormToTop(); setNameError(validation.error || t("invalidKeyName")); return; } @@ -1011,6 +1035,7 @@ export default function ApiManagerPageClient() { { setShowAddModal(false); setNewKeyName(""); @@ -1021,12 +1046,13 @@ export default function ApiManagerPageClient() { setCreateError(null); }} > -
+
{ setNewKeyName(e.target.value); @@ -1909,11 +1935,10 @@ const PermissionsModal = memo(function PermissionsModal({

{t("managementAccess")}

-

- Allow this API key to manage OmniRoute configuration. -

+

{t("managementAccessDesc")}

{t("ownUsageVisibilityDesc")}

+ {/* Stream Default Compatibility */} +
+
+

{t("streamDefaultMode")}

+

{t("streamDefaultModeDesc")}

+
+
+ + +
+
+ {/* Ban Toggle (SECURITY) */}
diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index 36ff779742..64a3727e59 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -78,6 +78,8 @@ export async function PATCH(request, { params }) { accessSchedule, rateLimits, scopes, + allowedEndpoints, + streamDefaultMode, } = validation.data; const payload: Parameters[1] = {}; @@ -95,6 +97,8 @@ export async function PATCH(request, { params }) { if (accessSchedule !== undefined) payload.accessSchedule = accessSchedule; if (rateLimits !== undefined) payload.rateLimits = rateLimits; if (scopes !== undefined) payload.scopes = scopes; + if (allowedEndpoints !== undefined) payload.allowedEndpoints = allowedEndpoints; + if (streamDefaultMode !== undefined) payload.streamDefaultMode = streamDefaultMode; const updated = await updateApiKeyPermissions(id, payload); if (!updated) { @@ -120,6 +124,8 @@ export async function PATCH(request, { params }) { ...(accessSchedule !== undefined && { accessSchedule }), ...(rateLimits !== undefined && { rateLimits }), ...(scopes !== undefined && { scopes }), + ...(allowedEndpoints !== undefined && { allowedEndpoints }), + ...(streamDefaultMode !== undefined && { streamDefaultMode }), }); } catch (error) { log.error("keys", "Error updating key permissions", error); diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts index 2a1747a4c1..0c69f50be3 100644 --- a/src/app/api/keys/route.ts +++ b/src/app/api/keys/route.ts @@ -83,6 +83,7 @@ export async function POST(request) { id: apiKey.id, machineId: apiKey.machineId, noLog: noLog === true, + streamDefaultMode: "legacy", }, { status: 201 } ); diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index bd3c426d43..9b971445d1 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1481,6 +1481,11 @@ "endpointsRestricted": "__MISSING__:Restricted to {count} endpoint{count, plural, one {} other {s}}.", "autoResolve": "Auto-Resolve", "autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.", + "streamDefaultMode": "Stream-Standardverhalten", + "streamDefaultModeDesc": "Steuert fehlende `stream`-Angaben für diesen Schlüssel. Im JSON-Modus werden nicht-streamende Antworten zurückgegeben, außer der Client fordert SSE explizit an.", + "streamDefaultLegacy": "Legacy", + "streamDefaultJson": "JSON-kompatibel", + "streamDefaultBadge": "JSON-Standardstream", "keyActive": "Key Active", "keyActiveDesc": "Enable or disable this API key. Disabled keys are immediately rejected with 403.", "accessSchedule": "Access Schedule", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 1b6dd03b2a..295bac46c4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1481,6 +1481,11 @@ "endpointsRestricted": "Restricted to {count} endpoint{count, plural, one {} other {s}}.", "autoResolve": "Auto-Resolve", "autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.", + "streamDefaultMode": "Stream Default Compatibility", + "streamDefaultModeDesc": "Controls omitted `stream` flags for this key. JSON mode returns non-streaming responses unless the client explicitly requests SSE.", + "streamDefaultLegacy": "Legacy", + "streamDefaultJson": "JSON Compatible", + "streamDefaultBadge": "JSON stream default", "keyActive": "Key Active", "keyActiveDesc": "Enable or disable this API key. Disabled keys are immediately rejected with 403.", "accessSchedule": "Access Schedule", diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index f41e30e9a0..c1c1bcc772 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -60,6 +60,7 @@ interface ApiKeyMetadata { isBanned: boolean; keyHash: string | null; allowedEndpoints: string[]; + streamDefaultMode: "legacy" | "json"; } interface ApiKeyRow extends JsonRecord { @@ -84,6 +85,8 @@ interface ApiKeyRow extends JsonRecord { accessSchedule?: unknown; rate_limits?: unknown; rateLimits?: unknown; + stream_default_mode?: unknown; + streamDefaultMode?: unknown; } interface StatementLike { @@ -121,6 +124,7 @@ interface ApiKeyView extends JsonRecord { isBanned?: boolean; expiresAt?: string | null; allowedEndpoints: string[]; + streamDefaultMode: "legacy" | "json"; } // LRU cache for API key validation (valid keys only) @@ -156,6 +160,7 @@ const API_KEY_COLUMN_FALLBACKS = [ { name: "is_banned", definition: "is_banned INTEGER NOT NULL DEFAULT 0" }, { name: "key_hash", definition: "key_hash TEXT" }, { name: "allowed_endpoints", definition: "allowed_endpoints TEXT" }, + { name: "stream_default_mode", definition: "stream_default_mode TEXT NOT NULL DEFAULT 'legacy'" }, ] as const; // Cache for model permission checks @@ -355,7 +360,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { "SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtGetKeyMetadata = db.prepare( - "SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints FROM api_keys WHERE key = ? OR key_hash = ?" + "SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtInsertKey = db.prepare( "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" @@ -401,6 +406,7 @@ export async function getApiKeys() { camelRow.isBanned = parseIsBanned(camelRow.isBanned); camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes); camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); + camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } @@ -425,6 +431,7 @@ export async function getApiKeyById(id: string) { camelRow.isBanned = parseIsBanned(camelRow.isBanned); camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes); camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); + camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } @@ -552,6 +559,10 @@ function parseIsBanned(value: unknown): boolean { return value === 1 || value === "1" || value === true; } +function parseStreamDefaultMode(value: unknown): "legacy" | "json" { + return value === "json" ? "json" : "legacy"; +} + async function hashKey(key: string): Promise { if (!key || typeof key !== "string") return ""; // CodeQL: This is intentionally SHA-256, NOT password hashing. API keys are @@ -661,6 +672,7 @@ export async function updateApiKeyPermissions( maxSessions?: number | null; scopes?: string[] | null; allowedEndpoints?: string[] | null; + streamDefaultMode?: "legacy" | "json" | null; } ) { const db = getDbInstance() as ApiKeysDbLike; @@ -687,6 +699,8 @@ export async function updateApiKeyPermissions( maxSessions: (update as { maxSessions?: number | null }).maxSessions, scopes: (update as { scopes?: string[] | null }).scopes, allowedEndpoints: (update as { allowedEndpoints?: string[] | null }).allowedEndpoints, + streamDefaultMode: (update as { streamDefaultMode?: "legacy" | "json" | null }) + .streamDefaultMode, }; if ( @@ -706,7 +720,8 @@ export async function updateApiKeyPermissions( normalized.expiresAt === undefined && (normalized as Record).maxSessions === undefined && (normalized as Record).scopes === undefined && - (normalized as Record).allowedEndpoints === undefined + (normalized as Record).allowedEndpoints === undefined && + (normalized as Record).streamDefaultMode === undefined ) { return false; } @@ -730,6 +745,7 @@ export async function updateApiKeyPermissions( maxSessions?: number; expiresAt?: string | null; scopes?: string; + streamDefaultMode?: "legacy" | "json"; } = { id }; if (normalized.name !== undefined) { @@ -817,13 +833,17 @@ export async function updateApiKeyPermissions( if (allowedEndpointsUpdate !== undefined) { updates.push("allowed_endpoints = @allowedEndpoints"); const nextEndpoints: string[] = Array.isArray(allowedEndpointsUpdate) - ? (allowedEndpointsUpdate as unknown[]).filter( - (s): s is string => typeof s === "string" - ) + ? (allowedEndpointsUpdate as unknown[]).filter((s): s is string => typeof s === "string") : []; (params as Record).allowedEndpoints = JSON.stringify(nextEndpoints); } + const streamDefaultModeUpdate = (normalized as Record).streamDefaultMode; + if (streamDefaultModeUpdate !== undefined) { + updates.push("stream_default_mode = @streamDefaultMode"); + params.streamDefaultMode = parseStreamDefaultMode(streamDefaultModeUpdate); + } + const scopesUpdate = (normalized as Record).scopes; const nextScopes: string[] = Array.isArray(scopesUpdate) ? (scopesUpdate as unknown[]).filter((s): s is string => typeof s === "string") @@ -1171,6 +1191,7 @@ export async function getApiKeyMetadata( keyHash: null, scopes: ["manage"], allowedEndpoints: [], + streamDefaultMode: "legacy", }; } @@ -1228,6 +1249,9 @@ export async function getApiKeyMetadata( allowedEndpoints: parseStringList( (record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints ), + streamDefaultMode: parseStreamDefaultMode( + (record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode + ), }; if (!metadata.id) { diff --git a/src/lib/db/migrations/077_api_key_stream_default_mode.sql b/src/lib/db/migrations/077_api_key_stream_default_mode.sql new file mode 100644 index 0000000000..0c8ffff0e4 --- /dev/null +++ b/src/lib/db/migrations/077_api_key_stream_default_mode.sql @@ -0,0 +1,3 @@ +-- 077: Per-API-key default for omitted chat completion stream flags. + +ALTER TABLE api_keys ADD COLUMN stream_default_mode TEXT NOT NULL DEFAULT 'legacy'; diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 2965c1d8a4..bd0f87f2a5 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1856,6 +1856,7 @@ export const updateKeyPermissionsSchema = z .optional(), scopes: z.array(z.string().trim().min(1).max(64)).max(32).optional(), allowedEndpoints: z.array(z.string().trim().min(1).max(64)).max(20).optional(), + streamDefaultMode: z.enum(["legacy", "json"]).optional(), }) .superRefine((value, ctx) => { if ( @@ -1873,7 +1874,8 @@ export const updateKeyPermissionsSchema = z value.accessSchedule === undefined && value.rateLimits === undefined && value.scopes === undefined && - value.allowedEndpoints === undefined + value.allowedEndpoints === undefined && + value.streamDefaultMode === undefined ) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/tests/unit/chatcore-sanitization.test.ts b/tests/unit/chatcore-sanitization.test.ts index b62211f789..03c17f33fd 100644 --- a/tests/unit/chatcore-sanitization.test.ts +++ b/tests/unit/chatcore-sanitization.test.ts @@ -103,10 +103,14 @@ async function invokeChatCore({ const originalFetch = globalThis.fetch; const calls = []; const nextcloudJsonOnlyClient = /nextcloud\s+openai\/localai\s+integration/i.test(userAgent); + const jsonStreamDefault = apiKeyInfo?.streamDefaultMode === "json"; const resolvedStream = body?.stream === true || (body?.stream === undefined && String(accept).toLowerCase().includes("text/event-stream")) || - (body?.stream === undefined && !nextcloudJsonOnlyClient && !String(accept).includes("json")); + (body?.stream === undefined && + !nextcloudJsonOnlyClient && + !jsonStreamDefault && + !String(accept).includes("json")); globalThis.fetch = async (url, init = {}) => { const parsedBody = init.body ? JSON.parse(String(init.body)) : null; @@ -527,6 +531,28 @@ test("chatCore treats Nextcloud OpenAI integration requests as non-streaming by assert.equal(nextcloudExplicitStream.call.headers.Accept, "text/event-stream"); }); +test("chatCore honors API key JSON stream-default compatibility mode", async () => { + const jsonCompatibleDefault = await invokeChatCore({ + accept: "*/*", + userAgent: "generic-openai-client", + apiKeyInfo: { id: "json-stream-default-key", streamDefaultMode: "json" }, + body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "hello" }] }, + }); + const explicitSse = await invokeChatCore({ + accept: "text/event-stream", + userAgent: "generic-openai-client", + apiKeyInfo: { id: "json-stream-default-key", streamDefaultMode: "json" }, + body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "hello" }] }, + }); + + assert.equal(jsonCompatibleDefault.call.headers.Accept, "application/json"); + assert.equal( + jsonCompatibleDefault.result.response.headers.get("content-type"), + "application/json" + ); + assert.equal(explicitSse.call.headers.Accept, "text/event-stream"); +}); + test("chatCore injects memories when enabled and memories are found", async () => { await settingsDb.updateSettings({ memoryEnabled: true, diff --git a/tests/unit/db-apikeys-crud.test.ts b/tests/unit/db-apikeys-crud.test.ts index b29dacf2d9..57547e7bdb 100644 --- a/tests/unit/db-apikeys-crud.test.ts +++ b/tests/unit/db-apikeys-crud.test.ts @@ -62,6 +62,7 @@ test("createApiKey requires machineId and returns a persisted key with defaults" assert.equal(byId.autoResolve, false); assert.equal(byId.isActive, true); assert.equal(byId.maxSessions, 0); + assert.equal(byId.streamDefaultMode, "legacy"); }); test("updateApiKeyPermissions persists settings, schedule and rate limits", async () => { @@ -87,6 +88,7 @@ test("updateApiKeyPermissions persists settings, schedule and rate limits", asyn maxRequestsPerMinute: 15, throttleDelayMs: 250, maxSessions: -3, + streamDefaultMode: "json", }); const row = await apiKeysDb.getApiKeyById(created.id); const metadata = await apiKeysDb.getApiKeyMetadata(created.key); @@ -104,6 +106,8 @@ test("updateApiKeyPermissions persists settings, schedule and rate limits", asyn assert.equal(metadata.maxRequestsPerMinute, 15); assert.equal(metadata.throttleDelayMs, 250); assert.equal(metadata.maxSessions, 0); + assert.equal(row.streamDefaultMode, "json"); + assert.equal(metadata.streamDefaultMode, "json"); }); test("validateApiKey and deleteApiKey stay consistent after cache invalidation", async () => { diff --git a/tests/unit/t07-no-log-key-config.test.ts b/tests/unit/t07-no-log-key-config.test.ts index 9eeaf922ae..9ce5ded52d 100644 --- a/tests/unit/t07-no-log-key-config.test.ts +++ b/tests/unit/t07-no-log-key-config.test.ts @@ -64,6 +64,11 @@ test("updateKeyPermissionsSchema accepts noLog-only updates and rejects empty pa }); assert.equal(maxSessionsOnly.success, true); + const streamDefaultOnly = schemas.validateBody(schemas.updateKeyPermissionsSchema, { + streamDefaultMode: "json", + }); + assert.equal(streamDefaultOnly.success, true); + const emptyPayload = schemas.validateBody(schemas.updateKeyPermissionsSchema, {}); assert.equal(emptyPayload.success, false); }); diff --git a/tests/unit/t26-ai-sdk-accept-header-compat.test.ts b/tests/unit/t26-ai-sdk-accept-header-compat.test.ts index 983379c6f9..0fcc59bb74 100644 --- a/tests/unit/t26-ai-sdk-accept-header-compat.test.ts +++ b/tests/unit/t26-ai-sdk-accept-header-compat.test.ts @@ -93,6 +93,17 @@ test("T26: Nextcloud OpenAI integration defaults to non-streaming JSON", () => { assert.equal(resolveStreamFlag(true, "application/json", "openai", ua), true); }); +test("T26: per-key JSON stream default keeps omitted stream non-streaming", () => { + const options = { streamDefaultMode: "json", userAgent: "generic-openai-client" }; + + assert.equal(resolveStreamFlag(undefined, undefined, "openai", options), false); + assert.equal(resolveStreamFlag(undefined, "*/*", "openai", options), false); + assert.equal(resolveStreamFlag(undefined, "application/json", "openai", options), false); + assert.equal(resolveStreamFlag(undefined, "text/event-stream", "openai", options), true); + assert.equal(resolveStreamFlag(true, "application/json", "openai", options), true); + assert.equal(resolveStreamFlag(false, "text/event-stream", "openai", options), false); +}); + test("T26: explicit non-stream aliases are detected", () => { assert.equal(hasExplicitNoStreamParam({ non_stream: true }), true); assert.equal(hasExplicitNoStreamParam({ disable_stream: true }), true); From 664a606bfb682913e599be38b32a33576f197be5 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Sat, 30 May 2026 18:54:26 +0200 Subject: [PATCH 27/82] fix(dashboard/api-manager): scroll to key name error in create modal --- .../dashboard/api-manager/ApiManagerPageClient.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index ea83c4890d..e58bffc5d6 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useCallback, memo } from "react"; +import { useState, useEffect, useMemo, useCallback, memo, useRef } from "react"; import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useTranslations } from "next-intl"; @@ -154,6 +154,7 @@ export default function ApiManagerPageClient() { const [usageStats, setUsageStats] = useState>({}); const [sessionCounts, setSessionCounts] = useState>({}); const [allowKeyReveal, setAllowKeyReveal] = useState(false); + const createKeyNameFieldRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); const [activeOnly, setActiveOnly] = useState(false); @@ -169,6 +170,13 @@ export default function ApiManagerPageClient() { fetchConnections(); }, []); + useEffect(() => { + if (!showAddModal || !nameError) return; + requestAnimationFrame(() => { + createKeyNameFieldRef.current?.scrollIntoView({ block: "center", behavior: "instant" }); + }); + }, [nameError, showAddModal]); + useEffect(() => { setActiveOnly(readActiveOnlyPreference()); }, []); @@ -1034,7 +1042,7 @@ export default function ApiManagerPageClient() { }} >
-
+
From b4c0ce6519b83480cc92bae941334f5948cf26a7 Mon Sep 17 00:00:00 2001 From: soyelmismo Date: Sat, 30 May 2026 13:03:02 -0500 Subject: [PATCH 28/82] fix: address Gemini Code Review feedback on PR #2951 - Add !has(key) guard before eviction to avoid evicting entries that are about to be updated (combo.ts, apiKeyRotator.ts, codexQuotaFetcher.ts) - Use optional chaining for provider?.toUpperCase() null safety - Replace Object.values() with for-in in estimateSizeFast hot path --- open-sse/handlers/chatCore.ts | 15 ++++++++------- open-sse/services/apiKeyRotator.ts | 7 ++++--- open-sse/services/codexQuotaFetcher.ts | 6 +++--- open-sse/services/combo.ts | 14 +++++++------- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 78e4f11817..b1eabdb92e 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -293,8 +293,9 @@ function estimateSizeFast(value: unknown): number { if (Array.isArray(v)) { for (let i = 0; i < v.length; i++) stack.push(v[i]); } else { - const vals = Object.values(v); - for (let i = 0; i < vals.length; i++) stack.push(vals[i]); + for (const key in v) { + if (Object.prototype.hasOwnProperty.call(v, key)) stack.push((v as Record)[key]); + } } } } @@ -3997,7 +3998,7 @@ export async function handleChatCore({ (translatedBody.conversationState?.history?.length ?? 0) + (translatedBody.conversationState?.currentMessage ? 1 : 0) || 0; - log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`); + log?.debug?.("REQUEST", `${provider?.toUpperCase()} | ${model} | ${msgCount} msgs`); // ── Tier 2: Authoritative per-model/provider token-limit check (provider now resolved) ── if (apiKeyInfo?.id) { @@ -4233,7 +4234,7 @@ export async function handleChatCore({ }; if (newCredentials?.accessToken || newCredentials?.copilotToken) { - log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`); + log?.info?.("TOKEN", `${provider?.toUpperCase()} | refreshed`); // Fall back to post-mutex mutation only for executors that don't route // through getAccessToken (and therefore never fire onPersist). For @@ -4287,11 +4288,11 @@ export async function handleChatCore({ // than the original 401 alone. Surface at error level with sanitization. log?.error?.( "TOKEN", - `${provider.toUpperCase()} | retry after refresh failed: ${sanitizeErrorMessage(retryErr)}` + `${provider?.toUpperCase()} | retry after refresh failed: ${sanitizeErrorMessage(retryErr)}` ); } } else { - log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); + log?.warn?.("TOKEN", `${provider?.toUpperCase()} | refresh failed`); if (isUnrecoverableRefreshError(newCredentials) && onCredentialsRefreshed) { await onCredentialsRefreshed({ testStatus: "expired", isActive: false }); } @@ -4942,7 +4943,7 @@ export async function handleChatCore({ const cacheUsageLogMeta = buildCacheUsageLogMeta(usage); if (usage && typeof usage === "object") { if (traceEnabled) { - const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | ${formatUsageLog(usage)}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`; + const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider?.toUpperCase()} | ${formatUsageLog(usage)}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`; console.log(`${COLORS.green}${msg}${COLORS.reset}`); } diff --git a/open-sse/services/apiKeyRotator.ts b/open-sse/services/apiKeyRotator.ts index abf2d7ccb5..58a1337f0d 100644 --- a/open-sse/services/apiKeyRotator.ts +++ b/open-sse/services/apiKeyRotator.ts @@ -31,7 +31,7 @@ const MAX_CONNECTION_EXTRA_KEYS = 500; */ export function trackConnectionExtraKeys(connectionId: string, extraKeys: string[]): void { const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0); - if (_connectionExtraKeys.size >= MAX_CONNECTION_EXTRA_KEYS) { + if (!_connectionExtraKeys.has(connectionId) && _connectionExtraKeys.size >= MAX_CONNECTION_EXTRA_KEYS) { const oldest = _connectionExtraKeys.keys().next().value; if (oldest !== undefined) _connectionExtraKeys.delete(oldest); } @@ -276,11 +276,12 @@ export function syncHealthFromDB(connectionId: string, health?: Record= MAX_KEY_HEALTH_ENTRIES) { + const scopedKey = `${connectionId}:${keyId}`; + if (!_keyHealth.has(scopedKey) && _keyHealth.size >= MAX_KEY_HEALTH_ENTRIES) { const oldest = _keyHealth.keys().next().value; if (oldest !== undefined) _keyHealth.delete(oldest); } - _keyHealth.set(`${connectionId}:${keyId}`, keyHealth); + _keyHealth.set(scopedKey, keyHealth); } } diff --git a/open-sse/services/codexQuotaFetcher.ts b/open-sse/services/codexQuotaFetcher.ts index a572485971..ef40c48286 100644 --- a/open-sse/services/codexQuotaFetcher.ts +++ b/open-sse/services/codexQuotaFetcher.ts @@ -86,7 +86,7 @@ const MAX_QUOTA_CACHE_ENTRIES = 200; * @param meta - Access token and optional workspace ID */ export function registerCodexConnection(connectionId: string, meta: CodexConnectionMeta): void { - if (connectionRegistry.size >= MAX_CONNECTIONS) { + if (!connectionRegistry.has(connectionId) && connectionRegistry.size >= MAX_CONNECTIONS) { const oldestKey = connectionRegistry.keys().next().value; if (oldestKey !== undefined) { quotaCache.delete(oldestKey); @@ -124,7 +124,7 @@ function getCodexConnectionMeta( if (accessToken) { const meta = { accessToken, ...(workspaceId ? { workspaceId } : {}) }; - if (connectionRegistry.size >= MAX_CONNECTIONS) { + if (!connectionRegistry.has(connectionId) && connectionRegistry.size >= MAX_CONNECTIONS) { const oldestKey = connectionRegistry.keys().next().value; if (oldestKey !== undefined) { quotaCache.delete(oldestKey); @@ -212,7 +212,7 @@ export async function fetchCodexQuota( if (!quota) return null; // Store in cache - if (quotaCache.size >= MAX_QUOTA_CACHE_ENTRIES) { + if (!quotaCache.has(connectionId) && quotaCache.size >= MAX_QUOTA_CACHE_ENTRIES) { const oldestCacheKey = quotaCache.keys().next().value; if (oldestCacheKey !== undefined) quotaCache.delete(oldestCacheKey); } diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index a0ddb3646a..511908ebb7 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -1550,7 +1550,7 @@ async function getQuotaAwareConnectionsForTarget( const activeConnections = Array.isArray(connections) ? (connections as Array>) : []; - if (resetAwareConnectionCache.size >= MAX_RESET_AWARE_CACHE) { + if (!resetAwareConnectionCache.has(provider) && resetAwareConnectionCache.size >= MAX_RESET_AWARE_CACHE) { const oldest = resetAwareConnectionCache.keys().next().value; if (oldest !== undefined) resetAwareConnectionCache.delete(oldest); } @@ -1685,7 +1685,7 @@ async function fetchResetAwareQuotaWithCache({ const refreshPromise = fetcher(connectionId, connection) .then((quota) => { if (quota) { - if (resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) { + if (!resetAwareQuotaCache.has(cacheKey) && resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) { const oldest = resetAwareQuotaCache.keys().next().value; if (oldest !== undefined) resetAwareQuotaCache.delete(oldest); } @@ -1702,7 +1702,7 @@ async function fetchResetAwareQuotaWithCache({ .catch((error) => { const previous = resetAwareQuotaCache.get(cacheKey); if (previous) { - if (resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) { + if (!resetAwareQuotaCache.has(cacheKey) && resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) { const oldest = resetAwareQuotaCache.keys().next().value; if (oldest !== undefined) resetAwareQuotaCache.delete(oldest); } @@ -1718,7 +1718,7 @@ async function fetchResetAwareQuotaWithCache({ return null; }); - if (resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) { + if (!resetAwareQuotaCache.has(cacheKey) && resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) { const oldest = resetAwareQuotaCache.keys().next().value; if (oldest !== undefined) resetAwareQuotaCache.delete(oldest); } @@ -1845,7 +1845,7 @@ async function orderTargetsByResetAwareQuota( if (tiedTargets.length > 1) { const key = `reset-aware:${comboName}`; const counter = rrCounters.get(key) || 0; - if (rrCounters.size >= MAX_RR_COUNTERS) { + if (!rrCounters.has(key) && rrCounters.size >= MAX_RR_COUNTERS) { const oldest = rrCounters.keys().next().value; if (oldest !== undefined) rrCounters.delete(oldest); } @@ -2008,7 +2008,7 @@ async function orderTargetsByResetWindow( const key = `reset-window:${comboName}`; const counter = rrCounters.get(key) || 0; - if (rrCounters.size >= MAX_RR_COUNTERS) { + if (!rrCounters.has(key) && rrCounters.size >= MAX_RR_COUNTERS) { const oldest = rrCounters.keys().next().value; if (oldest !== undefined) rrCounters.delete(oldest); } @@ -3921,7 +3921,7 @@ async function handleRoundRobinCombo({ // Get and increment atomic counter const counter = rrCounters.get(combo.name) || 0; - if (rrCounters.size >= MAX_RR_COUNTERS) { + if (!rrCounters.has(combo.name) && rrCounters.size >= MAX_RR_COUNTERS) { const oldest = rrCounters.keys().next().value; if (oldest !== undefined) rrCounters.delete(oldest); } From 6cdf69e0770142382e119c8e79aeb2fe4a34a62f Mon Sep 17 00:00:00 2001 From: soyelmismo Date: Sat, 30 May 2026 13:34:57 -0500 Subject: [PATCH 29/82] fix: address Kilo Code review feedback on PR #2951 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - estimateSizeFast: add WeakSet cycle detection to prevent infinite loop on circular object references - trace(): wrap JSON.stringify(extra) in try-catch to handle BigInt, circular refs, or other non-serializable values gracefully - Registry API change (Comment 3): verified all callers already use new getter functions — no broken call sites --- open-sse/handlers/chatCore.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index b1eabdb92e..34c5aaf1b0 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -277,10 +277,11 @@ function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown { return result; } -/** Fast size estimator — walks object tree without JSON.stringify */ +/** Fast size estimator — walks object tree without JSON.stringify, with circular-ref protection */ function estimateSizeFast(value: unknown): number { let bytes = 0; const stack: unknown[] = [value]; + const seen = new WeakSet(); while (stack.length > 0) { const v = stack.pop(); if (v === null || v === undefined) continue; @@ -290,6 +291,8 @@ function estimateSizeFast(value: unknown): number { } else if (typeof v === "number") bytes += 8; else if (typeof v === "boolean") bytes += 4; else if (typeof v === "object") { + if (seen.has(v as object)) continue; + seen.add(v as object); if (Array.isArray(v)) { for (let i = 0; i < v.length; i++) stack.push(v[i]); } else { @@ -1544,7 +1547,14 @@ export async function handleChatCore({ const trace = (label: string, extra?: Record) => { if (!traceEnabled) return; const elapsed = Date.now() - startTime; - const suffix = extra ? ` ${JSON.stringify(extra)}` : ""; + let suffix = ""; + if (extra) { + try { + suffix = ` ${JSON.stringify(extra)}`; + } catch { + suffix = " [unserializable]"; + } + } log?.info?.("STAGE_TRACE", `${traceId} ${label} t=${elapsed}ms${suffix}`); }; let tokensCompressed: number | null = null; From a91f352fdec4507810a4fa3d2f81b1a0a454d1b4 Mon Sep 17 00:00:00 2001 From: soyelmismo Date: Sat, 30 May 2026 14:02:27 -0500 Subject: [PATCH 30/82] test: add unit tests for CPU leak fixes and registry changes 5 new test files covering all 13 changed production files: - estimateSizeFast.test.ts: 16 tests for fast size estimator (circular ref protection, early exit, nested structures, Map safety) - eviction-guards-apiKeyRotator.test.ts: 5 tests for Map eviction guards (!has() check prevents evicting existing keys on update) - eviction-guards-codexQuotaFetcher.test.ts: 4 tests for connectionRegistry and quotaCache eviction guards - rateLimitManager-idle-eviction.test.ts: 6 tests for idle limiter cleanup, limiterLastUsed tracking, and shutdown behavior - registry-direct-exports.test.ts: 20 tests verifying all 8 registries export plain objects (no Proxy traps, no lazy getters, mutable entries) Extract estimateSizeFast/isSmallEnoughForSemanticCache into standalone open-sse/utils/estimateSize.ts to make them testable without importing the entire chatCore.ts dependency tree. --- open-sse/handlers/chatCore.ts | 32 +---- open-sse/utils/estimateSize.ts | 35 ++++++ tests/unit/estimateSizeFast.test.ts | 111 ++++++++++++++++++ .../eviction-guards-apiKeyRotator.test.ts | 81 +++++++++++++ .../eviction-guards-codexQuotaFetcher.test.ts | 49 ++++++++ .../rateLimitManager-idle-eviction.test.ts | 86 ++++++++++++++ tests/unit/registry-direct-exports.test.ts | 63 ++++++++++ 7 files changed, 426 insertions(+), 31 deletions(-) create mode 100644 open-sse/utils/estimateSize.ts create mode 100644 tests/unit/estimateSizeFast.test.ts create mode 100644 tests/unit/eviction-guards-apiKeyRotator.test.ts create mode 100644 tests/unit/eviction-guards-codexQuotaFetcher.test.ts create mode 100644 tests/unit/rateLimitManager-idle-eviction.test.ts create mode 100644 tests/unit/registry-direct-exports.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 34c5aaf1b0..d86468c1c0 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -277,37 +277,7 @@ function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown { return result; } -/** Fast size estimator — walks object tree without JSON.stringify, with circular-ref protection */ -function estimateSizeFast(value: unknown): number { - let bytes = 0; - const stack: unknown[] = [value]; - const seen = new WeakSet(); - while (stack.length > 0) { - const v = stack.pop(); - if (v === null || v === undefined) continue; - if (typeof v === "string") { - bytes += v.length; - if (bytes > 262144) return bytes; - } else if (typeof v === "number") bytes += 8; - else if (typeof v === "boolean") bytes += 4; - else if (typeof v === "object") { - if (seen.has(v as object)) continue; - seen.add(v as object); - if (Array.isArray(v)) { - for (let i = 0; i < v.length; i++) stack.push(v[i]); - } else { - for (const key in v) { - if (Object.prototype.hasOwnProperty.call(v, key)) stack.push((v as Record)[key]); - } - } - } - } - return bytes; -} - -function isSmallEnoughForSemanticCache(value: unknown): boolean { - return estimateSizeFast(value) <= 256 * 1024; -} +import { estimateSizeFast, isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; function extractMemoryTextFromResponse( response: Record | null | undefined diff --git a/open-sse/utils/estimateSize.ts b/open-sse/utils/estimateSize.ts new file mode 100644 index 0000000000..ac320f9aad --- /dev/null +++ b/open-sse/utils/estimateSize.ts @@ -0,0 +1,35 @@ +/** + * Fast object-tree size estimator — walks without JSON.stringify. + * Safe for circular references (uses WeakSet). + * Early-exits at 256KB to avoid wasting CPU on huge payloads. + */ +export function estimateSizeFast(value: unknown): number { + let bytes = 0; + const stack: unknown[] = [value]; + const seen = new WeakSet(); + while (stack.length > 0) { + const v = stack.pop(); + if (v === null || v === undefined) continue; + if (typeof v === "string") { + bytes += v.length; + if (bytes > 262144) return bytes; + } else if (typeof v === "number") bytes += 8; + else if (typeof v === "boolean") bytes += 4; + else if (typeof v === "object") { + if (seen.has(v as object)) continue; + seen.add(v as object); + if (Array.isArray(v)) { + for (let i = 0; i < v.length; i++) stack.push(v[i]); + } else { + for (const key in v) { + if (Object.prototype.hasOwnProperty.call(v, key)) stack.push((v as Record)[key]); + } + } + } + } + return bytes; +} + +export function isSmallEnoughForSemanticCache(value: unknown): boolean { + return estimateSizeFast(value) <= 256 * 1024; +} diff --git a/tests/unit/estimateSizeFast.test.ts b/tests/unit/estimateSizeFast.test.ts new file mode 100644 index 0000000000..d84fc75086 --- /dev/null +++ b/tests/unit/estimateSizeFast.test.ts @@ -0,0 +1,111 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { estimateSizeFast, isSmallEnoughForSemanticCache } = await import( + "../../open-sse/utils/estimateSize.ts" +); + +test("estimateSizeFast returns 0 for null/undefined", () => { + assert.equal(estimateSizeFast(null), 0); + assert.equal(estimateSizeFast(undefined), 0); +}); + +test("estimateSizeFast counts string lengths", () => { + assert.equal(estimateSizeFast("hello"), 5); + assert.equal(estimateSizeFast(""), 0); +}); + +test("estimateSizeFast counts numbers as 8 bytes", () => { + assert.equal(estimateSizeFast(42), 8); + assert.equal(estimateSizeFast(0), 8); + assert.equal(estimateSizeFast(3.14), 8); +}); + +test("estimateSizeFast counts booleans as 4 bytes", () => { + assert.equal(estimateSizeFast(true), 4); + assert.equal(estimateSizeFast(false), 4); +}); + +test("estimateSizeFast walks arrays recursively", () => { + const arr = ["abc", "de", 42]; + assert.equal(estimateSizeFast(arr), 3 + 2 + 8); // 13 +}); + +test("estimateSizeFast walks objects recursively", () => { + const obj = { a: "hello", b: 42 }; + assert.equal(estimateSizeFast(obj), 5 + 8); // 13 +}); + +test("estimateSizeFast walks nested structures", () => { + const nested = { messages: [{ role: "user", content: "hi" }] }; + // role=4, content=2 + assert.equal(estimateSizeFast(nested), 4 + 2); // 6 +}); + +test("estimateSizeFast handles circular references without infinite loop", () => { + const circular: Record = { a: "test" }; + circular.self = circular; // Create circular ref + // Should not hang — WeakSet skips already-visited objects + const result = estimateSizeFast(circular); + assert.equal(result, 4); // Only "test" (4) counted; circular ref skipped +}); + +test("estimateSizeFast handles deeply nested circular refs", () => { + const a: Record = { val: "x" }; + const b: Record = { ref: a }; + a.back = b; + const result = estimateSizeFast({ root: a }); + assert.equal(result, 1); // "x" = 1 +}); + +test("estimateSizeFast early-exits at 262144 bytes (256KB)", () => { + // Create a string > 256KB + const bigStr = "x".repeat(300_000); + const result = estimateSizeFast(bigStr); + assert.ok(result >= 262144, `Should early-exit, got ${result}`); +}); + +test("estimateSizeFast handles mixed object/array nesting", () => { + const data = { + choices: [ + { + delta: { content: "Hello world" }, + index: 0, + }, + ], + }; + // content=11, index=8 (number), delta keys: content+delta=7, choices=8 + const result = estimateSizeFast(data); + assert.ok(result > 0); + assert.ok(result < 100); +}); + +test("estimateSizeFast does not count keys, only values", () => { + // Object with long keys but short values + const obj = { aLongKeyName: "x", anotherLongKeyName: "y" }; + assert.equal(estimateSizeFast(obj), 2); // "x" + "y" +}); + +test("isSmallEnoughForSemanticCache returns true for small payloads", () => { + assert.ok(isSmallEnoughForSemanticCache({ msg: "hi" })); +}); + +test("isSmallEnoughForSemanticCache returns false for huge payloads", () => { + const huge = { data: "x".repeat(300_000) }; + assert.ok(!isSmallEnoughForSemanticCache(huge)); +}); + +test("isSmallEnoughForSemanticCache handles circular refs gracefully", () => { + const circular: Record = {}; + circular.self = circular; + // Should not hang; estimateSizeFast has WeakSet protection + const result = isSmallEnoughForSemanticCache(circular); + assert.equal(result, true); // 0 bytes < 256KB +}); + +test("estimateSizeFast handles Map-like objects (no infinite loop on iterables)", () => { + const map = new Map([["key", "value"]]); + // Maps are objects but have no enumerable own properties via for-in + const result = estimateSizeFast(map); + assert.ok(typeof result === "number"); +}); diff --git a/tests/unit/eviction-guards-apiKeyRotator.test.ts b/tests/unit/eviction-guards-apiKeyRotator.test.ts new file mode 100644 index 0000000000..59e086db80 --- /dev/null +++ b/tests/unit/eviction-guards-apiKeyRotator.test.ts @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const rotator = await import("../../open-sse/services/apiKeyRotator.ts"); +const { + trackConnectionExtraKeys, + connectionHasExtraKeys, + getAllKeyHealth, + syncHealthFromDB, + resetKeyStatus, + removeConnectionIndex, +} = rotator; + +test("trackConnectionExtraKeys: inserting into a full map does not evict existing key being updated", () => { + // Fill the map to capacity by inserting many unique connection IDs + for (let i = 0; i < 510; i++) { + trackConnectionExtraKeys(`conn-${i}`, [`key-${i}`]); + } + + // Now update an existing key — this should NOT evict it + trackConnectionExtraKeys("conn-0", ["key-0", "key-new"]); + assert.ok(connectionHasExtraKeys("conn-0", ["key-0"]), "Existing key should not be evicted on update"); +}); + +test("trackConnectionExtraKeys: evicts oldest when inserting NEW key at capacity", () => { + // The map was filled above. Insert a brand new key — oldest should be evicted + const before = connectionHasExtraKeys("conn-1", ["key-1"]); + trackConnectionExtraKeys("conn-NEW-UNIQUE-XYZ", ["new-key"]); + // conn-1 may or may not be evicted depending on insertion order, but the map should not grow unbounded + assert.ok(typeof before === "boolean"); +}); + +test("syncHealthFromDB: does not evict when updating existing scopedKey", () => { + // Seed with some health entries + for (let i = 0; i < 5; i++) { + resetKeyStatus("test-conn", `key-${i}`); + } + + // Sync health for existing entries — should not evict them + const health = { + "key-0": { status: "active" as const, failures: 0, lastFailure: 0 }, + "key-1": { status: "active" as const, failures: 0, lastFailure: 0 }, + }; + syncHealthFromDB("test-conn", health); + + const all = getAllKeyHealth(); + assert.ok(all["test-conn:key-0"], "key-0 should still exist after sync"); + assert.ok(all["test-conn:key-1"], "key-1 should still exist after sync"); +}); + +test("syncHealthFromDB: evicts oldest when inserting NEW scopedKey at capacity", () => { + // Fill the map by syncing many unique entries + for (let i = 0; i < 505; i++) { + syncHealthFromDB(`bulk-conn-${i}`, { + [`bulk-key-${i}`]: { status: "active" as const, failures: 0, lastFailure: 0 }, + }); + } + // Insert one more brand new entry — should trigger eviction of oldest + syncHealthFromDB("bulk-conn-NEW", { + "bulk-key-NEW": { status: "active" as const, failures: 0, lastFailure: 0 }, + }); + const all = getAllKeyHealth(); + assert.ok(all["bulk-conn-NEW:bulk-key-NEW"], "New entry should exist"); +}); + +test("removeConnectionIndex cleans all 3 maps (keyIndexes, connectionExtraKeys, keyHealth)", () => { + // Seed data + trackConnectionExtraKeys("cleanup-conn", ["k1", "k2"]); + resetKeyStatus("cleanup-conn", "k1"); + + // Verify data exists via the in-memory cache (no extraKeys arg) + assert.ok(connectionHasExtraKeys("cleanup-conn")); + + // Remove via removeConnectionIndex (cleans all 3 maps) + removeConnectionIndex("cleanup-conn"); + + // Verify cleaned — in-memory cache should be empty + assert.ok(!connectionHasExtraKeys("cleanup-conn")); + const all = getAllKeyHealth(); + assert.ok(!all["cleanup-conn:k1"], "Health entry should be removed"); +}); diff --git a/tests/unit/eviction-guards-codexQuotaFetcher.test.ts b/tests/unit/eviction-guards-codexQuotaFetcher.test.ts new file mode 100644 index 0000000000..9f06aab365 --- /dev/null +++ b/tests/unit/eviction-guards-codexQuotaFetcher.test.ts @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const codex = await import("../../open-sse/services/codexQuotaFetcher.ts"); +const { registerCodexConnection, unregisterCodexConnection, getCodexConnectionMeta } = codex; + +// getCodexConnectionMeta is not exported — use registerCodexConnection + internal verify +// Let's check what is exported +const exportedKeys = Object.keys(codex).filter((k) => typeof codex[k] === "function"); +assert.ok(exportedKeys.length > 0, "codexQuotaFetcher should export functions"); + +test("registerCodexConnection: inserting into a full map does not evict the key being updated", () => { + // Fill registry to capacity + for (let i = 0; i < 210; i++) { + registerCodexConnection(`conn-${i}`, { accessToken: `tok-${i}` }); + } + + // Update an existing connection — should NOT trigger eviction + registerCodexConnection("conn-0", { accessToken: "tok-0-updated" }); + + // If conn-0 was evicted, unregistering it would be a no-op. + // The key point: this should not throw or corrupt state. + unregisterCodexConnection("conn-0"); +}); + +test("registerCodexConnection: evicts oldest when inserting NEW connection at capacity", () => { + // Re-fill to capacity + for (let i = 0; i < 210; i++) { + registerCodexConnection(`fill-${i}`, { accessToken: `tok-${i}` }); + } + + // Insert a brand new one — should evict the oldest + registerCodexConnection("fill-NEW-UNIQUE", { accessToken: "tok-new" }); + + // The new entry should be registered (no throw) + unregisterCodexConnection("fill-NEW-UNIQUE"); +}); + +test("registerCodexConnection does not throw on normal usage", () => { + registerCodexConnection("test-conn", { accessToken: "test-token" }); + unregisterCodexConnection("test-conn"); +}); + +test("unregisterCodexConnection is idempotent", () => { + registerCodexConnection("idempotent-test", { accessToken: "tok" }); + unregisterCodexConnection("idempotent-test"); + // Should not throw on double unregister + unregisterCodexConnection("idempotent-test"); +}); diff --git a/tests/unit/rateLimitManager-idle-eviction.test.ts b/tests/unit/rateLimitManager-idle-eviction.test.ts new file mode 100644 index 0000000000..e316002c3a --- /dev/null +++ b/tests/unit/rateLimitManager-idle-eviction.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const rlm = await import("../../open-sse/services/rateLimitManager.ts"); +const { + enableRateLimitProtection, + disableRateLimitProtection, + isRateLimitEnabled, + withRateLimit, + updateFromHeaders, + getAllRateLimitStatus, + startRateLimitWatchdog, + stopRateLimitWatchdog, + __resetRateLimitManagerForTests, + __getLimiterStateForTests, +} = rlm; + +// Clean slate before each test +test.beforeEach(async () => { + await __resetRateLimitManagerForTests(); +}); + +test("enableRateLimitProtection creates limiter", async () => { + enableRateLimitProtection("test-conn-1"); + assert.ok(isRateLimitEnabled("test-conn-1")); +}); + +test("disableRateLimitProtection cleans up limiters and limiterLastUsed", async () => { + // Create a limiter by using withRateLimit + enableRateLimitProtection("test-conn-2"); + await withRateLimit("openai", "test-conn-2", "gpt-4", async () => "ok"); + + // Verify limiter exists + const before = getAllRateLimitStatus(); + const hasKey = Object.keys(before).some((k) => k.includes("test-conn-2")); + + // Disable — should clean up all 3 Maps (limiters, lastDispatchAt, limiterLastUsed) + disableRateLimitProtection("test-conn-2"); + assert.ok(!isRateLimitEnabled("test-conn-2")); +}); + +test("limiterLastUsed is populated on each withRateLimit call", async () => { + enableRateLimitProtection("test-conn-3"); + const result = await withRateLimit("anthropic", "test-conn-3", "claude-3", async () => "response"); + assert.equal(result, "response"); + + // Second call should also work (limiterLastUsed prevents eviction) + const result2 = await withRateLimit("anthropic", "test-conn-3", "claude-3", async () => "response2"); + assert.equal(result2, "response2"); +}); + +test("updateFromHeaders with 429 triggers limiter disconnect and cleanup", async () => { + enableRateLimitProtection("test-conn-4"); + await withRateLimit("openai", "test-conn-4", "gpt-4", async () => "ok"); + + // Simulate a 429 response — this should disconnect the old limiter + const headers = new Headers({ "retry-after": "60" }); + updateFromHeaders("openai", "test-conn-4", headers, 429, "gpt-4"); + + // Should not throw — old limiter was properly cleaned up + assert.ok(true); +}); + +test("shutdown clears all maps including limiterLastUsed", async () => { + enableRateLimitProtection("test-conn-5"); + await withRateLimit("openai", "test-conn-5", "gpt-4", async () => "ok"); + + // __resetRateLimitManagerForTests calls shutdown internally + await __resetRateLimitManagerForTests(); + + // All limiters should be gone + const after = getAllRateLimitStatus(); + assert.equal(Object.keys(after).length, 0); +}); + +test("multiple providers/connections create separate limiters", async () => { + enableRateLimitProtection("conn-a"); + enableRateLimitProtection("conn-b"); + await withRateLimit("openai", "conn-a", "gpt-4", async () => "a"); + await withRateLimit("anthropic", "conn-b", "claude-3", async () => "b"); + + const status = getAllRateLimitStatus(); + const keys = Object.keys(status); + // Should have at least 2 separate limiters + assert.ok(keys.length >= 2, `Expected >=2 limiters, got ${keys.length}`); +}); diff --git a/tests/unit/registry-direct-exports.test.ts b/tests/unit/registry-direct-exports.test.ts new file mode 100644 index 0000000000..3c784966b1 --- /dev/null +++ b/tests/unit/registry-direct-exports.test.ts @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Verify all 8 registries export plain objects (no Proxy, no lazy getter) +const registries = [ + { name: "audio", mod: await import("../../open-sse/config/audioRegistry.ts"), keys: ["AUDIO_TRANSCRIPTION_PROVIDERS", "AUDIO_SPEECH_PROVIDERS"] }, + { name: "embedding", mod: await import("../../open-sse/config/embeddingRegistry.ts"), keys: ["EMBEDDING_PROVIDERS"] }, + { name: "image", mod: await import("../../open-sse/config/imageRegistry.ts"), keys: ["IMAGE_PROVIDERS"] }, + { name: "moderation", mod: await import("../../open-sse/config/moderationRegistry.ts"), keys: ["MODERATION_PROVIDERS"] }, + { name: "music", mod: await import("../../open-sse/config/musicRegistry.ts"), keys: ["MUSIC_PROVIDERS"] }, + { name: "rerank", mod: await import("../../open-sse/config/rerankRegistry.ts"), keys: ["RERANK_PROVIDERS"] }, + { name: "search", mod: await import("../../open-sse/config/searchRegistry.ts"), keys: ["SEARCH_PROVIDERS"] }, + { name: "video", mod: await import("../../open-sse/config/videoRegistry.ts"), keys: ["VIDEO_PROVIDERS"] }, +]; + +for (const { name, mod, keys } of registries) { + for (const key of keys) { + test(`${name} registry: ${key} is a plain object, not a Proxy`, () => { + const registry = mod[key]; + assert.ok(registry, `${key} should be exported`); + assert.equal(typeof registry, "object"); + + // Proxy traps break Object.keys() — plain objects return keys immediately + const firstKey = Object.keys(registry)[0]; + assert.ok(firstKey, `${key} should have at least one entry`); + + // Direct property access should work without trap overhead + const entry = registry[firstKey]; + assert.ok(entry, `First entry should be accessible`); + }); + + test(`${name} registry: ${key} entries are mutable (no Proxy freeze)`, () => { + const registry = mod[key]; + const firstKey = Object.keys(registry)[0]; + const original = registry[firstKey]; + + // Should be able to mutate without Proxy restrictions + registry[firstKey] = { ...original, _test: true }; + assert.ok(registry[firstKey]._test === true); + + // Restore + registry[firstKey] = original; + }); + } +} + +// Verify registries don't use lazy initialization patterns +test("registries do not contain getOrCreate* functions", async () => { + for (const { name, mod } of registries) { + const fns = Object.keys(mod).filter((k) => typeof mod[k] === "function"); + const lazyFns = fns.filter((fn) => fn.startsWith("getOrCreate")); + assert.equal(lazyFns.length, 0, `${name} has lazy getter: ${lazyFns.join(", ")}`); + } +}); + +test("audioRegistry exports per-type provider lookup functions", async () => { + const { getTranscriptionProvider, getSpeechProvider } = await import( + "../../open-sse/config/audioRegistry.ts" + ); + // Should return null for unknown providers (not throw) + assert.equal(getTranscriptionProvider("nonexistent-provider"), null); + assert.equal(getSpeechProvider("nonexistent-provider"), null); +}); From ff7a9069f08b4b2784dc9a6bf8893bb1e1bf414d Mon Sep 17 00:00:00 2001 From: ReqX Date: Sat, 30 May 2026 21:54:54 +0000 Subject: [PATCH 31/82] fix(routing): add agy to executor map so it uses AntigravityExecutor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agy provider was registered in providerRegistry.ts with executor: "antigravity" but the executor map in executors/index.ts only had an "antigravity" entry. getExecutor("agy") fell through to DefaultExecutor, which returned undefined for baseUrl (agy only has baseUrls), causing fetch(undefined) → TypeError: Cannot read properties of undefined (reading 'toString'). Closes diegosouzapw/OmniRoute#2932 --- open-sse/executors/index.ts | 1 + tests/unit/executor-agy.test.ts | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 tests/unit/executor-agy.test.ts diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index d5428950ac..245e772328 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -48,6 +48,7 @@ import { DoubaoWebExecutor } from "./doubao-web.ts"; const executors = { antigravity: new AntigravityExecutor(), + agy: new AntigravityExecutor(), "gemini-cli": new GeminiCLIExecutor(), github: new GithubExecutor(), qoder: new QoderExecutor(), diff --git a/tests/unit/executor-agy.test.ts b/tests/unit/executor-agy.test.ts new file mode 100644 index 0000000000..07975cb18d --- /dev/null +++ b/tests/unit/executor-agy.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getExecutor, AntigravityExecutor } from "../../open-sse/executors/index.ts"; + +test("getExecutor('agy') returns AntigravityExecutor (not DefaultExecutor)", () => { + const executor = getExecutor("agy"); + assert.ok(executor instanceof AntigravityExecutor, "agy provider should use AntigravityExecutor"); +}); + +test("getExecutor('antigravity') returns AntigravityExecutor", () => { + const executor = getExecutor("antigravity"); + assert.ok(executor instanceof AntigravityExecutor, "antigravity provider should use AntigravityExecutor"); +}); + +test("getExecutor('agy') builds valid streaming URL", () => { + const executor = getExecutor("agy"); + const url = executor.buildUrl("gemini-3-flash", true); + assert.ok( + url.includes("streamGenerateContent?alt=sse"), + `expected streaming endpoint URL, got: ${url}` + ); +}); + +test("getExecutor('agy') builds valid non-streaming URL", () => { + const executor = getExecutor("agy"); + const url = executor.buildUrl("gemini-3-flash", false); + // Antigravity executor always uses streaming endpoint (buildUrl ignores stream flag) + assert.ok( + url.includes("streamGenerateContent?alt=sse"), + `expected streaming endpoint URL (always), got: ${url}` + ); +}); + +test("getExecutor('agy') buildHeaders returns Bearer auth", () => { + const executor = getExecutor("agy"); + const headers = executor.buildHeaders({ accessToken: "test-token" }); + assert.equal(headers.Authorization, "Bearer test-token"); +}); From 0c9345f75e96c16fb0fd89fcf1aa671f7a90cda1 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sat, 30 May 2026 18:38:26 -0400 Subject: [PATCH 32/82] fix: move enforceScopes guard before MCP_TOOL_MAP lookup, add scopes to all dynamic tool definitions - Move !enforceScopes guard before MCP_TOOL_MAP lookup in evaluateToolScopes() - Add inlineScopes parameter for dynamic tool scope resolution - Add scopes to all 33 dynamic tool definitions across 5 tool files - Wire toolDef.scopes through withScopeEnforcement in server.ts - Preserves existing behavior: tool_definition_missing returned when enforceScopes=true and no scopes found anywhere --- open-sse/mcp-server/scopeEnforcement.ts | 24 +-- open-sse/mcp-server/server.ts | 141 ++++++++++-------- open-sse/mcp-server/tools/compressionTools.ts | 5 + .../mcp-server/tools/gamificationTools.ts | 8 + open-sse/mcp-server/tools/memoryTools.ts | 3 + open-sse/mcp-server/tools/pluginTools.ts | 8 + open-sse/mcp-server/tools/skillTools.ts | 4 + 7 files changed, 122 insertions(+), 71 deletions(-) diff --git a/open-sse/mcp-server/scopeEnforcement.ts b/open-sse/mcp-server/scopeEnforcement.ts index 4c622d1e2f..9d0477c16c 100644 --- a/open-sse/mcp-server/scopeEnforcement.ts +++ b/open-sse/mcp-server/scopeEnforcement.ts @@ -99,26 +99,28 @@ export function resolveCallerScopeContext( export function evaluateToolScopes( toolName: string, callerScopes: readonly string[], - enforceScopes: boolean + enforceScopes: boolean, + inlineScopes?: readonly string[] ): ScopeCheckResult { - const toolDef = MCP_TOOL_MAP[toolName]; - if (!toolDef) { + const provided = normalizeScopeList(callerScopes); + + if (!enforceScopes) { + return { allowed: true, required: [], provided, missing: [] }; + } + + const toolScopes = inlineScopes ?? MCP_TOOL_MAP[toolName]?.scopes; + const required = Array.isArray(toolScopes) ? Array.from(toolScopes) : []; + + if (required.length === 0) { return { allowed: false, required: [], - provided: Array.from(callerScopes), + provided, missing: [], reason: "tool_definition_missing", }; } - const required = Array.isArray(toolDef.scopes) ? Array.from(toolDef.scopes) : []; - const provided = normalizeScopeList(callerScopes); - - if (!enforceScopes || required.length === 0) { - return { allowed: true, required, provided, missing: [] }; - } - const missing = required.filter( (requiredScope) => !provided.some((grantedScope) => scopeMatches(grantedScope, requiredScope)) ); diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 7b75448745..a3e50efbae 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -203,11 +203,12 @@ async function omniRouteFetch(path: string, options: RequestInit = {}): Promise< function withScopeEnforcement( toolName: string, - handler: (args: unknown, extra?: McpToolExtraLike) => Promise + handler: (args: unknown, extra?: McpToolExtraLike) => Promise, + toolScopes?: readonly string[] ) { return async (args: unknown, extra?: McpToolExtraLike): Promise => { const scopeContext = resolveCallerScopeContext(extra, Array.from(MCP_ALLOWED_SCOPES)); - const scopeCheck = evaluateToolScopes(toolName, scopeContext.scopes, MCP_ENFORCE_SCOPES); + const scopeCheck = evaluateToolScopes(toolName, scopeContext.scopes, MCP_ENFORCE_SCOPES, toolScopes); if (!scopeCheck.allowed) { const missingScopes = scopeCheck.missing.length > 0 ? scopeCheck.missing.join(", ") : "unavailable"; @@ -960,7 +961,7 @@ export function createMcpServer(): McpServer { ); // ── Memory Tools ────────────────────────────── - Object.values(memoryTools).forEach((toolDef) => { + Object.values(memoryTools).forEach((toolDef: any) => { server.registerTool( toolDef.name, { @@ -968,22 +969,26 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-expect-error - handler type lost through dynamic Object.values() access - const result = await toolDef.handler(parsedArgs); - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-expect-error - handler type lost through dynamic Object.values() access + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) ); }); // ── Skill Tools ────────────────────────────── - Object.values(skillTools).forEach((toolDef) => { + Object.values(skillTools).forEach((toolDef: any) => { server.registerTool( toolDef.name, { @@ -991,17 +996,21 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-expect-error - handler type lost through dynamic Object.values() access - const result = await toolDef.handler(parsedArgs); - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-expect-error - handler type lost through dynamic Object.values() access + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) ); }); @@ -1014,22 +1023,26 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-ignore: handler expected specific object - const result = await toolDef.handler(parsedArgs); - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-ignore: handler expected specific object + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) ); }); // ── Compression Tools ───────────────────────── - Object.values(compressionTools).forEach((toolDef) => { + Object.values(compressionTools).forEach((toolDef: any) => { server.registerTool( toolDef.name, { @@ -1037,17 +1050,21 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-expect-error - handler type lost through dynamic Object.values() access - const result = await toolDef.handler(parsedArgs); - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-expect-error - handler type lost through dynamic Object.values() access + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) ); }); @@ -1060,17 +1077,21 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-ignore: handler expected specific object - const result = await toolDef.handler(parsedArgs); - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-ignore: handler expected specific object + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) ); }); diff --git a/open-sse/mcp-server/tools/compressionTools.ts b/open-sse/mcp-server/tools/compressionTools.ts index 382a977634..91075f7de4 100644 --- a/open-sse/mcp-server/tools/compressionTools.ts +++ b/open-sse/mcp-server/tools/compressionTools.ts @@ -299,6 +299,7 @@ export const compressionTools = { name: "omniroute_compression_status", description: "Returns current compression configuration, strategy, analytics summary (requests compressed, tokens saved, avg ratio), and provider-aware cache statistics.", + scopes: ["read:compression"], inputSchema: compressionStatusInput, handler: (args: z.infer) => handleCompressionStatus(args), }, @@ -306,24 +307,28 @@ export const compressionTools = { name: "omniroute_compression_configure", description: "Configure compression settings at runtime. Supports enabling/disabling compression, changing strategy (off/lite/standard/aggressive/ultra/rtk/stacked), adjusting maxTokens threshold, targetRatio, auto-trigger mode, system prompt preservation, and MCP description compression.", + scopes: ["write:compression"], inputSchema: compressionConfigureInput, handler: (args: z.infer) => handleCompressionConfigure(args), }, omniroute_set_compression_engine: { name: "omniroute_set_compression_engine", description: "Set the active compression engine and Caveman/RTK runtime options.", + scopes: ["write:compression"], inputSchema: setCompressionEngineInput, handler: (args: z.infer) => handleSetCompressionEngine(args), }, omniroute_list_compression_combos: { name: "omniroute_list_compression_combos", description: "List compression combos and their engine pipelines.", + scopes: ["read:compression"], inputSchema: listCompressionCombosInput, handler: (_args: z.infer) => handleListCompressionCombos(), }, omniroute_compression_combo_stats: { name: "omniroute_compression_combo_stats", description: "Get compression analytics grouped by engine and compression combo.", + scopes: ["read:compression"], inputSchema: compressionComboStatsInput, handler: (args: z.infer) => handleCompressionComboStats(args), diff --git a/open-sse/mcp-server/tools/gamificationTools.ts b/open-sse/mcp-server/tools/gamificationTools.ts index de42fb2ff8..99321aa5a8 100644 --- a/open-sse/mcp-server/tools/gamificationTools.ts +++ b/open-sse/mcp-server/tools/gamificationTools.ts @@ -10,6 +10,7 @@ export const gamificationTools = [ { name: "gamification_leaderboard", description: "Get leaderboard rankings for a scope (global, weekly, monthly, tokens_shared).", + scopes: ["read:gamification"], inputSchema: z.object({ scope: z.enum(["global", "weekly", "monthly", "tokens_shared"]).default("global"), limit: z.number().min(1).max(100).default(50), @@ -23,6 +24,7 @@ export const gamificationTools = [ { name: "gamification_rank", description: "Get rank for an API key in a leaderboard scope.", + scopes: ["read:gamification"], inputSchema: z.object({ apiKeyId: z.string(), scope: z.enum(["global", "weekly", "monthly", "tokens_shared"]).default("global"), @@ -36,6 +38,7 @@ export const gamificationTools = [ { name: "gamification_profile", description: "Get XP, level, and badges for an API key.", + scopes: ["read:gamification"], inputSchema: z.object({ apiKeyId: z.string(), }), @@ -64,6 +67,7 @@ export const gamificationTools = [ { name: "gamification_badges", description: "List all badge definitions or earned badges for an API key.", + scopes: ["read:gamification"], inputSchema: z.object({ apiKeyId: z.string().optional(), category: z.string().optional(), @@ -83,6 +87,7 @@ export const gamificationTools = [ { name: "gamification_transfer", description: "Transfer tokens between API keys.", + scopes: ["write:gamification"], inputSchema: z.object({ fromApiKeyId: z.string(), toApiKeyId: z.string(), @@ -108,6 +113,7 @@ export const gamificationTools = [ { name: "gamification_invite", description: "Create an invite token for server connection.", + scopes: ["write:gamification"], inputSchema: z.object({ apiKeyId: z.string(), serverUrl: z.string().optional(), @@ -122,6 +128,7 @@ export const gamificationTools = [ { name: "gamification_servers", description: "List connected community servers.", + scopes: ["read:gamification"], inputSchema: z.object({}), handler: async () => { const { listServers } = await import("../../../src/lib/gamification/servers"); @@ -131,6 +138,7 @@ export const gamificationTools = [ { name: "gamification_anomalies", description: "Get flagged anomalous XP activity (admin only).", + scopes: ["read:gamification"], inputSchema: z.object({}), handler: async () => { const { getAnomalies } = await import("../../../src/lib/gamification/antiCheat"); diff --git a/open-sse/mcp-server/tools/memoryTools.ts b/open-sse/mcp-server/tools/memoryTools.ts index 9ac7dd9ce2..da2ce1dfc1 100644 --- a/open-sse/mcp-server/tools/memoryTools.ts +++ b/open-sse/mcp-server/tools/memoryTools.ts @@ -30,6 +30,7 @@ export const memoryTools = { omniroute_memory_search: { name: "omniroute_memory_search", description: "Search memories by query, type, or API key with token budget enforcement", + scopes: ["read:memory"], inputSchema: MemorySearchSchema, handler: async (args: z.infer) => { const config = { @@ -63,6 +64,7 @@ export const memoryTools = { omniroute_memory_add: { name: "omniroute_memory_add", description: "Add a new memory entry", + scopes: ["write:memory"], inputSchema: MemoryAddSchema, handler: async (args: z.infer) => { const memory = await createMemory({ @@ -88,6 +90,7 @@ export const memoryTools = { omniroute_memory_clear: { name: "omniroute_memory_clear", description: "Clear memories for an API key, optionally filtered by type or age", + scopes: ["write:memory"], inputSchema: MemoryClearSchema, handler: async (args: z.infer) => { const result = await listMemories({ diff --git a/open-sse/mcp-server/tools/pluginTools.ts b/open-sse/mcp-server/tools/pluginTools.ts index a6d4a3f9b1..ed8dd9be97 100644 --- a/open-sse/mcp-server/tools/pluginTools.ts +++ b/open-sse/mcp-server/tools/pluginTools.ts @@ -12,6 +12,7 @@ export const pluginTools = [ { name: "plugin_list", description: "List all installed plugins with their status, hooks, and metadata.", + scopes: ["read:plugins"], inputSchema: z.object({ status: z .enum(["installed", "active", "inactive", "error"]) @@ -39,6 +40,7 @@ export const pluginTools = [ { name: "plugin_install", description: "Install a plugin from a local directory path.", + scopes: ["write:plugins"], inputSchema: z.object({ path: z.string().describe("Absolute path to the plugin directory containing plugin.json"), }), @@ -58,6 +60,7 @@ export const pluginTools = [ { name: "plugin_activate", description: "Activate an installed plugin (loads hooks into the request pipeline).", + scopes: ["write:plugins"], inputSchema: z.object({ name: z.string().describe("Plugin name (kebab-case)"), }), @@ -70,6 +73,7 @@ export const pluginTools = [ { name: "plugin_deactivate", description: "Deactivate an active plugin (unloads hooks from the request pipeline).", + scopes: ["write:plugins"], inputSchema: z.object({ name: z.string().describe("Plugin name (kebab-case)"), }), @@ -82,6 +86,7 @@ export const pluginTools = [ { name: "plugin_uninstall", description: "Uninstall a plugin (deactivates, removes files, removes from DB).", + scopes: ["write:plugins"], inputSchema: z.object({ name: z.string().describe("Plugin name (kebab-case)"), }), @@ -94,6 +99,7 @@ export const pluginTools = [ { name: "plugin_configure", description: "Get or update a plugin's configuration.", + scopes: ["write:plugins"], inputSchema: z.object({ name: z.string().describe("Plugin name"), config: z @@ -122,6 +128,7 @@ export const pluginTools = [ { name: "plugin_executions", description: "View plugin execution history (from skill_executions table).", + scopes: ["read:plugins"], inputSchema: z.object({ name: z.string().optional().describe("Filter by plugin name"), limit: z.number().min(1).max(100).default(20).describe("Max results to return"), @@ -137,6 +144,7 @@ export const pluginTools = [ { name: "plugin_scan", description: "Scan the plugin directory for new plugins and sync with DB.", + scopes: ["write:plugins"], inputSchema: z.object({}), handler: async () => { const result = await pluginManager.scan(); diff --git a/open-sse/mcp-server/tools/skillTools.ts b/open-sse/mcp-server/tools/skillTools.ts index 2ea5d8ddb9..bb3f0acb6e 100644 --- a/open-sse/mcp-server/tools/skillTools.ts +++ b/open-sse/mcp-server/tools/skillTools.ts @@ -25,6 +25,7 @@ export const skillTools = { omniroute_skills_list: { name: "omniroute_skills_list", description: "List all registered skills with optional filtering by API key or name", + scopes: ["read:skills"], inputSchema: SkillListSchema, handler: async (args: z.infer) => { await skillRegistry.loadFromDatabase(args.apiKeyId); @@ -55,6 +56,7 @@ export const skillTools = { omniroute_skills_enable: { name: "omniroute_skills_enable", description: "Enable or disable a specific skill by ID", + scopes: ["write:skills"], inputSchema: SkillEnableSchema, handler: async (args: z.infer) => { await skillRegistry.loadFromDatabase(args.apiKeyId); @@ -70,6 +72,7 @@ export const skillTools = { omniroute_skills_execute: { name: "omniroute_skills_execute", description: "Execute a skill with provided input and return the result", + scopes: ["execute:skills"], inputSchema: SkillExecuteSchema, handler: async (args: z.infer) => { const execution = await skillExecutor.execute(args.skillName, args.input, { @@ -92,6 +95,7 @@ export const skillTools = { omniroute_skills_executions: { name: "omniroute_skills_executions", description: "List recent skill execution history", + scopes: ["read:skills"], inputSchema: z.object({ apiKeyId: z.string().optional(), limit: z.number().int().positive().max(100).optional(), From 8cd77b0f4979caae037609bf9ae35711f5af71b4 Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sat, 30 May 2026 19:11:23 -0400 Subject: [PATCH 33/82] feat(notion): add Notion MCP context source with 6 tools, dashboard tab, and 20 tests --- open-sse/mcp-server/server.ts | 31 ++- open-sse/mcp-server/tools/notionTools.ts | 106 ++++++++ .../dashboard/endpoint/EndpointPageClient.tsx | 9 +- .../endpoint/components/NotionSourceCard.tsx | 177 +++++++++++++ src/app/api/settings/notion/route.ts | 89 +++++++ src/lib/db/notion.ts | 48 ++++ src/lib/notion/api.ts | 249 ++++++++++++++++++ tests/unit/db/notion.test.mjs | 25 ++ tests/unit/notion-api.test.ts | 54 ++++ tests/unit/notion-tools.test.ts | 61 +++++ 10 files changed, 847 insertions(+), 2 deletions(-) create mode 100644 open-sse/mcp-server/tools/notionTools.ts create mode 100644 src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx create mode 100644 src/app/api/settings/notion/route.ts create mode 100644 src/lib/db/notion.ts create mode 100644 src/lib/notion/api.ts create mode 100644 tests/unit/db/notion.test.mjs create mode 100644 tests/unit/notion-api.test.ts create mode 100644 tests/unit/notion-tools.test.ts diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 7b75448745..6df518f58a 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -78,6 +78,7 @@ import { skillTools } from "./tools/skillTools.ts"; import { pluginTools } from "./tools/pluginTools.ts"; import { compressionTools } from "./tools/compressionTools.ts"; import { gamificationTools } from "./tools/gamificationTools.ts"; +import { notionTools } from "./tools/notionTools.ts"; import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts"; import { smartFilterText } from "../services/compression/engines/mcpAccessibility/index.ts"; import { @@ -104,7 +105,8 @@ const TOTAL_MCP_TOOL_COUNT = Object.keys(memoryTools).length + Object.keys(skillTools).length + gamificationTools.length + - pluginTools.length; + pluginTools.length + + notionTools.length; type JsonRecord = Record; @@ -1074,6 +1076,33 @@ export function createMcpServer(): McpServer { ); }); + // ── Notion Context Source Tools ─────────────── + notionTools.forEach((toolDef) => { + server.registerTool( + toolDef.name, + { + description: toolDef.description, + // @ts-ignore: dynamic zod access + inputSchema: toolDef.inputSchema, + }, + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-ignore: handler expected specific object + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) + ); + }); + return server; } diff --git a/open-sse/mcp-server/tools/notionTools.ts b/open-sse/mcp-server/tools/notionTools.ts new file mode 100644 index 0000000000..2415482a94 --- /dev/null +++ b/open-sse/mcp-server/tools/notionTools.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +import { createNotionClient } from "../../../src/lib/notion/api.ts"; +import { getNotionToken } from "../../../src/lib/db/notion.ts"; + +function requireToken(): string { + const token = getNotionToken(); + if (!token) throw new Error("Notion integration token not configured. Set it in Settings > Context Sources."); + return token; +} + +export const notionTools = [ + { + name: "notion_search", + description: "Search pages and databases in Notion by text query. Returns matching page titles, IDs, and URL.", + scopes: ["read:notion"], + inputSchema: z.object({ + query: z.string().min(1).max(500).describe("Search query text"), + pageSize: z.number().min(1).max(100).default(20).describe("Results per page (max 100)"), + startCursor: z.string().optional().describe("Pagination cursor"), + }), + handler: async (args: { query: string; pageSize?: number; startCursor?: string }) => { + const client = createNotionClient(requireToken()); + return client.searchPagesAndDatabases(args.query, args.startCursor, args.pageSize); + }, + }, + { + name: "notion_get_page", + description: "Get the content and metadata of a Notion page by its ID.", + scopes: ["read:notion"], + inputSchema: z.object({ + pageId: z.string().min(1).describe("Notion page ID (32-char hex or UUID)"), + }), + handler: async (args: { pageId: string }) => { + const client = createNotionClient(requireToken()); + return client.getPage(args.pageId); + }, + }, + { + name: "notion_list_block_children", + description: "List all block children of a Notion block or page. Returns the block tree structure.", + scopes: ["read:notion"], + inputSchema: z.object({ + blockId: z.string().min(1).describe("Block ID to fetch children from"), + pageSize: z.number().min(1).max(100).default(50).describe("Blocks per page (max 100)"), + startCursor: z.string().optional().describe("Pagination cursor"), + }), + handler: async (args: { blockId: string; pageSize?: number; startCursor?: string }) => { + const client = createNotionClient(requireToken()); + return client.listBlockChildren(args.blockId, args.startCursor, args.pageSize); + }, + }, + { + name: "notion_query_database", + description: "Query a Notion database with optional filters and sorts. Returns matching entries.", + scopes: ["read:notion"], + inputSchema: z.object({ + databaseId: z.string().min(1).describe("Notion database ID (32-char hex or UUID)"), + filter: z.unknown().optional().describe("Optional filter object (Notion API filter format)"), + sorts: z.array(z.unknown()).optional().describe("Optional sort array (Notion API sort format)"), + pageSize: z.number().min(1).max(100).default(50).describe("Results per page (max 100)"), + startCursor: z.string().optional().describe("Pagination cursor"), + }), + handler: async (args: { + databaseId: string; + filter?: unknown; + sorts?: unknown[]; + pageSize?: number; + startCursor?: string; + }) => { + const client = createNotionClient(requireToken()); + return client.queryDatabase( + args.databaseId, + args.filter, + args.sorts, + args.startCursor, + args.pageSize + ); + }, + }, + { + name: "notion_get_database", + description: "Get metadata and schema of a Notion database by its ID.", + scopes: ["read:notion"], + inputSchema: z.object({ + databaseId: z.string().min(1).describe("Notion database ID (32-char hex or UUID)"), + }), + handler: async (args: { databaseId: string }) => { + const client = createNotionClient(requireToken()); + return client.getDatabase(args.databaseId); + }, + }, + { + name: "notion_append_blocks", + description: "Append block children to an existing Notion block or page. Maximum 100 blocks per request.", + scopes: ["write:notion"], + inputSchema: z.object({ + blockId: z.string().min(1).describe("Target block or page ID to append to"), + children: z.array(z.unknown()).describe("Array of block objects to append"), + after: z.string().optional().describe("Block ID to append after (position parameter)"), + }), + handler: async (args: { blockId: string; children: unknown[]; after?: string }) => { + const client = createNotionClient(requireToken()); + return client.appendBlocks(args.blockId, args.children, args.after); + }, + }, +]; diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 1392b4d75a..e3461e4a1e 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -11,6 +11,7 @@ import { useTranslations } from "next-intl"; import A2ADashboardPage from "./components/A2ADashboard"; import McpDashboardPage from "./components/MCPDashboard"; import TokenSaverCard from "./components/TokenSaverCard"; +import NotionSourceCard from "./components/NotionSourceCard"; const BUILD_TIME_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || null; const CLOUD_ACTION_TIMEOUT_MS = 15000; @@ -121,12 +122,13 @@ type EndpointTunnelVisibility = { showNgrokTunnel: boolean; }; -type EndpointTab = "apis" | "mcp" | "a2a"; +type EndpointTab = "apis" | "mcp" | "a2a" | "context-sources"; const ENDPOINT_TABS: Array<{ value: EndpointTab; label: string; icon: string }> = [ { value: "apis", label: "APIs", icon: "api" }, { value: "mcp", label: "MCP", icon: "extension" }, { value: "a2a", label: "A2A", icon: "hub" }, + { value: "context-sources", label: "Context Sources", icon: "database" }, ]; const DEFAULT_TUNNEL_VISIBILITY: EndpointTunnelVisibility = { @@ -1233,6 +1235,11 @@ export default function APIPageClient({ machineId }: Readonly : null} {activeEndpointTab === "a2a" ? : null} + {activeEndpointTab === "context-sources" ? ( +
+ +
+ ) : null} {/* Endpoint Card */} diff --git a/src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx b/src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx new file mode 100644 index 0000000000..b95fac1b2c --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card, Button, Input, Badge } from "@/shared/components"; + +export default function NotionSourceCard() { + const t = useTranslations("endpoint"); + const [connected, setConnected] = useState(false); + const [token, setToken] = useState(""); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [expanded, setExpanded] = useState(false); + + const fetchConfig = useCallback(async () => { + try { + const res = await fetch("/api/settings/notion"); + if (res.ok) { + const data = await res.json(); + setConnected(data.connected); + } + } catch { + // Non-critical + } + }, []); + + useEffect(() => { + void fetchConfig(); + }, [fetchConfig]); + + useEffect(() => { + if (message) { + const timer = setTimeout(() => setMessage(null), 5000); + return () => clearTimeout(timer); + } + }, [message]); + + const handleSaveToken = async () => { + if (!token.trim()) { + setMessage({ type: "error", text: "Please enter a Notion integration token" }); + return; + } + setBusy(true); + setMessage(null); + try { + const res = await fetch("/api/settings/notion", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: token.trim() }), + }); + const data = await res.json(); + if (res.ok) { + setConnected(true); + setMessage({ type: "success", text: data.message }); + } else { + setMessage({ type: "error", text: data.error ?? "Failed to connect" }); + setConnected(false); + } + } catch (err) { + setMessage({ type: "error", text: err instanceof Error ? err.message : "Connection failed" }); + } finally { + setBusy(false); + } + }; + + const handleDisconnect = async () => { + setBusy(true); + setMessage(null); + try { + const res = await fetch("/api/settings/notion", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setConnected(false); + setToken(""); + setMessage({ type: "success", text: data.message }); + } else { + setMessage({ type: "error", text: data.error ?? "Failed to disconnect" }); + } + } catch (err) { + setMessage({ type: "error", text: err instanceof Error ? err.message : "Disconnect failed" }); + } finally { + setBusy(false); + } + }; + + return ( + +
+ + + {expanded && ( +
+ {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + + {!connected ? ( +
+ +
+ setToken(e.target.value)} + placeholder="ntn_... or secret_..." + disabled={busy} + className="font-mono text-sm flex-1" + /> + +
+

+ Create an Internal Integration at{" "} + + https://www.notion.so/profile/integrations + +

+
+ ) : ( +
+ + Token configured. Notion tools are available via MCP. + + +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/app/api/settings/notion/route.ts b/src/app/api/settings/notion/route.ts new file mode 100644 index 0000000000..dc92e14189 --- /dev/null +++ b/src/app/api/settings/notion/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { + getNotionConfig, + setNotionToken, + clearNotionToken, +} from "@/lib/db/notion"; +import { createNotionClient } from "@/lib/notion/api"; + +const setTokenSchema = z.object({ + token: z.string().min(1).max(500), +}).strict(); + +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const config = getNotionConfig(); + return NextResponse.json({ + connected: config.connected, + hasToken: config.token !== null, + }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = setTokenSchema.safeParse(rawBody); + if (!parsed.success) { + return NextResponse.json( + { error: "Missing or invalid token", details: parsed.error.issues }, + { status: 400 } + ); + } + + try { + setNotionToken(parsed.data.token); + + const client = createNotionClient(parsed.data.token); + const result = await client.searchPagesAndDatabases("test", undefined, 1); + if (result && typeof result === "object" && "object" in result && (result as Record).object === "error") { + clearNotionToken(); + return NextResponse.json( + { error: "Token validation failed: invalid token", connected: false }, + { status: 400 } + ); + } + + return NextResponse.json({ + connected: true, + message: "Notion integration token saved and validated", + }); + } catch (error) { + clearNotionToken(); + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg, connected: false }, { status: 400 }); + } +} + +export async function DELETE(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + clearNotionToken(); + return NextResponse.json({ + connected: false, + message: "Notion integration disconnected", + }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/lib/db/notion.ts b/src/lib/db/notion.ts new file mode 100644 index 0000000000..2a12518204 --- /dev/null +++ b/src/lib/db/notion.ts @@ -0,0 +1,48 @@ +import { getDbInstance } from "./core"; + +const NOTION_NAMESPACE = "notion"; +const NOTION_TOKEN_KEY = "integration_token"; + +type KeyValueRow = { + value?: string; +}; + +export function getNotionToken(): string | null { + try { + const db = getDbInstance(); + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get(NOTION_NAMESPACE, NOTION_TOKEN_KEY) as KeyValueRow | undefined; + return typeof row?.value === "string" ? JSON.parse(row.value) : null; + } catch { + return null; + } +} + +export function setNotionToken(token: string): void { + try { + const db = getDbInstance(); + db.prepare( + "INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" + ).run(NOTION_NAMESPACE, NOTION_TOKEN_KEY, JSON.stringify(token)); + } catch { + // Non-fatal — token still works in-memory if persistence fails. + } +} + +export function clearNotionToken(): void { + try { + const db = getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run( + NOTION_NAMESPACE, + NOTION_TOKEN_KEY + ); + } catch { + // Non-fatal. + } +} + +export function getNotionConfig(): { token: string | null; connected: boolean } { + const token = getNotionToken(); + return { token, connected: token !== null && token.length > 0 }; +} diff --git a/src/lib/notion/api.ts b/src/lib/notion/api.ts new file mode 100644 index 0000000000..5e52b0cc2d --- /dev/null +++ b/src/lib/notion/api.ts @@ -0,0 +1,249 @@ +const NOTION_API_BASE = "https://api.notion.com/v1"; +const NOTION_VERSION = "2026-03-11"; +const MAX_RETRIES = 3; +const TIMEOUT_MS = 55000; + +export class NotionAuthError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionAuthError"; + } +} + +export class NotionNotFoundError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionNotFoundError"; + } +} + +export class NotionRateLimitError extends Error { + retryAfter: number; + constructor(msg: string, retryAfter: number) { + super(msg); + this.name = "NotionRateLimitError"; + this.retryAfter = retryAfter; + } +} + +export class NotionValidationError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionValidationError"; + } +} + +export class NotionServerError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionServerError"; + } +} + +export class NotionTimeoutError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionTimeoutError"; + } +} + +type NotionErrorBody = { + object: "error"; + status: number; + code: string; + message: string; +}; + +function classifyNotionError(status: number, code: string, message: string): Error { + switch (status) { + case 401: + return new NotionAuthError(message); + case 403: + return new NotionAuthError(`Access denied: ${message}`); + case 404: + return new NotionNotFoundError(message); + case 409: + return new NotionValidationError(`Conflict: ${message}`); + case 429: { + const retryAfter = 1; + const match = message.match(/retry after (\d+)/i) ?? message.match(/(\d+)/); + const parsed = match ? parseInt(match[1], 10) : 1; + return new NotionRateLimitError(message, Math.max(parsed, retryAfter)); + } + case 400: + return new NotionValidationError(message); + default: + if (status >= 500) return new NotionServerError(message); + return new NotionValidationError(message); + } +} + +function sanitize(msg: string): string { + return msg.replace(/\s+at\s+\S+/g, "").replace(/\/[\w/.-]+\.[a-z]+\:\d+/g, "").slice(0, 4096); +} + +async function notionFetch( + path: string, + apiKey: string, + options: RequestInit = {} +): Promise { + const url = `${NOTION_API_BASE}${path}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + const mergedSignal = options.signal + ? combineSignals(options.signal, controller.signal) + : controller.signal; + + let lastError: Error | null = null; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const response = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${apiKey}`, + "Notion-Version": NOTION_VERSION, + "Content-Type": "application/json", + ...(options.headers as Record), + }, + signal: mergedSignal, + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})) as Record; + const errBody = body as Partial; + const code = errBody?.code ?? "unknown"; + const msg = errBody?.message ?? `HTTP ${response.status}`; + const error = classifyNotionError(response.status, code, msg); + + if (error instanceof NotionRateLimitError) { + lastError = error; + const waitMs = error.retryAfter * 1000 + Math.pow(2, attempt) * 200; + await sleep(waitMs); + continue; + } + + if (error instanceof NotionServerError && attempt < MAX_RETRIES - 1) { + lastError = error; + await sleep(Math.pow(2, attempt) * 500); + continue; + } + + throw error; + } + + return response.json(); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + clearTimeout(timeout); + throw new NotionTimeoutError("Notion API request timed out after 55s"); + } + if (err instanceof NotionAuthError || err instanceof NotionNotFoundError || err instanceof NotionValidationError) { + clearTimeout(timeout); + throw err; + } + if (attempt < MAX_RETRIES - 1) { + lastError = err instanceof Error ? err : new NotionServerError(String(err)); + await sleep(Math.pow(2, attempt) * 500); + continue; + } + } + } + + clearTimeout(timeout); + throw lastError ?? new NotionServerError("Exhausted all retries"); +} + +function combineSignals(...signals: AbortSignal[]): AbortSignal { + const controller = new AbortController(); + for (const signal of signals) { + if (signal.aborted) { + controller.abort(signal.reason); + return controller.signal; + } + signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true }); + } + return controller.signal; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function createNotionClient(apiKey: string) { + const client = { + async searchPagesAndDatabases( + query: string, + startCursor?: string, + pageSize = 20 + ): Promise { + const body: Record = { + query, + page_size: Math.min(pageSize, 100), + filter: { value: "page", property: "object" }, + }; + if (startCursor) body.start_cursor = startCursor; + return notionFetch("/search", apiKey, { + method: "POST", + body: JSON.stringify(body), + }); + }, + + async getPage(pageId: string): Promise { + return notionFetch(`/pages/${pageId}`, apiKey); + }, + + async listBlockChildren( + blockId: string, + startCursor?: string, + pageSize = 50 + ): Promise { + const params = new URLSearchParams(); + params.set("page_size", String(Math.min(pageSize, 100))); + if (startCursor) params.set("start_cursor", startCursor); + return notionFetch(`/blocks/${blockId}/children?${params}`, apiKey); + }, + + async queryDatabase( + databaseId: string, + filter?: unknown, + sorts?: unknown[], + startCursor?: string, + pageSize = 50 + ): Promise { + const body: Record = { + page_size: Math.min(pageSize, 100), + }; + if (filter) body.filter = filter; + if (sorts) body.sorts = sorts; + if (startCursor) body.start_cursor = startCursor; + return notionFetch(`/databases/${databaseId}/query`, apiKey, { + method: "POST", + body: JSON.stringify(body), + }); + }, + + async getDatabase(databaseId: string): Promise { + return notionFetch(`/databases/${databaseId}`, apiKey); + }, + + async appendBlocks( + blockId: string, + children: unknown[], + after?: string + ): Promise { + const body: Record = { + children: children.slice(0, 100), + }; + if (after) body.after = after; + return notionFetch(`/blocks/${blockId}/children`, apiKey, { + method: "PATCH", + body: JSON.stringify(body), + }); + }, + }; + + return client; +} + +export type NotionClient = ReturnType; diff --git a/tests/unit/db/notion.test.mjs b/tests/unit/db/notion.test.mjs new file mode 100644 index 0000000000..b1e204f82a --- /dev/null +++ b/tests/unit/db/notion.test.mjs @@ -0,0 +1,25 @@ +import { test } from "node:test"; +import assert from "node:assert"; + +test("notion DB module exports expected functions", async () => { + const mod = await import("../../../src/lib/db/notion.ts"); + assert.equal(typeof mod.getNotionToken, "function"); + assert.equal(typeof mod.setNotionToken, "function"); + assert.equal(typeof mod.clearNotionToken, "function"); + assert.equal(typeof mod.getNotionConfig, "function"); +}); + +test("getNotionConfig returns expected shape", async () => { + const { getNotionConfig } = await import("../../../src/lib/db/notion.ts"); + const config = getNotionConfig(); + assert.ok(typeof config === "object"); + assert.ok("connected" in config); + assert.ok("token" in config); + assert.equal(typeof config.connected, "boolean"); +}); + +test("setNotionToken and clearNotionToken are callable without DB", async () => { + const { setNotionToken, clearNotionToken } = await import("../../../src/lib/db/notion.ts"); + assert.doesNotThrow(() => setNotionToken("test")); + assert.doesNotThrow(() => clearNotionToken()); +}); diff --git a/tests/unit/notion-api.test.ts b/tests/unit/notion-api.test.ts new file mode 100644 index 0000000000..860a5e52dd --- /dev/null +++ b/tests/unit/notion-api.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + NotionAuthError, + NotionNotFoundError, + NotionRateLimitError, + NotionValidationError, + NotionServerError, + NotionTimeoutError, +} from "../../src/lib/notion/api.ts"; + +test("NotionAuthError has correct name", () => { + const err = new NotionAuthError("bad token"); + assert.equal(err.name, "NotionAuthError"); + assert.equal(err.message, "bad token"); +}); + +test("NotionNotFoundError has correct name", () => { + const err = new NotionNotFoundError("not found"); + assert.equal(err.name, "NotionNotFoundError"); +}); + +test("NotionRateLimitError has retryAfter property", () => { + const err = new NotionRateLimitError("rate limited", 5); + assert.equal(err.retryAfter, 5); + assert.equal(err.name, "NotionRateLimitError"); +}); + +test("NotionValidationError has correct name", () => { + const err = new NotionValidationError("invalid"); + assert.equal(err.name, "NotionValidationError"); +}); + +test("NotionServerError has correct name", () => { + const err = new NotionServerError("server error"); + assert.equal(err.name, "NotionServerError"); +}); + +test("NotionTimeoutError has correct name", () => { + const err = new NotionTimeoutError("timed out"); + assert.equal(err.name, "NotionTimeoutError"); +}); + +test("createNotionClient returns object with expected methods", async () => { + const { createNotionClient } = await import("../../src/lib/notion/api.ts"); + const client = createNotionClient("test-token"); + assert.equal(typeof client.searchPagesAndDatabases, "function"); + assert.equal(typeof client.getPage, "function"); + assert.equal(typeof client.listBlockChildren, "function"); + assert.equal(typeof client.queryDatabase, "function"); + assert.equal(typeof client.getDatabase, "function"); + assert.equal(typeof client.appendBlocks, "function"); +}); diff --git a/tests/unit/notion-tools.test.ts b/tests/unit/notion-tools.test.ts new file mode 100644 index 0000000000..c7f87af7a4 --- /dev/null +++ b/tests/unit/notion-tools.test.ts @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { evaluateToolScopes } from "../../open-sse/mcp-server/scopeEnforcement.ts"; + +test("notion tools — enforcement disabled allows any", () => { + const result = evaluateToolScopes("notion_search", [], false); + assert.equal(result.allowed, true); +}); + +test("notion tools — missing read:notion denied via inline scopes", () => { + const result = evaluateToolScopes("notion_search", ["read:health"], true, ["read:notion"]); + assert.equal(result.allowed, false); + assert.ok(result.missing.includes("read:notion")); +}); + +test("notion tools — correct read scope allowed via inline scopes", () => { + const result = evaluateToolScopes("notion_search", ["read:notion"], true, ["read:notion"]); + assert.equal(result.allowed, true); + assert.deepEqual(result.missing, []); +}); + +test("notion tools — wildcard read:* covers read:notion", () => { + const result = evaluateToolScopes("notion_search", ["read:*"], true, ["read:notion"]); + assert.equal(result.allowed, true); +}); + +test("notion tools — write:notion denied for read-only caller", () => { + const result = evaluateToolScopes("notion_append_blocks", ["read:notion"], true, ["write:notion"]); + assert.equal(result.allowed, false); + assert.ok(result.missing.includes("write:notion")); +}); + +test("notion tools — write:notion allowed with correct scope", () => { + const result = evaluateToolScopes("notion_append_blocks", ["write:notion"], true, ["write:notion"]); + assert.equal(result.allowed, true); +}); + +test("notion tools — tool without inline scopes returns denied with tool_definition_missing", () => { + // Without inline scopes, a tool not in MCP_TOOL_MAP is treated as missing. + const result = evaluateToolScopes("notion_search", ["read:notion"], true); + assert.equal(result.allowed, false); + assert.equal(result.reason, "tool_definition_missing"); +}); + +test("notion tools — inline scopes parameter missing scope denied", () => { + const result = evaluateToolScopes("notion_search", ["read:health"], true, ["read:notion"]); + assert.equal(result.allowed, false); +}); + +test("notion tools — each read tool validates independently", () => { + for (const name of ["notion_search", "notion_get_page", "notion_list_block_children", "notion_query_database", "notion_get_database"]) { + const result = evaluateToolScopes(name, ["read:notion"], true, ["read:notion"]); + assert.equal(result.allowed, true, `${name} should be allowed with read:notion`); + } +}); + +test("notion tools — append_blocks requires write scope", () => { + const result = evaluateToolScopes("notion_append_blocks", ["read:notion"], true, ["write:notion"]); + assert.equal(result.allowed, false); +}); From 58eb093a2e57fa1cdef2cd2bd834238169d1d38f Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sat, 30 May 2026 19:33:37 -0400 Subject: [PATCH 34/82] docs: update CHANGELOG and MCP-SERVER.md for scope fix and Notion context source --- .source/browser.ts | 2 +- .source/server.ts | 58 +++++++++++------------ CHANGELOG.md | 8 ++++ docs/frameworks/MCP-SERVER.md | 87 ++++++++++++++++++++++++++--------- 4 files changed, 103 insertions(+), 52 deletions(-) diff --git a/.source/browser.ts b/.source/browser.ts index ce5576f0bb..006dbe3f12 100644 --- a/.source/browser.ts +++ b/.source/browser.ts @@ -7,6 +7,6 @@ const create = browser(); const browserCollections = { - docs: create.doc("docs", {"architecture/ARCHITECTURE.md": () => import("../docs/architecture/ARCHITECTURE.md?collection=docs"), "architecture/AUTHZ_GUIDE.md": () => import("../docs/architecture/AUTHZ_GUIDE.md?collection=docs"), "architecture/CODEBASE_DOCUMENTATION.md": () => import("../docs/architecture/CODEBASE_DOCUMENTATION.md?collection=docs"), "architecture/REPOSITORY_MAP.md": () => import("../docs/architecture/REPOSITORY_MAP.md?collection=docs"), "architecture/RESILIENCE_GUIDE.md": () => import("../docs/architecture/RESILIENCE_GUIDE.md?collection=docs"), "compression/COMPRESSION_ENGINES.md": () => import("../docs/compression/COMPRESSION_ENGINES.md?collection=docs"), "compression/COMPRESSION_GUIDE.md": () => import("../docs/compression/COMPRESSION_GUIDE.md?collection=docs"), "compression/COMPRESSION_LANGUAGE_PACKS.md": () => import("../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs"), "compression/COMPRESSION_RULES_FORMAT.md": () => import("../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs"), "compression/RTK_COMPRESSION.md": () => import("../docs/compression/RTK_COMPRESSION.md?collection=docs"), "guides/DOCKER_GUIDE.md": () => import("../docs/guides/DOCKER_GUIDE.md?collection=docs"), "guides/ELECTRON_GUIDE.md": () => import("../docs/guides/ELECTRON_GUIDE.md?collection=docs"), "guides/FEATURES.md": () => import("../docs/guides/FEATURES.md?collection=docs"), "guides/I18N.md": () => import("../docs/guides/I18N.md?collection=docs"), "guides/KIRO_SETUP.md": () => import("../docs/guides/KIRO_SETUP.md?collection=docs"), "guides/PWA_GUIDE.md": () => import("../docs/guides/PWA_GUIDE.md?collection=docs"), "guides/SETUP_GUIDE.md": () => import("../docs/guides/SETUP_GUIDE.md?collection=docs"), "guides/TERMUX_GUIDE.md": () => import("../docs/guides/TERMUX_GUIDE.md?collection=docs"), "guides/TROUBLESHOOTING.md": () => import("../docs/guides/TROUBLESHOOTING.md?collection=docs"), "guides/UNINSTALL.md": () => import("../docs/guides/UNINSTALL.md?collection=docs"), "guides/USER_GUIDE.md": () => import("../docs/guides/USER_GUIDE.md?collection=docs"), "frameworks/A2A-SERVER.md": () => import("../docs/frameworks/A2A-SERVER.md?collection=docs"), "frameworks/AGENT_PROTOCOLS_GUIDE.md": () => import("../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs"), "frameworks/CLOUD_AGENT.md": () => import("../docs/frameworks/CLOUD_AGENT.md?collection=docs"), "frameworks/EMBEDDED-SERVICES.md": () => import("../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs"), "frameworks/EVALS.md": () => import("../docs/frameworks/EVALS.md?collection=docs"), "frameworks/GAMIFICATION.md": () => import("../docs/frameworks/GAMIFICATION.md?collection=docs"), "frameworks/MCP-SERVER.md": () => import("../docs/frameworks/MCP-SERVER.md?collection=docs"), "frameworks/MEMORY.md": () => import("../docs/frameworks/MEMORY.md?collection=docs"), "frameworks/OPENCODE.md": () => import("../docs/frameworks/OPENCODE.md?collection=docs"), "frameworks/SKILLS.md": () => import("../docs/frameworks/SKILLS.md?collection=docs"), "frameworks/WEBHOOKS.md": () => import("../docs/frameworks/WEBHOOKS.md?collection=docs"), "ops/COVERAGE_PLAN.md": () => import("../docs/ops/COVERAGE_PLAN.md?collection=docs"), "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": () => import("../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs"), "ops/FLY_IO_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs"), "ops/PROXY_GUIDE.md": () => import("../docs/ops/PROXY_GUIDE.md?collection=docs"), "ops/RELEASE_CHECKLIST.md": () => import("../docs/ops/RELEASE_CHECKLIST.md?collection=docs"), "ops/SQLITE_RUNTIME.md": () => import("../docs/ops/SQLITE_RUNTIME.md?collection=docs"), "ops/TUNNELS_GUIDE.md": () => import("../docs/ops/TUNNELS_GUIDE.md?collection=docs"), "ops/VM_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/VM_DEPLOYMENT_GUIDE.md?collection=docs"), "reference/API_REFERENCE.md": () => import("../docs/reference/API_REFERENCE.md?collection=docs"), "reference/CLI-TOOLS.md": () => import("../docs/reference/CLI-TOOLS.md?collection=docs"), "reference/ENVIRONMENT.md": () => import("../docs/reference/ENVIRONMENT.md?collection=docs"), "reference/FREE_TIERS.md": () => import("../docs/reference/FREE_TIERS.md?collection=docs"), "reference/PROVIDER_REFERENCE.md": () => import("../docs/reference/PROVIDER_REFERENCE.md?collection=docs"), "routing/AUTO-COMBO.md": () => import("../docs/routing/AUTO-COMBO.md?collection=docs"), "routing/REASONING_REPLAY.md": () => import("../docs/routing/REASONING_REPLAY.md?collection=docs"), "security/CLI_TOKEN.md": () => import("../docs/security/CLI_TOKEN.md?collection=docs"), "security/CLI_TOKEN_AUTH.md": () => import("../docs/security/CLI_TOKEN_AUTH.md?collection=docs"), "security/COMPLIANCE.md": () => import("../docs/security/COMPLIANCE.md?collection=docs"), "security/ERROR_SANITIZATION.md": () => import("../docs/security/ERROR_SANITIZATION.md?collection=docs"), "security/GUARDRAILS.md": () => import("../docs/security/GUARDRAILS.md?collection=docs"), "security/PUBLIC_CREDS.md": () => import("../docs/security/PUBLIC_CREDS.md?collection=docs"), "security/ROUTE_GUARD_TIERS.md": () => import("../docs/security/ROUTE_GUARD_TIERS.md?collection=docs"), "security/SOCKET_DEV_FINDINGS.md": () => import("../docs/security/SOCKET_DEV_FINDINGS.md?collection=docs"), "security/STEALTH_GUIDE.md": () => import("../docs/security/STEALTH_GUIDE.md?collection=docs"), }), + docs: create.doc("docs", {"architecture/ARCHITECTURE.md": () => import("../docs/architecture/ARCHITECTURE.md?collection=docs"), "architecture/AUTHZ_GUIDE.md": () => import("../docs/architecture/AUTHZ_GUIDE.md?collection=docs"), "architecture/CODEBASE_DOCUMENTATION.md": () => import("../docs/architecture/CODEBASE_DOCUMENTATION.md?collection=docs"), "architecture/REPOSITORY_MAP.md": () => import("../docs/architecture/REPOSITORY_MAP.md?collection=docs"), "architecture/RESILIENCE_GUIDE.md": () => import("../docs/architecture/RESILIENCE_GUIDE.md?collection=docs"), "compression/COMPRESSION_ENGINES.md": () => import("../docs/compression/COMPRESSION_ENGINES.md?collection=docs"), "compression/COMPRESSION_GUIDE.md": () => import("../docs/compression/COMPRESSION_GUIDE.md?collection=docs"), "compression/COMPRESSION_LANGUAGE_PACKS.md": () => import("../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs"), "compression/COMPRESSION_RULES_FORMAT.md": () => import("../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs"), "compression/RTK_COMPRESSION.md": () => import("../docs/compression/RTK_COMPRESSION.md?collection=docs"), "frameworks/A2A-SERVER.md": () => import("../docs/frameworks/A2A-SERVER.md?collection=docs"), "frameworks/AGENT_PROTOCOLS_GUIDE.md": () => import("../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs"), "frameworks/CLOUD_AGENT.md": () => import("../docs/frameworks/CLOUD_AGENT.md?collection=docs"), "frameworks/EMBEDDED-SERVICES.md": () => import("../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs"), "frameworks/EVALS.md": () => import("../docs/frameworks/EVALS.md?collection=docs"), "frameworks/GAMIFICATION.md": () => import("../docs/frameworks/GAMIFICATION.md?collection=docs"), "frameworks/MCP-SERVER.md": () => import("../docs/frameworks/MCP-SERVER.md?collection=docs"), "frameworks/MEMORY.md": () => import("../docs/frameworks/MEMORY.md?collection=docs"), "frameworks/OPENCODE.md": () => import("../docs/frameworks/OPENCODE.md?collection=docs"), "frameworks/SKILLS.md": () => import("../docs/frameworks/SKILLS.md?collection=docs"), "frameworks/WEBHOOKS.md": () => import("../docs/frameworks/WEBHOOKS.md?collection=docs"), "guides/DOCKER_GUIDE.md": () => import("../docs/guides/DOCKER_GUIDE.md?collection=docs"), "guides/ELECTRON_GUIDE.md": () => import("../docs/guides/ELECTRON_GUIDE.md?collection=docs"), "guides/FEATURES.md": () => import("../docs/guides/FEATURES.md?collection=docs"), "guides/I18N.md": () => import("../docs/guides/I18N.md?collection=docs"), "guides/KIRO_SETUP.md": () => import("../docs/guides/KIRO_SETUP.md?collection=docs"), "guides/PWA_GUIDE.md": () => import("../docs/guides/PWA_GUIDE.md?collection=docs"), "guides/SETUP_GUIDE.md": () => import("../docs/guides/SETUP_GUIDE.md?collection=docs"), "guides/TERMUX_GUIDE.md": () => import("../docs/guides/TERMUX_GUIDE.md?collection=docs"), "guides/TROUBLESHOOTING.md": () => import("../docs/guides/TROUBLESHOOTING.md?collection=docs"), "guides/UNINSTALL.md": () => import("../docs/guides/UNINSTALL.md?collection=docs"), "guides/USER_GUIDE.md": () => import("../docs/guides/USER_GUIDE.md?collection=docs"), "ops/COVERAGE_PLAN.md": () => import("../docs/ops/COVERAGE_PLAN.md?collection=docs"), "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": () => import("../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs"), "ops/FLY_IO_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs"), "ops/PROXY_GUIDE.md": () => import("../docs/ops/PROXY_GUIDE.md?collection=docs"), "ops/RELEASE_CHECKLIST.md": () => import("../docs/ops/RELEASE_CHECKLIST.md?collection=docs"), "ops/SQLITE_RUNTIME.md": () => import("../docs/ops/SQLITE_RUNTIME.md?collection=docs"), "ops/TUNNELS_GUIDE.md": () => import("../docs/ops/TUNNELS_GUIDE.md?collection=docs"), "ops/VM_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/VM_DEPLOYMENT_GUIDE.md?collection=docs"), "reference/API_REFERENCE.md": () => import("../docs/reference/API_REFERENCE.md?collection=docs"), "reference/CLI-TOOLS.md": () => import("../docs/reference/CLI-TOOLS.md?collection=docs"), "reference/ENVIRONMENT.md": () => import("../docs/reference/ENVIRONMENT.md?collection=docs"), "reference/FREE_TIERS.md": () => import("../docs/reference/FREE_TIERS.md?collection=docs"), "reference/PROVIDER_REFERENCE.md": () => import("../docs/reference/PROVIDER_REFERENCE.md?collection=docs"), "routing/AUTO-COMBO.md": () => import("../docs/routing/AUTO-COMBO.md?collection=docs"), "routing/REASONING_REPLAY.md": () => import("../docs/routing/REASONING_REPLAY.md?collection=docs"), "security/CLI_TOKEN.md": () => import("../docs/security/CLI_TOKEN.md?collection=docs"), "security/CLI_TOKEN_AUTH.md": () => import("../docs/security/CLI_TOKEN_AUTH.md?collection=docs"), "security/COMPLIANCE.md": () => import("../docs/security/COMPLIANCE.md?collection=docs"), "security/ERROR_SANITIZATION.md": () => import("../docs/security/ERROR_SANITIZATION.md?collection=docs"), "security/GUARDRAILS.md": () => import("../docs/security/GUARDRAILS.md?collection=docs"), "security/PUBLIC_CREDS.md": () => import("../docs/security/PUBLIC_CREDS.md?collection=docs"), "security/ROUTE_GUARD_TIERS.md": () => import("../docs/security/ROUTE_GUARD_TIERS.md?collection=docs"), "security/SOCKET_DEV_FINDINGS.md": () => import("../docs/security/SOCKET_DEV_FINDINGS.md?collection=docs"), "security/STEALTH_GUIDE.md": () => import("../docs/security/STEALTH_GUIDE.md?collection=docs"), }), }; export default browserCollections; \ No newline at end of file diff --git a/.source/server.ts b/.source/server.ts index b7e781f56b..9debc21e3a 100644 --- a/.source/server.ts +++ b/.source/server.ts @@ -1,10 +1,10 @@ // @ts-nocheck -import { default as __fd_glob_65 } from "../docs/security/meta.json?collection=docs" -import { default as __fd_glob_64 } from "../docs/routing/meta.json?collection=docs" -import { default as __fd_glob_63 } from "../docs/reference/openapi.yaml?collection=docs" -import { default as __fd_glob_62 } from "../docs/reference/meta.json?collection=docs" -import { default as __fd_glob_61 } from "../docs/ops/meta.json?collection=docs" -import { default as __fd_glob_60 } from "../docs/guides/meta.json?collection=docs" +import { default as __fd_glob_65 } from "../docs/reference/openapi.yaml?collection=docs" +import { default as __fd_glob_64 } from "../docs/reference/meta.json?collection=docs" +import { default as __fd_glob_63 } from "../docs/security/meta.json?collection=docs" +import { default as __fd_glob_62 } from "../docs/routing/meta.json?collection=docs" +import { default as __fd_glob_61 } from "../docs/guides/meta.json?collection=docs" +import { default as __fd_glob_60 } from "../docs/ops/meta.json?collection=docs" import { default as __fd_glob_59 } from "../docs/frameworks/meta.json?collection=docs" import { default as __fd_glob_58 } from "../docs/compression/meta.json?collection=docs" import { default as __fd_glob_57 } from "../docs/architecture/meta.json?collection=docs" @@ -33,28 +33,28 @@ import * as __fd_glob_35 from "../docs/ops/PROXY_GUIDE.md?collection=docs" import * as __fd_glob_34 from "../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs" import * as __fd_glob_33 from "../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs" import * as __fd_glob_32 from "../docs/ops/COVERAGE_PLAN.md?collection=docs" -import * as __fd_glob_31 from "../docs/frameworks/WEBHOOKS.md?collection=docs" -import * as __fd_glob_30 from "../docs/frameworks/SKILLS.md?collection=docs" -import * as __fd_glob_29 from "../docs/frameworks/OPENCODE.md?collection=docs" -import * as __fd_glob_28 from "../docs/frameworks/MEMORY.md?collection=docs" -import * as __fd_glob_27 from "../docs/frameworks/MCP-SERVER.md?collection=docs" -import * as __fd_glob_26 from "../docs/frameworks/GAMIFICATION.md?collection=docs" -import * as __fd_glob_25 from "../docs/frameworks/EVALS.md?collection=docs" -import * as __fd_glob_24 from "../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs" -import * as __fd_glob_23 from "../docs/frameworks/CLOUD_AGENT.md?collection=docs" -import * as __fd_glob_22 from "../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs" -import * as __fd_glob_21 from "../docs/frameworks/A2A-SERVER.md?collection=docs" -import * as __fd_glob_20 from "../docs/guides/USER_GUIDE.md?collection=docs" -import * as __fd_glob_19 from "../docs/guides/UNINSTALL.md?collection=docs" -import * as __fd_glob_18 from "../docs/guides/TROUBLESHOOTING.md?collection=docs" -import * as __fd_glob_17 from "../docs/guides/TERMUX_GUIDE.md?collection=docs" -import * as __fd_glob_16 from "../docs/guides/SETUP_GUIDE.md?collection=docs" -import * as __fd_glob_15 from "../docs/guides/PWA_GUIDE.md?collection=docs" -import * as __fd_glob_14 from "../docs/guides/KIRO_SETUP.md?collection=docs" -import * as __fd_glob_13 from "../docs/guides/I18N.md?collection=docs" -import * as __fd_glob_12 from "../docs/guides/FEATURES.md?collection=docs" -import * as __fd_glob_11 from "../docs/guides/ELECTRON_GUIDE.md?collection=docs" -import * as __fd_glob_10 from "../docs/guides/DOCKER_GUIDE.md?collection=docs" +import * as __fd_glob_31 from "../docs/guides/USER_GUIDE.md?collection=docs" +import * as __fd_glob_30 from "../docs/guides/UNINSTALL.md?collection=docs" +import * as __fd_glob_29 from "../docs/guides/TROUBLESHOOTING.md?collection=docs" +import * as __fd_glob_28 from "../docs/guides/TERMUX_GUIDE.md?collection=docs" +import * as __fd_glob_27 from "../docs/guides/SETUP_GUIDE.md?collection=docs" +import * as __fd_glob_26 from "../docs/guides/PWA_GUIDE.md?collection=docs" +import * as __fd_glob_25 from "../docs/guides/KIRO_SETUP.md?collection=docs" +import * as __fd_glob_24 from "../docs/guides/I18N.md?collection=docs" +import * as __fd_glob_23 from "../docs/guides/FEATURES.md?collection=docs" +import * as __fd_glob_22 from "../docs/guides/ELECTRON_GUIDE.md?collection=docs" +import * as __fd_glob_21 from "../docs/guides/DOCKER_GUIDE.md?collection=docs" +import * as __fd_glob_20 from "../docs/frameworks/WEBHOOKS.md?collection=docs" +import * as __fd_glob_19 from "../docs/frameworks/SKILLS.md?collection=docs" +import * as __fd_glob_18 from "../docs/frameworks/OPENCODE.md?collection=docs" +import * as __fd_glob_17 from "../docs/frameworks/MEMORY.md?collection=docs" +import * as __fd_glob_16 from "../docs/frameworks/MCP-SERVER.md?collection=docs" +import * as __fd_glob_15 from "../docs/frameworks/GAMIFICATION.md?collection=docs" +import * as __fd_glob_14 from "../docs/frameworks/EVALS.md?collection=docs" +import * as __fd_glob_13 from "../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs" +import * as __fd_glob_12 from "../docs/frameworks/CLOUD_AGENT.md?collection=docs" +import * as __fd_glob_11 from "../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs" +import * as __fd_glob_10 from "../docs/frameworks/A2A-SERVER.md?collection=docs" import * as __fd_glob_9 from "../docs/compression/RTK_COMPRESSION.md?collection=docs" import * as __fd_glob_8 from "../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs" import * as __fd_glob_7 from "../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs" @@ -73,4 +73,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}}); -export const docs = await create.docs("docs", "docs", {"meta.json": __fd_glob_56, "architecture/meta.json": __fd_glob_57, "compression/meta.json": __fd_glob_58, "frameworks/meta.json": __fd_glob_59, "guides/meta.json": __fd_glob_60, "ops/meta.json": __fd_glob_61, "reference/meta.json": __fd_glob_62, "reference/openapi.yaml": __fd_glob_63, "routing/meta.json": __fd_glob_64, "security/meta.json": __fd_glob_65, }, {"architecture/ARCHITECTURE.md": __fd_glob_0, "architecture/AUTHZ_GUIDE.md": __fd_glob_1, "architecture/CODEBASE_DOCUMENTATION.md": __fd_glob_2, "architecture/REPOSITORY_MAP.md": __fd_glob_3, "architecture/RESILIENCE_GUIDE.md": __fd_glob_4, "compression/COMPRESSION_ENGINES.md": __fd_glob_5, "compression/COMPRESSION_GUIDE.md": __fd_glob_6, "compression/COMPRESSION_LANGUAGE_PACKS.md": __fd_glob_7, "compression/COMPRESSION_RULES_FORMAT.md": __fd_glob_8, "compression/RTK_COMPRESSION.md": __fd_glob_9, "guides/DOCKER_GUIDE.md": __fd_glob_10, "guides/ELECTRON_GUIDE.md": __fd_glob_11, "guides/FEATURES.md": __fd_glob_12, "guides/I18N.md": __fd_glob_13, "guides/KIRO_SETUP.md": __fd_glob_14, "guides/PWA_GUIDE.md": __fd_glob_15, "guides/SETUP_GUIDE.md": __fd_glob_16, "guides/TERMUX_GUIDE.md": __fd_glob_17, "guides/TROUBLESHOOTING.md": __fd_glob_18, "guides/UNINSTALL.md": __fd_glob_19, "guides/USER_GUIDE.md": __fd_glob_20, "frameworks/A2A-SERVER.md": __fd_glob_21, "frameworks/AGENT_PROTOCOLS_GUIDE.md": __fd_glob_22, "frameworks/CLOUD_AGENT.md": __fd_glob_23, "frameworks/EMBEDDED-SERVICES.md": __fd_glob_24, "frameworks/EVALS.md": __fd_glob_25, "frameworks/GAMIFICATION.md": __fd_glob_26, "frameworks/MCP-SERVER.md": __fd_glob_27, "frameworks/MEMORY.md": __fd_glob_28, "frameworks/OPENCODE.md": __fd_glob_29, "frameworks/SKILLS.md": __fd_glob_30, "frameworks/WEBHOOKS.md": __fd_glob_31, "ops/COVERAGE_PLAN.md": __fd_glob_32, "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": __fd_glob_33, "ops/FLY_IO_DEPLOYMENT_GUIDE.md": __fd_glob_34, "ops/PROXY_GUIDE.md": __fd_glob_35, "ops/RELEASE_CHECKLIST.md": __fd_glob_36, "ops/SQLITE_RUNTIME.md": __fd_glob_37, "ops/TUNNELS_GUIDE.md": __fd_glob_38, "ops/VM_DEPLOYMENT_GUIDE.md": __fd_glob_39, "reference/API_REFERENCE.md": __fd_glob_40, "reference/CLI-TOOLS.md": __fd_glob_41, "reference/ENVIRONMENT.md": __fd_glob_42, "reference/FREE_TIERS.md": __fd_glob_43, "reference/PROVIDER_REFERENCE.md": __fd_glob_44, "routing/AUTO-COMBO.md": __fd_glob_45, "routing/REASONING_REPLAY.md": __fd_glob_46, "security/CLI_TOKEN.md": __fd_glob_47, "security/CLI_TOKEN_AUTH.md": __fd_glob_48, "security/COMPLIANCE.md": __fd_glob_49, "security/ERROR_SANITIZATION.md": __fd_glob_50, "security/GUARDRAILS.md": __fd_glob_51, "security/PUBLIC_CREDS.md": __fd_glob_52, "security/ROUTE_GUARD_TIERS.md": __fd_glob_53, "security/SOCKET_DEV_FINDINGS.md": __fd_glob_54, "security/STEALTH_GUIDE.md": __fd_glob_55, }); \ No newline at end of file +export const docs = await create.docs("docs", "docs", {"meta.json": __fd_glob_56, "architecture/meta.json": __fd_glob_57, "compression/meta.json": __fd_glob_58, "frameworks/meta.json": __fd_glob_59, "ops/meta.json": __fd_glob_60, "guides/meta.json": __fd_glob_61, "routing/meta.json": __fd_glob_62, "security/meta.json": __fd_glob_63, "reference/meta.json": __fd_glob_64, "reference/openapi.yaml": __fd_glob_65, }, {"architecture/ARCHITECTURE.md": __fd_glob_0, "architecture/AUTHZ_GUIDE.md": __fd_glob_1, "architecture/CODEBASE_DOCUMENTATION.md": __fd_glob_2, "architecture/REPOSITORY_MAP.md": __fd_glob_3, "architecture/RESILIENCE_GUIDE.md": __fd_glob_4, "compression/COMPRESSION_ENGINES.md": __fd_glob_5, "compression/COMPRESSION_GUIDE.md": __fd_glob_6, "compression/COMPRESSION_LANGUAGE_PACKS.md": __fd_glob_7, "compression/COMPRESSION_RULES_FORMAT.md": __fd_glob_8, "compression/RTK_COMPRESSION.md": __fd_glob_9, "frameworks/A2A-SERVER.md": __fd_glob_10, "frameworks/AGENT_PROTOCOLS_GUIDE.md": __fd_glob_11, "frameworks/CLOUD_AGENT.md": __fd_glob_12, "frameworks/EMBEDDED-SERVICES.md": __fd_glob_13, "frameworks/EVALS.md": __fd_glob_14, "frameworks/GAMIFICATION.md": __fd_glob_15, "frameworks/MCP-SERVER.md": __fd_glob_16, "frameworks/MEMORY.md": __fd_glob_17, "frameworks/OPENCODE.md": __fd_glob_18, "frameworks/SKILLS.md": __fd_glob_19, "frameworks/WEBHOOKS.md": __fd_glob_20, "guides/DOCKER_GUIDE.md": __fd_glob_21, "guides/ELECTRON_GUIDE.md": __fd_glob_22, "guides/FEATURES.md": __fd_glob_23, "guides/I18N.md": __fd_glob_24, "guides/KIRO_SETUP.md": __fd_glob_25, "guides/PWA_GUIDE.md": __fd_glob_26, "guides/SETUP_GUIDE.md": __fd_glob_27, "guides/TERMUX_GUIDE.md": __fd_glob_28, "guides/TROUBLESHOOTING.md": __fd_glob_29, "guides/UNINSTALL.md": __fd_glob_30, "guides/USER_GUIDE.md": __fd_glob_31, "ops/COVERAGE_PLAN.md": __fd_glob_32, "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": __fd_glob_33, "ops/FLY_IO_DEPLOYMENT_GUIDE.md": __fd_glob_34, "ops/PROXY_GUIDE.md": __fd_glob_35, "ops/RELEASE_CHECKLIST.md": __fd_glob_36, "ops/SQLITE_RUNTIME.md": __fd_glob_37, "ops/TUNNELS_GUIDE.md": __fd_glob_38, "ops/VM_DEPLOYMENT_GUIDE.md": __fd_glob_39, "reference/API_REFERENCE.md": __fd_glob_40, "reference/CLI-TOOLS.md": __fd_glob_41, "reference/ENVIRONMENT.md": __fd_glob_42, "reference/FREE_TIERS.md": __fd_glob_43, "reference/PROVIDER_REFERENCE.md": __fd_glob_44, "routing/AUTO-COMBO.md": __fd_glob_45, "routing/REASONING_REPLAY.md": __fd_glob_46, "security/CLI_TOKEN.md": __fd_glob_47, "security/CLI_TOKEN_AUTH.md": __fd_glob_48, "security/COMPLIANCE.md": __fd_glob_49, "security/ERROR_SANITIZATION.md": __fd_glob_50, "security/GUARDRAILS.md": __fd_glob_51, "security/PUBLIC_CREDS.md": __fd_glob_52, "security/ROUTE_GUARD_TIERS.md": __fd_glob_53, "security/SOCKET_DEV_FINDINGS.md": __fd_glob_54, "security/STEALTH_GUIDE.md": __fd_glob_55, }); \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a114d775a2..239006f3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### ✨ New Features + +- **notion:** add Notion as an MCP context source — 6 tools (`notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read`, `notion_append_blocks`) scoped under `read:notion` / `write:notion`, with dashboard "Context Sources" tab, settings API, and token persistence in `key_value` table (#2959) + +### 🔧 Bug Fixes + +- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958) + --- ## [3.8.7] — 2026-05-29 diff --git a/docs/frameworks/MCP-SERVER.md b/docs/frameworks/MCP-SERVER.md index 1dae671b76..3c7b0f51fb 100644 --- a/docs/frameworks/MCP-SERVER.md +++ b/docs/frameworks/MCP-SERVER.md @@ -1,18 +1,18 @@ --- title: "OmniRoute MCP Server Documentation" -version: 3.8.2 -lastUpdated: 2026-05-13 +version: 3.8.8 +lastUpdated: 2026-05-30 --- # OmniRoute MCP Server Documentation -> Model Context Protocol server with 37 tools across routing, cache, compression, memory, skills, and proxy operations. +> Model Context Protocol server with 43 tools across routing, cache, compression, memory, skills, proxy, and context source operations. > -> Source of truth: `open-sse/mcp-server/schemas/tools.ts` (30 tools) + `open-sse/mcp-server/tools/memoryTools.ts` (3 tools) + `open-sse/mcp-server/tools/skillTools.ts` (4 tools). Tool registration and scope wiring lives in `open-sse/mcp-server/server.ts`. +> Source of truth: `open-sse/mcp-server/schemas/tools.ts` (30 tools) + `open-sse/mcp-server/tools/memoryTools.ts` (3 tools) + `open-sse/mcp-server/tools/skillTools.ts` (4 tools) + `open-sse/mcp-server/tools/notionTools.ts` (6 tools). Tool registration and scope wiring lives in `open-sse/mcp-server/server.ts`. -![MCP tool inventory (37 tools by category)](../diagrams/exported/mcp-tools-37.svg) +![MCP tool inventory (43 tools by category)](../diagrams/exported/mcp-tools-43.svg) -> Source: [diagrams/mcp-tools-37.mmd](../diagrams/mcp-tools-37.mmd) +> Source: [diagrams/mcp-tools-43.mmd](../diagrams/mcp-tools-43.mmd) (update from `mcp-tools-37` when regenerating) ## Installation @@ -158,27 +158,55 @@ the runtime compression model behind these tools. Defined in `open-sse/mcp-server/tools/memoryTools.ts`. Auth/scope is enforced through the standard MCP scope pipeline. -| Tool | Description | -| :------------------------ | :---------------------------------------------------------------------------------- | -| `omniroute_memory_search` | Search memories by query / type / API key with token-budget enforcement | -| `omniroute_memory_add` | Add a new memory entry (`factual` / `episodic` / `procedural` / `semantic`) | -| `omniroute_memory_clear` | Clear memories for an API key, optionally filtered by type or `olderThan` timestamp | +| Tool | Scopes | Description | +| :------------------------ | :--------------- | :---------------------------------------------------------------------------------- | +| `omniroute_memory_search` | `read:memory` | Search memories by query / type / API key with token-budget enforcement | +| `omniroute_memory_add` | `write:memory` | Add a new memory entry (`factual` / `episodic` / `procedural` / `semantic`) | +| `omniroute_memory_clear` | `write:memory` | Clear memories for an API key, optionally filtered by type or `olderThan` timestamp | ## Skill Tools (4) Defined in `open-sse/mcp-server/tools/skillTools.ts`. Backed by `src/lib/skills/registry` + `src/lib/skills/executor`. -| Tool | Description | -| :---------------------------- | :-------------------------------------------------------------------------------- | -| `omniroute_skills_list` | List registered skills with optional filtering by API key, name, or enabled state | -| `omniroute_skills_enable` | Enable or disable a specific skill by ID | -| `omniroute_skills_execute` | Execute a skill with provided input and return the execution record | -| `omniroute_skills_executions` | List recent skill execution history | +| Tool | Scopes | Description | +| :---------------------------- | :-------------- | :-------------------------------------------------------------------------------- | +| `omniroute_skills_list` | `read:skills` | List registered skills with optional filtering by API key, name, or enabled state | +| `omniroute_skills_enable` | `write:skills` | Enable or disable a specific skill by ID | +| `omniroute_skills_execute` | `execute:skills`| Execute a skill with provided input and return the execution record | +| `omniroute_skills_executions` | `read:skills` | List recent skill execution history | + +## Notion Context Source (6) + +Defined in `open-sse/mcp-server/tools/notionTools.ts`. Token stored in `key_value` table via `src/lib/db/notion.ts`. REST client in `src/lib/notion/api.ts`. Settings API in `src/app/api/settings/notion/route.ts`. Dashboard UI in `src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx`. + +Configure your Notion integration token from the **Context Sources** tab in the Endpoint dashboard, or via the REST API: + +```bash +# Set token +curl -X POST http://localhost:20128/api/settings/notion \ + -H "Content-Type: application/json" \ + -d '{"token": "ntn_..."}' + +# Check status +curl http://localhost:20128/api/settings/notion + +# Disconnect +curl -X DELETE http://localhost:20128/api/settings/notion +``` + +| Tool | Scopes | Description | +| :--------------------------- | :--------------- | :------------------------------------------------------------------------------------ | +| `omniroute_notion_search` | `read:notion` | Full-text search across all pages and databases | +| `omniroute_notion_list_databases` | `read:notion` | List all accessible databases with schema metadata | +| `omniroute_notion_get_database` | `read:notion` | Get database schema by ID | +| `omniroute_notion_query_database` | `read:notion` | Query a database with filters, sorts, and pagination | +| `omniroute_notion_read` | `read:notion` | Read a page or block by ID with its content | +| `omniroute_notion_append_blocks` | `write:notion`| Append children blocks to a parent block (max 100 per request) | ## Related Frameworks (v3.8.0) -The MCP tool inventory above (37 tools = 30 base + 3 memory + 4 skills) is intentionally -scoped to runtime routing/cache/compression/memory/skills/proxy operations. Two adjacent +The MCP tool inventory above (43 tools = 30 core + 3 memory + 4 skills + 6 notion) is intentionally +scoped to runtime routing/cache/compression/memory/skills/proxy/context-source operations. Two adjacent frameworks ship alongside the MCP server in v3.8.0 and are documented separately: ### Cloud Agents @@ -247,11 +275,16 @@ MCP tools are authenticated through API key scopes. Scope enforcement is central | `read:compression` | `compression_status`, `list_compression_combos`, `compression_combo_stats` | | `write:compression` | `compression_configure`, `set_compression_engine` | | `read:proxies` | `oneproxy_fetch`, `oneproxy_rotate`, `oneproxy_stats` | +| `read:notion` | `notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read` | +| `write:notion` | `notion_append_blocks` | +| `read:memory` | `memory_search` | +| `write:memory` | `memory_add`, `memory_clear` | +| `read:skills` | `skills_list`, `skills_executions` | +| `write:skills` | `skills_enable` | +| `execute:skills` | `skills_execute` | Wildcard scopes are supported: `read:*` grants all read-scopes, `*` grants full access. -Memory and Skill tools currently do not declare static scope requirements in their definitions; access is gated by the caller's API key and audited through the standard MCP audit pipeline. - --- ## Environment Variables @@ -294,7 +327,7 @@ The heartbeat snapshot contains: "transport": "stdio", "scopesEnforced": false, "allowedScopes": [], - "toolCount": 37 + "toolCount": 43 } ``` @@ -328,9 +361,19 @@ Use the dashboard or the `/api/mcp/audit` and `/api/mcp/audit/stats` REST endpoi | `open-sse/mcp-server/tools/compressionTools.ts` | Compression tool handlers | | `open-sse/mcp-server/tools/memoryTools.ts` | Memory tool definitions (3 tools) | | `open-sse/mcp-server/tools/skillTools.ts` | Skill tool definitions (4 tools) | +| `open-sse/mcp-server/tools/notionTools.ts` | Notion context source tool definitions (6 tools) | +| `open-sse/mcp-server/tools/gamificationTools.ts`| Gamification tool definitions (8 tools) | +| `open-sse/mcp-server/tools/pluginTools.ts` | Plugin registration and management tools (8 tools) | | `src/app/api/mcp/status/route.ts` | `/api/mcp/status` endpoint | | `src/app/api/mcp/tools/route.ts` | `/api/mcp/tools` endpoint | | `src/app/api/mcp/sse/route.ts` | `/api/mcp/sse` SSE transport route | | `src/app/api/mcp/stream/route.ts` | `/api/mcp/stream` Streamable HTTP transport route | | `src/app/api/mcp/audit/route.ts` | `/api/mcp/audit` audit log query | | `src/app/api/mcp/audit/stats/route.ts` | `/api/mcp/audit/stats` aggregated audit metrics | +| `src/lib/notion/api.ts` | Notion REST API client (retry, timeout, error classification) | +| `src/lib/db/notion.ts` | Notion token persistence (`key_value` table) | +| `src/app/api/settings/notion/route.ts` | Notion settings API (GET/POST/DELETE) | +| `src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx` | Notion token management UI | +| `tests/unit/notion-api.test.ts` | Notion API client tests (7) | +| `tests/unit/notion-tools.test.ts` | Notion tools scope enforcement tests (10) | +| `tests/unit/db/notion.test.mjs` | Notion DB module tests (3) | From 8dff29c760a718eeeb5e49239dd94aab24d35faf Mon Sep 17 00:00:00 2001 From: Brandon Bennett Date: Sat, 30 May 2026 19:33:37 -0400 Subject: [PATCH 35/82] docs: update CHANGELOG and MCP-SERVER.md for scope fix and Notion context source --- .source/browser.ts | 2 +- .source/server.ts | 58 +++++++++++------------ CHANGELOG.md | 8 ++++ docs/frameworks/MCP-SERVER.md | 87 ++++++++++++++++++++++++++--------- 4 files changed, 103 insertions(+), 52 deletions(-) diff --git a/.source/browser.ts b/.source/browser.ts index ce5576f0bb..006dbe3f12 100644 --- a/.source/browser.ts +++ b/.source/browser.ts @@ -7,6 +7,6 @@ const create = browser(); const browserCollections = { - docs: create.doc("docs", {"architecture/ARCHITECTURE.md": () => import("../docs/architecture/ARCHITECTURE.md?collection=docs"), "architecture/AUTHZ_GUIDE.md": () => import("../docs/architecture/AUTHZ_GUIDE.md?collection=docs"), "architecture/CODEBASE_DOCUMENTATION.md": () => import("../docs/architecture/CODEBASE_DOCUMENTATION.md?collection=docs"), "architecture/REPOSITORY_MAP.md": () => import("../docs/architecture/REPOSITORY_MAP.md?collection=docs"), "architecture/RESILIENCE_GUIDE.md": () => import("../docs/architecture/RESILIENCE_GUIDE.md?collection=docs"), "compression/COMPRESSION_ENGINES.md": () => import("../docs/compression/COMPRESSION_ENGINES.md?collection=docs"), "compression/COMPRESSION_GUIDE.md": () => import("../docs/compression/COMPRESSION_GUIDE.md?collection=docs"), "compression/COMPRESSION_LANGUAGE_PACKS.md": () => import("../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs"), "compression/COMPRESSION_RULES_FORMAT.md": () => import("../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs"), "compression/RTK_COMPRESSION.md": () => import("../docs/compression/RTK_COMPRESSION.md?collection=docs"), "guides/DOCKER_GUIDE.md": () => import("../docs/guides/DOCKER_GUIDE.md?collection=docs"), "guides/ELECTRON_GUIDE.md": () => import("../docs/guides/ELECTRON_GUIDE.md?collection=docs"), "guides/FEATURES.md": () => import("../docs/guides/FEATURES.md?collection=docs"), "guides/I18N.md": () => import("../docs/guides/I18N.md?collection=docs"), "guides/KIRO_SETUP.md": () => import("../docs/guides/KIRO_SETUP.md?collection=docs"), "guides/PWA_GUIDE.md": () => import("../docs/guides/PWA_GUIDE.md?collection=docs"), "guides/SETUP_GUIDE.md": () => import("../docs/guides/SETUP_GUIDE.md?collection=docs"), "guides/TERMUX_GUIDE.md": () => import("../docs/guides/TERMUX_GUIDE.md?collection=docs"), "guides/TROUBLESHOOTING.md": () => import("../docs/guides/TROUBLESHOOTING.md?collection=docs"), "guides/UNINSTALL.md": () => import("../docs/guides/UNINSTALL.md?collection=docs"), "guides/USER_GUIDE.md": () => import("../docs/guides/USER_GUIDE.md?collection=docs"), "frameworks/A2A-SERVER.md": () => import("../docs/frameworks/A2A-SERVER.md?collection=docs"), "frameworks/AGENT_PROTOCOLS_GUIDE.md": () => import("../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs"), "frameworks/CLOUD_AGENT.md": () => import("../docs/frameworks/CLOUD_AGENT.md?collection=docs"), "frameworks/EMBEDDED-SERVICES.md": () => import("../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs"), "frameworks/EVALS.md": () => import("../docs/frameworks/EVALS.md?collection=docs"), "frameworks/GAMIFICATION.md": () => import("../docs/frameworks/GAMIFICATION.md?collection=docs"), "frameworks/MCP-SERVER.md": () => import("../docs/frameworks/MCP-SERVER.md?collection=docs"), "frameworks/MEMORY.md": () => import("../docs/frameworks/MEMORY.md?collection=docs"), "frameworks/OPENCODE.md": () => import("../docs/frameworks/OPENCODE.md?collection=docs"), "frameworks/SKILLS.md": () => import("../docs/frameworks/SKILLS.md?collection=docs"), "frameworks/WEBHOOKS.md": () => import("../docs/frameworks/WEBHOOKS.md?collection=docs"), "ops/COVERAGE_PLAN.md": () => import("../docs/ops/COVERAGE_PLAN.md?collection=docs"), "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": () => import("../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs"), "ops/FLY_IO_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs"), "ops/PROXY_GUIDE.md": () => import("../docs/ops/PROXY_GUIDE.md?collection=docs"), "ops/RELEASE_CHECKLIST.md": () => import("../docs/ops/RELEASE_CHECKLIST.md?collection=docs"), "ops/SQLITE_RUNTIME.md": () => import("../docs/ops/SQLITE_RUNTIME.md?collection=docs"), "ops/TUNNELS_GUIDE.md": () => import("../docs/ops/TUNNELS_GUIDE.md?collection=docs"), "ops/VM_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/VM_DEPLOYMENT_GUIDE.md?collection=docs"), "reference/API_REFERENCE.md": () => import("../docs/reference/API_REFERENCE.md?collection=docs"), "reference/CLI-TOOLS.md": () => import("../docs/reference/CLI-TOOLS.md?collection=docs"), "reference/ENVIRONMENT.md": () => import("../docs/reference/ENVIRONMENT.md?collection=docs"), "reference/FREE_TIERS.md": () => import("../docs/reference/FREE_TIERS.md?collection=docs"), "reference/PROVIDER_REFERENCE.md": () => import("../docs/reference/PROVIDER_REFERENCE.md?collection=docs"), "routing/AUTO-COMBO.md": () => import("../docs/routing/AUTO-COMBO.md?collection=docs"), "routing/REASONING_REPLAY.md": () => import("../docs/routing/REASONING_REPLAY.md?collection=docs"), "security/CLI_TOKEN.md": () => import("../docs/security/CLI_TOKEN.md?collection=docs"), "security/CLI_TOKEN_AUTH.md": () => import("../docs/security/CLI_TOKEN_AUTH.md?collection=docs"), "security/COMPLIANCE.md": () => import("../docs/security/COMPLIANCE.md?collection=docs"), "security/ERROR_SANITIZATION.md": () => import("../docs/security/ERROR_SANITIZATION.md?collection=docs"), "security/GUARDRAILS.md": () => import("../docs/security/GUARDRAILS.md?collection=docs"), "security/PUBLIC_CREDS.md": () => import("../docs/security/PUBLIC_CREDS.md?collection=docs"), "security/ROUTE_GUARD_TIERS.md": () => import("../docs/security/ROUTE_GUARD_TIERS.md?collection=docs"), "security/SOCKET_DEV_FINDINGS.md": () => import("../docs/security/SOCKET_DEV_FINDINGS.md?collection=docs"), "security/STEALTH_GUIDE.md": () => import("../docs/security/STEALTH_GUIDE.md?collection=docs"), }), + docs: create.doc("docs", {"architecture/ARCHITECTURE.md": () => import("../docs/architecture/ARCHITECTURE.md?collection=docs"), "architecture/AUTHZ_GUIDE.md": () => import("../docs/architecture/AUTHZ_GUIDE.md?collection=docs"), "architecture/CODEBASE_DOCUMENTATION.md": () => import("../docs/architecture/CODEBASE_DOCUMENTATION.md?collection=docs"), "architecture/REPOSITORY_MAP.md": () => import("../docs/architecture/REPOSITORY_MAP.md?collection=docs"), "architecture/RESILIENCE_GUIDE.md": () => import("../docs/architecture/RESILIENCE_GUIDE.md?collection=docs"), "compression/COMPRESSION_ENGINES.md": () => import("../docs/compression/COMPRESSION_ENGINES.md?collection=docs"), "compression/COMPRESSION_GUIDE.md": () => import("../docs/compression/COMPRESSION_GUIDE.md?collection=docs"), "compression/COMPRESSION_LANGUAGE_PACKS.md": () => import("../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs"), "compression/COMPRESSION_RULES_FORMAT.md": () => import("../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs"), "compression/RTK_COMPRESSION.md": () => import("../docs/compression/RTK_COMPRESSION.md?collection=docs"), "frameworks/A2A-SERVER.md": () => import("../docs/frameworks/A2A-SERVER.md?collection=docs"), "frameworks/AGENT_PROTOCOLS_GUIDE.md": () => import("../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs"), "frameworks/CLOUD_AGENT.md": () => import("../docs/frameworks/CLOUD_AGENT.md?collection=docs"), "frameworks/EMBEDDED-SERVICES.md": () => import("../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs"), "frameworks/EVALS.md": () => import("../docs/frameworks/EVALS.md?collection=docs"), "frameworks/GAMIFICATION.md": () => import("../docs/frameworks/GAMIFICATION.md?collection=docs"), "frameworks/MCP-SERVER.md": () => import("../docs/frameworks/MCP-SERVER.md?collection=docs"), "frameworks/MEMORY.md": () => import("../docs/frameworks/MEMORY.md?collection=docs"), "frameworks/OPENCODE.md": () => import("../docs/frameworks/OPENCODE.md?collection=docs"), "frameworks/SKILLS.md": () => import("../docs/frameworks/SKILLS.md?collection=docs"), "frameworks/WEBHOOKS.md": () => import("../docs/frameworks/WEBHOOKS.md?collection=docs"), "guides/DOCKER_GUIDE.md": () => import("../docs/guides/DOCKER_GUIDE.md?collection=docs"), "guides/ELECTRON_GUIDE.md": () => import("../docs/guides/ELECTRON_GUIDE.md?collection=docs"), "guides/FEATURES.md": () => import("../docs/guides/FEATURES.md?collection=docs"), "guides/I18N.md": () => import("../docs/guides/I18N.md?collection=docs"), "guides/KIRO_SETUP.md": () => import("../docs/guides/KIRO_SETUP.md?collection=docs"), "guides/PWA_GUIDE.md": () => import("../docs/guides/PWA_GUIDE.md?collection=docs"), "guides/SETUP_GUIDE.md": () => import("../docs/guides/SETUP_GUIDE.md?collection=docs"), "guides/TERMUX_GUIDE.md": () => import("../docs/guides/TERMUX_GUIDE.md?collection=docs"), "guides/TROUBLESHOOTING.md": () => import("../docs/guides/TROUBLESHOOTING.md?collection=docs"), "guides/UNINSTALL.md": () => import("../docs/guides/UNINSTALL.md?collection=docs"), "guides/USER_GUIDE.md": () => import("../docs/guides/USER_GUIDE.md?collection=docs"), "ops/COVERAGE_PLAN.md": () => import("../docs/ops/COVERAGE_PLAN.md?collection=docs"), "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": () => import("../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs"), "ops/FLY_IO_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs"), "ops/PROXY_GUIDE.md": () => import("../docs/ops/PROXY_GUIDE.md?collection=docs"), "ops/RELEASE_CHECKLIST.md": () => import("../docs/ops/RELEASE_CHECKLIST.md?collection=docs"), "ops/SQLITE_RUNTIME.md": () => import("../docs/ops/SQLITE_RUNTIME.md?collection=docs"), "ops/TUNNELS_GUIDE.md": () => import("../docs/ops/TUNNELS_GUIDE.md?collection=docs"), "ops/VM_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/VM_DEPLOYMENT_GUIDE.md?collection=docs"), "reference/API_REFERENCE.md": () => import("../docs/reference/API_REFERENCE.md?collection=docs"), "reference/CLI-TOOLS.md": () => import("../docs/reference/CLI-TOOLS.md?collection=docs"), "reference/ENVIRONMENT.md": () => import("../docs/reference/ENVIRONMENT.md?collection=docs"), "reference/FREE_TIERS.md": () => import("../docs/reference/FREE_TIERS.md?collection=docs"), "reference/PROVIDER_REFERENCE.md": () => import("../docs/reference/PROVIDER_REFERENCE.md?collection=docs"), "routing/AUTO-COMBO.md": () => import("../docs/routing/AUTO-COMBO.md?collection=docs"), "routing/REASONING_REPLAY.md": () => import("../docs/routing/REASONING_REPLAY.md?collection=docs"), "security/CLI_TOKEN.md": () => import("../docs/security/CLI_TOKEN.md?collection=docs"), "security/CLI_TOKEN_AUTH.md": () => import("../docs/security/CLI_TOKEN_AUTH.md?collection=docs"), "security/COMPLIANCE.md": () => import("../docs/security/COMPLIANCE.md?collection=docs"), "security/ERROR_SANITIZATION.md": () => import("../docs/security/ERROR_SANITIZATION.md?collection=docs"), "security/GUARDRAILS.md": () => import("../docs/security/GUARDRAILS.md?collection=docs"), "security/PUBLIC_CREDS.md": () => import("../docs/security/PUBLIC_CREDS.md?collection=docs"), "security/ROUTE_GUARD_TIERS.md": () => import("../docs/security/ROUTE_GUARD_TIERS.md?collection=docs"), "security/SOCKET_DEV_FINDINGS.md": () => import("../docs/security/SOCKET_DEV_FINDINGS.md?collection=docs"), "security/STEALTH_GUIDE.md": () => import("../docs/security/STEALTH_GUIDE.md?collection=docs"), }), }; export default browserCollections; \ No newline at end of file diff --git a/.source/server.ts b/.source/server.ts index b7e781f56b..9debc21e3a 100644 --- a/.source/server.ts +++ b/.source/server.ts @@ -1,10 +1,10 @@ // @ts-nocheck -import { default as __fd_glob_65 } from "../docs/security/meta.json?collection=docs" -import { default as __fd_glob_64 } from "../docs/routing/meta.json?collection=docs" -import { default as __fd_glob_63 } from "../docs/reference/openapi.yaml?collection=docs" -import { default as __fd_glob_62 } from "../docs/reference/meta.json?collection=docs" -import { default as __fd_glob_61 } from "../docs/ops/meta.json?collection=docs" -import { default as __fd_glob_60 } from "../docs/guides/meta.json?collection=docs" +import { default as __fd_glob_65 } from "../docs/reference/openapi.yaml?collection=docs" +import { default as __fd_glob_64 } from "../docs/reference/meta.json?collection=docs" +import { default as __fd_glob_63 } from "../docs/security/meta.json?collection=docs" +import { default as __fd_glob_62 } from "../docs/routing/meta.json?collection=docs" +import { default as __fd_glob_61 } from "../docs/guides/meta.json?collection=docs" +import { default as __fd_glob_60 } from "../docs/ops/meta.json?collection=docs" import { default as __fd_glob_59 } from "../docs/frameworks/meta.json?collection=docs" import { default as __fd_glob_58 } from "../docs/compression/meta.json?collection=docs" import { default as __fd_glob_57 } from "../docs/architecture/meta.json?collection=docs" @@ -33,28 +33,28 @@ import * as __fd_glob_35 from "../docs/ops/PROXY_GUIDE.md?collection=docs" import * as __fd_glob_34 from "../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs" import * as __fd_glob_33 from "../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs" import * as __fd_glob_32 from "../docs/ops/COVERAGE_PLAN.md?collection=docs" -import * as __fd_glob_31 from "../docs/frameworks/WEBHOOKS.md?collection=docs" -import * as __fd_glob_30 from "../docs/frameworks/SKILLS.md?collection=docs" -import * as __fd_glob_29 from "../docs/frameworks/OPENCODE.md?collection=docs" -import * as __fd_glob_28 from "../docs/frameworks/MEMORY.md?collection=docs" -import * as __fd_glob_27 from "../docs/frameworks/MCP-SERVER.md?collection=docs" -import * as __fd_glob_26 from "../docs/frameworks/GAMIFICATION.md?collection=docs" -import * as __fd_glob_25 from "../docs/frameworks/EVALS.md?collection=docs" -import * as __fd_glob_24 from "../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs" -import * as __fd_glob_23 from "../docs/frameworks/CLOUD_AGENT.md?collection=docs" -import * as __fd_glob_22 from "../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs" -import * as __fd_glob_21 from "../docs/frameworks/A2A-SERVER.md?collection=docs" -import * as __fd_glob_20 from "../docs/guides/USER_GUIDE.md?collection=docs" -import * as __fd_glob_19 from "../docs/guides/UNINSTALL.md?collection=docs" -import * as __fd_glob_18 from "../docs/guides/TROUBLESHOOTING.md?collection=docs" -import * as __fd_glob_17 from "../docs/guides/TERMUX_GUIDE.md?collection=docs" -import * as __fd_glob_16 from "../docs/guides/SETUP_GUIDE.md?collection=docs" -import * as __fd_glob_15 from "../docs/guides/PWA_GUIDE.md?collection=docs" -import * as __fd_glob_14 from "../docs/guides/KIRO_SETUP.md?collection=docs" -import * as __fd_glob_13 from "../docs/guides/I18N.md?collection=docs" -import * as __fd_glob_12 from "../docs/guides/FEATURES.md?collection=docs" -import * as __fd_glob_11 from "../docs/guides/ELECTRON_GUIDE.md?collection=docs" -import * as __fd_glob_10 from "../docs/guides/DOCKER_GUIDE.md?collection=docs" +import * as __fd_glob_31 from "../docs/guides/USER_GUIDE.md?collection=docs" +import * as __fd_glob_30 from "../docs/guides/UNINSTALL.md?collection=docs" +import * as __fd_glob_29 from "../docs/guides/TROUBLESHOOTING.md?collection=docs" +import * as __fd_glob_28 from "../docs/guides/TERMUX_GUIDE.md?collection=docs" +import * as __fd_glob_27 from "../docs/guides/SETUP_GUIDE.md?collection=docs" +import * as __fd_glob_26 from "../docs/guides/PWA_GUIDE.md?collection=docs" +import * as __fd_glob_25 from "../docs/guides/KIRO_SETUP.md?collection=docs" +import * as __fd_glob_24 from "../docs/guides/I18N.md?collection=docs" +import * as __fd_glob_23 from "../docs/guides/FEATURES.md?collection=docs" +import * as __fd_glob_22 from "../docs/guides/ELECTRON_GUIDE.md?collection=docs" +import * as __fd_glob_21 from "../docs/guides/DOCKER_GUIDE.md?collection=docs" +import * as __fd_glob_20 from "../docs/frameworks/WEBHOOKS.md?collection=docs" +import * as __fd_glob_19 from "../docs/frameworks/SKILLS.md?collection=docs" +import * as __fd_glob_18 from "../docs/frameworks/OPENCODE.md?collection=docs" +import * as __fd_glob_17 from "../docs/frameworks/MEMORY.md?collection=docs" +import * as __fd_glob_16 from "../docs/frameworks/MCP-SERVER.md?collection=docs" +import * as __fd_glob_15 from "../docs/frameworks/GAMIFICATION.md?collection=docs" +import * as __fd_glob_14 from "../docs/frameworks/EVALS.md?collection=docs" +import * as __fd_glob_13 from "../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs" +import * as __fd_glob_12 from "../docs/frameworks/CLOUD_AGENT.md?collection=docs" +import * as __fd_glob_11 from "../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs" +import * as __fd_glob_10 from "../docs/frameworks/A2A-SERVER.md?collection=docs" import * as __fd_glob_9 from "../docs/compression/RTK_COMPRESSION.md?collection=docs" import * as __fd_glob_8 from "../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs" import * as __fd_glob_7 from "../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs" @@ -73,4 +73,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}}); -export const docs = await create.docs("docs", "docs", {"meta.json": __fd_glob_56, "architecture/meta.json": __fd_glob_57, "compression/meta.json": __fd_glob_58, "frameworks/meta.json": __fd_glob_59, "guides/meta.json": __fd_glob_60, "ops/meta.json": __fd_glob_61, "reference/meta.json": __fd_glob_62, "reference/openapi.yaml": __fd_glob_63, "routing/meta.json": __fd_glob_64, "security/meta.json": __fd_glob_65, }, {"architecture/ARCHITECTURE.md": __fd_glob_0, "architecture/AUTHZ_GUIDE.md": __fd_glob_1, "architecture/CODEBASE_DOCUMENTATION.md": __fd_glob_2, "architecture/REPOSITORY_MAP.md": __fd_glob_3, "architecture/RESILIENCE_GUIDE.md": __fd_glob_4, "compression/COMPRESSION_ENGINES.md": __fd_glob_5, "compression/COMPRESSION_GUIDE.md": __fd_glob_6, "compression/COMPRESSION_LANGUAGE_PACKS.md": __fd_glob_7, "compression/COMPRESSION_RULES_FORMAT.md": __fd_glob_8, "compression/RTK_COMPRESSION.md": __fd_glob_9, "guides/DOCKER_GUIDE.md": __fd_glob_10, "guides/ELECTRON_GUIDE.md": __fd_glob_11, "guides/FEATURES.md": __fd_glob_12, "guides/I18N.md": __fd_glob_13, "guides/KIRO_SETUP.md": __fd_glob_14, "guides/PWA_GUIDE.md": __fd_glob_15, "guides/SETUP_GUIDE.md": __fd_glob_16, "guides/TERMUX_GUIDE.md": __fd_glob_17, "guides/TROUBLESHOOTING.md": __fd_glob_18, "guides/UNINSTALL.md": __fd_glob_19, "guides/USER_GUIDE.md": __fd_glob_20, "frameworks/A2A-SERVER.md": __fd_glob_21, "frameworks/AGENT_PROTOCOLS_GUIDE.md": __fd_glob_22, "frameworks/CLOUD_AGENT.md": __fd_glob_23, "frameworks/EMBEDDED-SERVICES.md": __fd_glob_24, "frameworks/EVALS.md": __fd_glob_25, "frameworks/GAMIFICATION.md": __fd_glob_26, "frameworks/MCP-SERVER.md": __fd_glob_27, "frameworks/MEMORY.md": __fd_glob_28, "frameworks/OPENCODE.md": __fd_glob_29, "frameworks/SKILLS.md": __fd_glob_30, "frameworks/WEBHOOKS.md": __fd_glob_31, "ops/COVERAGE_PLAN.md": __fd_glob_32, "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": __fd_glob_33, "ops/FLY_IO_DEPLOYMENT_GUIDE.md": __fd_glob_34, "ops/PROXY_GUIDE.md": __fd_glob_35, "ops/RELEASE_CHECKLIST.md": __fd_glob_36, "ops/SQLITE_RUNTIME.md": __fd_glob_37, "ops/TUNNELS_GUIDE.md": __fd_glob_38, "ops/VM_DEPLOYMENT_GUIDE.md": __fd_glob_39, "reference/API_REFERENCE.md": __fd_glob_40, "reference/CLI-TOOLS.md": __fd_glob_41, "reference/ENVIRONMENT.md": __fd_glob_42, "reference/FREE_TIERS.md": __fd_glob_43, "reference/PROVIDER_REFERENCE.md": __fd_glob_44, "routing/AUTO-COMBO.md": __fd_glob_45, "routing/REASONING_REPLAY.md": __fd_glob_46, "security/CLI_TOKEN.md": __fd_glob_47, "security/CLI_TOKEN_AUTH.md": __fd_glob_48, "security/COMPLIANCE.md": __fd_glob_49, "security/ERROR_SANITIZATION.md": __fd_glob_50, "security/GUARDRAILS.md": __fd_glob_51, "security/PUBLIC_CREDS.md": __fd_glob_52, "security/ROUTE_GUARD_TIERS.md": __fd_glob_53, "security/SOCKET_DEV_FINDINGS.md": __fd_glob_54, "security/STEALTH_GUIDE.md": __fd_glob_55, }); \ No newline at end of file +export const docs = await create.docs("docs", "docs", {"meta.json": __fd_glob_56, "architecture/meta.json": __fd_glob_57, "compression/meta.json": __fd_glob_58, "frameworks/meta.json": __fd_glob_59, "ops/meta.json": __fd_glob_60, "guides/meta.json": __fd_glob_61, "routing/meta.json": __fd_glob_62, "security/meta.json": __fd_glob_63, "reference/meta.json": __fd_glob_64, "reference/openapi.yaml": __fd_glob_65, }, {"architecture/ARCHITECTURE.md": __fd_glob_0, "architecture/AUTHZ_GUIDE.md": __fd_glob_1, "architecture/CODEBASE_DOCUMENTATION.md": __fd_glob_2, "architecture/REPOSITORY_MAP.md": __fd_glob_3, "architecture/RESILIENCE_GUIDE.md": __fd_glob_4, "compression/COMPRESSION_ENGINES.md": __fd_glob_5, "compression/COMPRESSION_GUIDE.md": __fd_glob_6, "compression/COMPRESSION_LANGUAGE_PACKS.md": __fd_glob_7, "compression/COMPRESSION_RULES_FORMAT.md": __fd_glob_8, "compression/RTK_COMPRESSION.md": __fd_glob_9, "frameworks/A2A-SERVER.md": __fd_glob_10, "frameworks/AGENT_PROTOCOLS_GUIDE.md": __fd_glob_11, "frameworks/CLOUD_AGENT.md": __fd_glob_12, "frameworks/EMBEDDED-SERVICES.md": __fd_glob_13, "frameworks/EVALS.md": __fd_glob_14, "frameworks/GAMIFICATION.md": __fd_glob_15, "frameworks/MCP-SERVER.md": __fd_glob_16, "frameworks/MEMORY.md": __fd_glob_17, "frameworks/OPENCODE.md": __fd_glob_18, "frameworks/SKILLS.md": __fd_glob_19, "frameworks/WEBHOOKS.md": __fd_glob_20, "guides/DOCKER_GUIDE.md": __fd_glob_21, "guides/ELECTRON_GUIDE.md": __fd_glob_22, "guides/FEATURES.md": __fd_glob_23, "guides/I18N.md": __fd_glob_24, "guides/KIRO_SETUP.md": __fd_glob_25, "guides/PWA_GUIDE.md": __fd_glob_26, "guides/SETUP_GUIDE.md": __fd_glob_27, "guides/TERMUX_GUIDE.md": __fd_glob_28, "guides/TROUBLESHOOTING.md": __fd_glob_29, "guides/UNINSTALL.md": __fd_glob_30, "guides/USER_GUIDE.md": __fd_glob_31, "ops/COVERAGE_PLAN.md": __fd_glob_32, "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": __fd_glob_33, "ops/FLY_IO_DEPLOYMENT_GUIDE.md": __fd_glob_34, "ops/PROXY_GUIDE.md": __fd_glob_35, "ops/RELEASE_CHECKLIST.md": __fd_glob_36, "ops/SQLITE_RUNTIME.md": __fd_glob_37, "ops/TUNNELS_GUIDE.md": __fd_glob_38, "ops/VM_DEPLOYMENT_GUIDE.md": __fd_glob_39, "reference/API_REFERENCE.md": __fd_glob_40, "reference/CLI-TOOLS.md": __fd_glob_41, "reference/ENVIRONMENT.md": __fd_glob_42, "reference/FREE_TIERS.md": __fd_glob_43, "reference/PROVIDER_REFERENCE.md": __fd_glob_44, "routing/AUTO-COMBO.md": __fd_glob_45, "routing/REASONING_REPLAY.md": __fd_glob_46, "security/CLI_TOKEN.md": __fd_glob_47, "security/CLI_TOKEN_AUTH.md": __fd_glob_48, "security/COMPLIANCE.md": __fd_glob_49, "security/ERROR_SANITIZATION.md": __fd_glob_50, "security/GUARDRAILS.md": __fd_glob_51, "security/PUBLIC_CREDS.md": __fd_glob_52, "security/ROUTE_GUARD_TIERS.md": __fd_glob_53, "security/SOCKET_DEV_FINDINGS.md": __fd_glob_54, "security/STEALTH_GUIDE.md": __fd_glob_55, }); \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a114d775a2..239006f3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### ✨ New Features + +- **notion:** add Notion as an MCP context source — 6 tools (`notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read`, `notion_append_blocks`) scoped under `read:notion` / `write:notion`, with dashboard "Context Sources" tab, settings API, and token persistence in `key_value` table (#2959) + +### 🔧 Bug Fixes + +- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958) + --- ## [3.8.7] — 2026-05-29 diff --git a/docs/frameworks/MCP-SERVER.md b/docs/frameworks/MCP-SERVER.md index 1dae671b76..3c7b0f51fb 100644 --- a/docs/frameworks/MCP-SERVER.md +++ b/docs/frameworks/MCP-SERVER.md @@ -1,18 +1,18 @@ --- title: "OmniRoute MCP Server Documentation" -version: 3.8.2 -lastUpdated: 2026-05-13 +version: 3.8.8 +lastUpdated: 2026-05-30 --- # OmniRoute MCP Server Documentation -> Model Context Protocol server with 37 tools across routing, cache, compression, memory, skills, and proxy operations. +> Model Context Protocol server with 43 tools across routing, cache, compression, memory, skills, proxy, and context source operations. > -> Source of truth: `open-sse/mcp-server/schemas/tools.ts` (30 tools) + `open-sse/mcp-server/tools/memoryTools.ts` (3 tools) + `open-sse/mcp-server/tools/skillTools.ts` (4 tools). Tool registration and scope wiring lives in `open-sse/mcp-server/server.ts`. +> Source of truth: `open-sse/mcp-server/schemas/tools.ts` (30 tools) + `open-sse/mcp-server/tools/memoryTools.ts` (3 tools) + `open-sse/mcp-server/tools/skillTools.ts` (4 tools) + `open-sse/mcp-server/tools/notionTools.ts` (6 tools). Tool registration and scope wiring lives in `open-sse/mcp-server/server.ts`. -![MCP tool inventory (37 tools by category)](../diagrams/exported/mcp-tools-37.svg) +![MCP tool inventory (43 tools by category)](../diagrams/exported/mcp-tools-43.svg) -> Source: [diagrams/mcp-tools-37.mmd](../diagrams/mcp-tools-37.mmd) +> Source: [diagrams/mcp-tools-43.mmd](../diagrams/mcp-tools-43.mmd) (update from `mcp-tools-37` when regenerating) ## Installation @@ -158,27 +158,55 @@ the runtime compression model behind these tools. Defined in `open-sse/mcp-server/tools/memoryTools.ts`. Auth/scope is enforced through the standard MCP scope pipeline. -| Tool | Description | -| :------------------------ | :---------------------------------------------------------------------------------- | -| `omniroute_memory_search` | Search memories by query / type / API key with token-budget enforcement | -| `omniroute_memory_add` | Add a new memory entry (`factual` / `episodic` / `procedural` / `semantic`) | -| `omniroute_memory_clear` | Clear memories for an API key, optionally filtered by type or `olderThan` timestamp | +| Tool | Scopes | Description | +| :------------------------ | :--------------- | :---------------------------------------------------------------------------------- | +| `omniroute_memory_search` | `read:memory` | Search memories by query / type / API key with token-budget enforcement | +| `omniroute_memory_add` | `write:memory` | Add a new memory entry (`factual` / `episodic` / `procedural` / `semantic`) | +| `omniroute_memory_clear` | `write:memory` | Clear memories for an API key, optionally filtered by type or `olderThan` timestamp | ## Skill Tools (4) Defined in `open-sse/mcp-server/tools/skillTools.ts`. Backed by `src/lib/skills/registry` + `src/lib/skills/executor`. -| Tool | Description | -| :---------------------------- | :-------------------------------------------------------------------------------- | -| `omniroute_skills_list` | List registered skills with optional filtering by API key, name, or enabled state | -| `omniroute_skills_enable` | Enable or disable a specific skill by ID | -| `omniroute_skills_execute` | Execute a skill with provided input and return the execution record | -| `omniroute_skills_executions` | List recent skill execution history | +| Tool | Scopes | Description | +| :---------------------------- | :-------------- | :-------------------------------------------------------------------------------- | +| `omniroute_skills_list` | `read:skills` | List registered skills with optional filtering by API key, name, or enabled state | +| `omniroute_skills_enable` | `write:skills` | Enable or disable a specific skill by ID | +| `omniroute_skills_execute` | `execute:skills`| Execute a skill with provided input and return the execution record | +| `omniroute_skills_executions` | `read:skills` | List recent skill execution history | + +## Notion Context Source (6) + +Defined in `open-sse/mcp-server/tools/notionTools.ts`. Token stored in `key_value` table via `src/lib/db/notion.ts`. REST client in `src/lib/notion/api.ts`. Settings API in `src/app/api/settings/notion/route.ts`. Dashboard UI in `src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx`. + +Configure your Notion integration token from the **Context Sources** tab in the Endpoint dashboard, or via the REST API: + +```bash +# Set token +curl -X POST http://localhost:20128/api/settings/notion \ + -H "Content-Type: application/json" \ + -d '{"token": "ntn_..."}' + +# Check status +curl http://localhost:20128/api/settings/notion + +# Disconnect +curl -X DELETE http://localhost:20128/api/settings/notion +``` + +| Tool | Scopes | Description | +| :--------------------------- | :--------------- | :------------------------------------------------------------------------------------ | +| `omniroute_notion_search` | `read:notion` | Full-text search across all pages and databases | +| `omniroute_notion_list_databases` | `read:notion` | List all accessible databases with schema metadata | +| `omniroute_notion_get_database` | `read:notion` | Get database schema by ID | +| `omniroute_notion_query_database` | `read:notion` | Query a database with filters, sorts, and pagination | +| `omniroute_notion_read` | `read:notion` | Read a page or block by ID with its content | +| `omniroute_notion_append_blocks` | `write:notion`| Append children blocks to a parent block (max 100 per request) | ## Related Frameworks (v3.8.0) -The MCP tool inventory above (37 tools = 30 base + 3 memory + 4 skills) is intentionally -scoped to runtime routing/cache/compression/memory/skills/proxy operations. Two adjacent +The MCP tool inventory above (43 tools = 30 core + 3 memory + 4 skills + 6 notion) is intentionally +scoped to runtime routing/cache/compression/memory/skills/proxy/context-source operations. Two adjacent frameworks ship alongside the MCP server in v3.8.0 and are documented separately: ### Cloud Agents @@ -247,11 +275,16 @@ MCP tools are authenticated through API key scopes. Scope enforcement is central | `read:compression` | `compression_status`, `list_compression_combos`, `compression_combo_stats` | | `write:compression` | `compression_configure`, `set_compression_engine` | | `read:proxies` | `oneproxy_fetch`, `oneproxy_rotate`, `oneproxy_stats` | +| `read:notion` | `notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read` | +| `write:notion` | `notion_append_blocks` | +| `read:memory` | `memory_search` | +| `write:memory` | `memory_add`, `memory_clear` | +| `read:skills` | `skills_list`, `skills_executions` | +| `write:skills` | `skills_enable` | +| `execute:skills` | `skills_execute` | Wildcard scopes are supported: `read:*` grants all read-scopes, `*` grants full access. -Memory and Skill tools currently do not declare static scope requirements in their definitions; access is gated by the caller's API key and audited through the standard MCP audit pipeline. - --- ## Environment Variables @@ -294,7 +327,7 @@ The heartbeat snapshot contains: "transport": "stdio", "scopesEnforced": false, "allowedScopes": [], - "toolCount": 37 + "toolCount": 43 } ``` @@ -328,9 +361,19 @@ Use the dashboard or the `/api/mcp/audit` and `/api/mcp/audit/stats` REST endpoi | `open-sse/mcp-server/tools/compressionTools.ts` | Compression tool handlers | | `open-sse/mcp-server/tools/memoryTools.ts` | Memory tool definitions (3 tools) | | `open-sse/mcp-server/tools/skillTools.ts` | Skill tool definitions (4 tools) | +| `open-sse/mcp-server/tools/notionTools.ts` | Notion context source tool definitions (6 tools) | +| `open-sse/mcp-server/tools/gamificationTools.ts`| Gamification tool definitions (8 tools) | +| `open-sse/mcp-server/tools/pluginTools.ts` | Plugin registration and management tools (8 tools) | | `src/app/api/mcp/status/route.ts` | `/api/mcp/status` endpoint | | `src/app/api/mcp/tools/route.ts` | `/api/mcp/tools` endpoint | | `src/app/api/mcp/sse/route.ts` | `/api/mcp/sse` SSE transport route | | `src/app/api/mcp/stream/route.ts` | `/api/mcp/stream` Streamable HTTP transport route | | `src/app/api/mcp/audit/route.ts` | `/api/mcp/audit` audit log query | | `src/app/api/mcp/audit/stats/route.ts` | `/api/mcp/audit/stats` aggregated audit metrics | +| `src/lib/notion/api.ts` | Notion REST API client (retry, timeout, error classification) | +| `src/lib/db/notion.ts` | Notion token persistence (`key_value` table) | +| `src/app/api/settings/notion/route.ts` | Notion settings API (GET/POST/DELETE) | +| `src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx` | Notion token management UI | +| `tests/unit/notion-api.test.ts` | Notion API client tests (7) | +| `tests/unit/notion-tools.test.ts` | Notion tools scope enforcement tests (10) | +| `tests/unit/db/notion.test.mjs` | Notion DB module tests (3) | From 187bc509bb04c5a13438d331b9579ec1b71850cc Mon Sep 17 00:00:00 2001 From: terence71-glitch Date: Sat, 30 May 2026 17:17:52 -0700 Subject: [PATCH 36/82] fix(sse): bypass web-search fallback on Claude -> Claude passthrough (#2960) Integrated into release/v3.8.8 --- open-sse/services/webSearchFallback.ts | 11 +- tests/unit/web-search-fallback-format.test.ts | 157 +++++++++++++++++- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/open-sse/services/webSearchFallback.ts b/open-sse/services/webSearchFallback.ts index 62f3f06ca5..61fe2355c3 100644 --- a/open-sse/services/webSearchFallback.ts +++ b/open-sse/services/webSearchFallback.ts @@ -130,6 +130,7 @@ function buildFallbackTool(tool: JsonRecord, targetFormat?: string | null): Json } export function supportsNativeWebSearchFallbackBypass({ + sourceFormat, targetFormat, nativeCodexPassthrough, }: { @@ -138,8 +139,16 @@ export function supportsNativeWebSearchFallbackBypass({ targetFormat: string | null | undefined; nativeCodexPassthrough: boolean; }): boolean { + // Native Codex (OpenAI Responses) passthrough: the upstream runs web search itself. if (nativeCodexPassthrough) return true; - return targetFormat === FORMATS.GEMINI; + // Gemini target: the Gemini translator maps built-in web search to googleSearch natively. + if (targetFormat === FORMATS.GEMINI) return true; + // Claude -> Claude passthrough: the Anthropic Messages upstream (e.g. a Claude + // subscription driven by Claude Code) natively runs web_search_20250305. Forward the + // native tool untouched instead of rewriting it to omniroute_web_search. Mirrors the + // Codex/Gemini bypasses so every native-web-search provider is treated symmetrically. + if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) return true; + return false; } export function prepareWebSearchFallbackBody( diff --git a/tests/unit/web-search-fallback-format.test.ts b/tests/unit/web-search-fallback-format.test.ts index 605aa1f7dd..8941eb7a2f 100644 --- a/tests/unit/web-search-fallback-format.test.ts +++ b/tests/unit/web-search-fallback-format.test.ts @@ -1,8 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { prepareWebSearchFallbackBody, OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } = - await import("../../open-sse/services/webSearchFallback.ts"); +const { + prepareWebSearchFallbackBody, + supportsNativeWebSearchFallbackBypass, + OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME, +} = await import("../../open-sse/services/webSearchFallback.ts"); // Regression for #2390: when the target is a Responses-API provider, the injected // omniroute_web_search tool must use the FLAT function shape ({ type, name }), not the @@ -72,3 +75,153 @@ test("#2390 tool_choice matches the injected tool shape per target format", () = const cFn = cChoice.function as Record | undefined; assert.equal(cFn?.name, OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME); }); + +// ── Native web-search bypass: predicate coverage for every native path ── + +test("bypass predicate: true for native Codex passthrough", () => { + assert.equal( + supportsNativeWebSearchFallbackBypass({ + provider: "openai", + sourceFormat: "openai-responses", + targetFormat: "openai-responses", + nativeCodexPassthrough: true, + }), + true + ); +}); + +test("bypass predicate: true for Gemini target", () => { + assert.equal( + supportsNativeWebSearchFallbackBypass({ + provider: "gemini", + sourceFormat: "openai", + targetFormat: "gemini", + nativeCodexPassthrough: false, + }), + true + ); +}); + +test("bypass predicate: true for Claude -> Claude passthrough", () => { + assert.equal( + supportsNativeWebSearchFallbackBypass({ + provider: "claude", + sourceFormat: "claude", + targetFormat: "claude", + nativeCodexPassthrough: false, + }), + true + ); +}); + +test("bypass predicate: false for standard OpenAI -> OpenAI", () => { + assert.equal( + supportsNativeWebSearchFallbackBypass({ + provider: "openai", + sourceFormat: "openai", + targetFormat: "openai", + nativeCodexPassthrough: false, + }), + false + ); +}); + +test("bypass predicate: false when only the target is Claude (non-native tool must convert)", () => { + // An OpenAI-format client hitting a Claude target sends an OpenAI-shaped web_search + // tool that is NOT native Anthropic format, so it must still be converted. Only the + // Claude -> Claude passthrough (native body) is bypassed. + assert.equal( + supportsNativeWebSearchFallbackBypass({ + provider: "claude", + sourceFormat: "openai", + targetFormat: "claude", + nativeCodexPassthrough: false, + }), + false + ); +}); + +test("bypass predicate: false when only the source is Claude", () => { + assert.equal( + supportsNativeWebSearchFallbackBypass({ + provider: "openai", + sourceFormat: "claude", + targetFormat: "openai", + nativeCodexPassthrough: false, + }), + false + ); +}); + +// ── Native web-search bypass: end-to-end body behavior ── + +test("Claude -> Claude: native web_search_20250305 forwarded untouched", () => { + const inputBody = { + tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 5 }], + }; + const { body, fallback } = prepareWebSearchFallbackBody(inputBody, { + provider: "claude", + sourceFormat: "claude", + targetFormat: "claude", + nativeCodexPassthrough: false, + }); + + assert.equal(fallback.enabled, false); + assert.equal(fallback.toolName, null); + assert.equal(fallback.convertedToolCount, 0); + // Body forwarded verbatim — the native tool reaches the Anthropic upstream as-is. + assert.deepEqual(body, inputBody); +}); + +test("Claude -> Claude: bare web_search type also forwarded untouched", () => { + // Even the bare (unversioned) web_search type is forwarded on the Claude passthrough, + // because the Anthropic upstream owns web search. This is the explicit protection that + // no longer depends on the versioned type being absent from the matcher set. + const inputBody = { tools: [{ type: "web_search" }] }; + const { body, fallback } = prepareWebSearchFallbackBody(inputBody, { + provider: "claude", + sourceFormat: "claude", + targetFormat: "claude", + nativeCodexPassthrough: false, + }); + + assert.equal(fallback.enabled, false); + assert.equal(fallback.toolName, null); + assert.deepEqual(body, inputBody); +}); + +test("native Codex passthrough: built-in web_search_preview forwarded untouched", () => { + // Symmetric end-to-end coverage for the Codex bypass. + const inputBody = { tools: [{ type: "web_search_preview" }] }; + const { body, fallback } = prepareWebSearchFallbackBody(inputBody, { + provider: "openai", + sourceFormat: "openai-responses", + targetFormat: "openai-responses", + nativeCodexPassthrough: true, + }); + + assert.equal(fallback.enabled, false); + assert.equal(fallback.toolName, null); + assert.deepEqual(body, inputBody); +}); + +test("OpenAI -> Claude (non-passthrough): built-in web_search IS still converted", () => { + // Regression guard: the new Claude bypass must NOT swallow the conversion path for a + // non-native (OpenAI-format) client that merely targets a Claude provider. + const inputBody = { + tools: [{ type: "web_search", search_context_size: "low" }], + }; + const { body, fallback } = prepareWebSearchFallbackBody(inputBody, { + provider: "claude", + sourceFormat: "openai", + targetFormat: "claude", + nativeCodexPassthrough: false, + }); + + assert.equal(fallback.enabled, true); + assert.equal(fallback.toolName, OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME); + assert.equal(fallback.convertedToolCount, 1); + const tools = (body.tools as Record[]) || []; + const toolNames = tools.map((t) => (t.function ? t.function.name : t.name)); + assert.ok(toolNames.includes(OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME)); +}); From 52503064a8e6527a6e451c1b1aedc5cb4662e982 Mon Sep 17 00:00:00 2001 From: terence71-glitch Date: Sat, 30 May 2026 17:18:08 -0700 Subject: [PATCH 37/82] fix(skills): avoid Claude assistant tool_result blocks (#2956) Integrated into release/v3.8.8 --- src/lib/skills/interception.ts | 48 ++++++++++++++++++++++---- tests/unit/skills-interception.test.ts | 36 +++++++++++-------- 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/lib/skills/interception.ts b/src/lib/skills/interception.ts index f26b954109..f026e3a6ab 100644 --- a/src/lib/skills/interception.ts +++ b/src/lib/skills/interception.ts @@ -290,18 +290,52 @@ export async function handleToolCallExecution( }; } - case "anthropic": + case "anthropic": { + // Anthropic only permits tool_result blocks in user messages. This helper + // returns a single assistant response, so there is no valid place to put a + // server-side skill result as tool_result here. Keep client-native tool_use + // blocks untouched, remove the OmniRoute-handled tool_use blocks, and expose + // their results as plain assistant text instead of corrupting history with + // assistant-side tool_result blocks. See #2815. + // + // When no client-native tool_use blocks remain (all were handled here), the + // upstream stop_reason "tool_use" is stale and would make clients wait for + // tool_use blocks that no longer exist — so it is normalized to "end_turn" + // (with stop_sequence cleared). The mixed-tool branch keeps the original + // stop_reason because real native tool_use blocks still need the client. + const handledToolCallIds = new Set(results.map((r) => r.id)); + const toolNamesById = new Map(toolCalls.map((call) => [call.id, call.name])); + const remainingContent = (Array.isArray(response.content) ? response.content : []).filter( + (block: any) => !(block?.type === "tool_use" && handledToolCallIds.has(block.id)) + ); + const resultTextBlocks = results.map((r) => ({ + type: "text", + text: `[Skill result: ${toolNamesById.get(r.id) || r.id}]\n${JSON.stringify( + r.result + )}`, + })); + const firstRemainingToolUseIndex = remainingContent.findIndex( + (block: any) => block?.type === "tool_use" + ); + + if (firstRemainingToolUseIndex === -1) { + return { + ...response, + content: [...remainingContent, ...resultTextBlocks], + stop_reason: "end_turn", + stop_sequence: null, + }; + } + return { ...response, content: [ - ...response.content, - ...results.map((r) => ({ - type: "tool_result", - tool_use_id: r.id, - content: JSON.stringify(r.result), - })), + ...remainingContent.slice(0, firstRemainingToolUseIndex), + ...resultTextBlocks, + ...remainingContent.slice(firstRemainingToolUseIndex), ], }; + } default: return response; diff --git a/tests/unit/skills-interception.test.ts b/tests/unit/skills-interception.test.ts index 3cc7b4491b..075b24452a 100644 --- a/tests/unit/skills-interception.test.ts +++ b/tests/unit/skills-interception.test.ts @@ -215,9 +215,10 @@ test("handleToolCallExecution appends OpenAI tool results and leaves empty respo assert.equal(await handleToolCallExecution(untouched, "gpt-4.1", executionContext), untouched); }); -test("handleToolCallExecution appends Anthropic tool_result blocks", async () => { +test("handleToolCallExecution returns Anthropic skill results as text", async () => { const anthropicResponse = await handleToolCallExecution( { + stop_reason: "tool_use", content: [{ type: "tool_use", id: "tool-1", name: "lookup@1.0.0", input: { id: "77" } }], }, "claude-3-7-sonnet", @@ -225,13 +226,17 @@ test("handleToolCallExecution appends Anthropic tool_result blocks", async () => ); assert.deepEqual(anthropicResponse.content, [ - { type: "tool_use", id: "tool-1", name: "lookup@1.0.0", input: { id: "77" } }, { - type: "tool_result", - tool_use_id: "tool-1", - content: '{"record":"resolved:77"}', + type: "text", + text: '[Skill result: lookup@1.0.0]\n{"record":"resolved:77"}', }, ]); + assert.equal( + anthropicResponse.content.some((b: { type: string }) => b.type === "tool_result"), + false + ); + assert.equal(anthropicResponse.stop_reason, "end_turn"); + assert.equal(anthropicResponse.stop_sequence, null); }); test("handleToolCallExecution appends Responses API function_call_output items", async () => { @@ -285,6 +290,7 @@ test("handleToolCallExecution forwards unregistered client-native tool_use untou test("handleToolCallExecution intercepts a registered skill alongside an unregistered tool (#2815)", async () => { const mixed = await handleToolCallExecution( { + stop_reason: "tool_use", content: [ { type: "tool_use", id: "tool-native", name: "Bash", input: { command: "ls" } }, { type: "tool_use", id: "tool-skill", name: "lookup@1.0.0", input: { id: "9" } }, @@ -295,14 +301,14 @@ test("handleToolCallExecution intercepts a registered skill alongside an unregis ); assert.deepEqual(mixed.content, [ - { type: "tool_use", id: "tool-native", name: "Bash", input: { command: "ls" } }, - { type: "tool_use", id: "tool-skill", name: "lookup@1.0.0", input: { id: "9" } }, { - type: "tool_result", - tool_use_id: "tool-skill", - content: '{"record":"resolved:9"}', + type: "text", + text: '[Skill result: lookup@1.0.0]\n{"record":"resolved:9"}', }, + { type: "tool_use", id: "tool-native", name: "Bash", input: { command: "ls" } }, ]); + assert.equal(mixed.content.some((b: { type: string }) => b.type === "tool_result"), false); + assert.equal(mixed.stop_reason, "tool_use"); }); test("handleToolCallExecution loads registry from DB on cold cache (covers loadFromDatabase fix)", async () => { @@ -316,6 +322,7 @@ test("handleToolCallExecution loads registry from DB on cold cache (covers loadF const result = await handleToolCallExecution( { + stop_reason: "tool_use", content: [ { type: "tool_use", id: "tool-skill", name: "lookup@1.0.0", input: { id: "cold" } }, ], @@ -325,11 +332,12 @@ test("handleToolCallExecution loads registry from DB on cold cache (covers loadF ); assert.deepEqual(result.content, [ - { type: "tool_use", id: "tool-skill", name: "lookup@1.0.0", input: { id: "cold" } }, { - type: "tool_result", - tool_use_id: "tool-skill", - content: '{"record":"resolved:cold"}', + type: "text", + text: '[Skill result: lookup@1.0.0]\n{"record":"resolved:cold"}', }, ]); + assert.equal(result.content.some((b: { type: string }) => b.type === "tool_result"), false); + assert.equal(result.stop_reason, "end_turn"); + assert.equal(result.stop_sequence, null); }); From 7a0e803c0197bdede454e0a5fc19fd509f70cb50 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Sun, 31 May 2026 07:18:16 +0700 Subject: [PATCH 38/82] feat: add Qwen Web (chat.qwen.ai) cookie provider (#2947) Integrated into release/v3.8.8 --- open-sse/config/providerRegistry.ts | 20 +++ open-sse/executors/index.ts | 4 + open-sse/executors/qwen-web.ts | 159 ++++++++++++++++++ .../providers/[id]/webSessionCredentials.ts | 6 + src/shared/constants/providers.ts | 14 ++ tests/unit/web-cookie-providers-new.test.ts | 17 ++ 6 files changed, 220 insertions(+) create mode 100644 open-sse/executors/qwen-web.ts diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 0ea46aa9a2..405f7ac46e 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -3940,6 +3940,26 @@ export const REGISTRY: Record = { ], }, + "qwen-web": { + id: "qwen-web", + alias: "qw", + format: "openai", + executor: "qwen-web", + baseUrl: "https://chat.qwen.ai/api/chat/completions", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "qwen-plus", name: "Qwen Plus" }, + { id: "qwen-max", name: "Qwen Max" }, + { id: "qwen-turbo", name: "Qwen Turbo" }, + { id: "qwen3-plus", name: "Qwen3 Plus" }, + { id: "qwen3-max", name: "Qwen3 Max" }, + { id: "qwen3-flash", name: "Qwen3 Flash" }, + { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, + { id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" }, + ], + }, + codestral: { id: "codestral", alias: "codestral", diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index d5428950ac..adf2f4e208 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -45,6 +45,7 @@ import { VeniceWebExecutor } from "./venice-web.ts"; import { V0VercelWebExecutor } from "./v0-vercel-web.ts"; import { KimiWebExecutor } from "./kimi-web.ts"; import { DoubaoWebExecutor } from "./doubao-web.ts"; +import { QwenWebExecutor } from "./qwen-web.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -127,6 +128,8 @@ const executors = { kimi: new KimiWebExecutor(), // Alias "doubao-web": new DoubaoWebExecutor(), db: new DoubaoWebExecutor(), // Alias + "qwen-web": new QwenWebExecutor(), + qw: new QwenWebExecutor(), // Alias }; const defaultCache = new Map(); @@ -182,3 +185,4 @@ export { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-ref export { AdaptaWebExecutor } from "./adapta-web.ts"; export { T3ChatWebExecutor } from "./t3-chat-web.ts"; export { InnerAiExecutor } from "./inner-ai.ts"; +export { QwenWebExecutor } from "./qwen-web.ts"; diff --git a/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts new file mode 100644 index 0000000000..66f8aec0db --- /dev/null +++ b/open-sse/executors/qwen-web.ts @@ -0,0 +1,159 @@ +/** + * QwenWebExecutor — Alibaba Tongyi Qwen Chat via chat.qwen.ai + * + * Routes requests through Qwen's consumer chat API. + * Chinese market provider with strong vision, coding, and reasoning models. + * + * Auth: Token from chat.qwen.ai Local Storage or tongyi_sso_ticket cookie + * Endpoint: POST https://chat.qwen.ai/api/chat/completions + * Format: OpenAI-compatible + */ +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; + +const BASE_URL = "https://chat.qwen.ai"; +const CHAT_URL = `${BASE_URL}/api/chat/completions`; +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + +export class QwenWebExecutor extends BaseExecutor { + constructor() { + super("qwen-web", { id: "qwen-web", baseUrl: BASE_URL }); + } + + async execute(input: ExecuteInput) { + const { body, credentials, signal, stream: wantStream } = input; + const bodyObj = (body || {}) as Record; + const rawToken = String(credentials?.apiKey ?? credentials?.accessToken ?? "").trim(); + + const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || []; + const modelId = (bodyObj.model as string) || "qwen-plus"; + + const reqBody = { + messages: messages.map((m) => ({ role: m.role, content: m.content })), + model: modelId, + stream: wantStream, + max_tokens: (bodyObj.max_tokens as number) || 4096, + }; + + const reqHeaders: Record = { + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + Accept: wantStream ? "text/event-stream" : "application/json", + Referer: `${BASE_URL}/`, + Origin: BASE_URL, + }; + if (rawToken) { + reqHeaders["Authorization"] = `Bearer ${rawToken}`; + } + + let upstream: Response; + try { + upstream = await fetch(CHAT_URL, { + method: "POST", + headers: reqHeaders, + body: JSON.stringify(reqBody), + signal, + }); + } catch (err) { + return makeErrorResult( + 502, + `Qwen fetch failed: ${err instanceof Error ? err.message : "unknown"}`, + body, + CHAT_URL + ); + } + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + if (upstream.status === 401) { + return makeErrorResult( + 401, + "Qwen authentication failed. Your token may have expired. " + + "Get a fresh token from chat.qwen.ai (DevTools → Application → Local Storage → token)", + body, + CHAT_URL + ); + } + return makeErrorResult(upstream.status, `Qwen error: ${errText}`, body, CHAT_URL); + } + + if (!wantStream) { + const data = (await upstream.json()) as Record; + const content = + (data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content || + (data?.content as string) || + ""; + return { + response: new Response( + JSON.stringify({ + id: `chatcmpl-qwen-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: modelId, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + }), + { headers: { "Content-Type": "application/json" } } + ), + url: CHAT_URL, + headers: reqHeaders, + transformedBody: reqBody, + }; + } + + // Streaming + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const stream = new ReadableStream({ + async start(controller) { + const reader = upstream.body?.getReader(); + if (!reader) { controller.close(); return; } + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data === "[DONE]") { controller.enqueue(encoder.encode("data: [DONE]\n\n")); continue; } + try { + const parsed = JSON.parse(data); + const text = parsed.choices?.[0]?.delta?.content || parsed.choices?.[0]?.text || ""; + if (text) { + const chunk = { + id: `chatcmpl-qwen-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: modelId, + choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + } catch { + /* skip unparseable chunks */ + } + } + } + } catch (err) { + if (!signal?.aborted) controller.error(err); + } finally { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } + }, + }); + + return { + response: new Response(stream, { + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }, + }), + url: CHAT_URL, + headers: reqHeaders, + transformedBody: reqBody, + }; + } +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts index d755a22ca7..d479c1235f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts @@ -141,6 +141,12 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { placeholder: "session=... or full Cookie header from doubao.com", acceptsFullCookieHeader: true, }, + "qwen-web": { + kind: "token", + credentialName: "token", + placeholder: "Paste your Qwen token from chat.qwen.ai (Local Storage → token)", + acceptsFullCookieHeader: false, + }, } satisfies Record; export function getWebSessionCredentialRequirement( diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 0bca6d0785..8c4ca6ce3e 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -516,6 +516,20 @@ export const WEB_COOKIE_PROVIDERS = { subscriptionRisk: true, riskNoticeVariant: "webCookie", }, + "qwen-web": { + id: "qwen-web", + alias: "qw", + name: "Qwen Web (Free)", + icon: "auto_awesome", + color: "#10B981", + textIcon: "QW", + website: "https://chat.qwen.ai", + hasFree: true, + freeNote: "Free — Qwen models via chat.qwen.ai with login token. No subscription required.", + authHint: + "Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → " + + 'copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token).', + }, }; // API Key Providers diff --git a/tests/unit/web-cookie-providers-new.test.ts b/tests/unit/web-cookie-providers-new.test.ts index e30f003aab..b6ff83b790 100644 --- a/tests/unit/web-cookie-providers-new.test.ts +++ b/tests/unit/web-cookie-providers-new.test.ts @@ -8,6 +8,7 @@ const { VeniceWebExecutor } = await import("../../open-sse/executors/venice-web. const { V0VercelWebExecutor } = await import("../../open-sse/executors/v0-vercel-web.ts"); const { KimiWebExecutor } = await import("../../open-sse/executors/kimi-web.ts"); const { DoubaoWebExecutor } = await import("../../open-sse/executors/doubao-web.ts"); +const { QwenWebExecutor } = await import("../../open-sse/executors/qwen-web.ts"); const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); // ── Helpers ────────────────────────────────────────────────────────────────── @@ -175,6 +176,22 @@ test("Doubao Web sets correct provider", () => { assert.equal(executor.getProvider(), "doubao-web"); }); +// ── Registration Tests (Qwen Web) ──────────────────────────────────────────── + +test("Qwen Web executor is registered", () => { + assert.ok(hasSpecializedExecutor("qwen-web")); + assert.ok(hasSpecializedExecutor("qw")); + const executor = getExecutor("qwen-web"); + assert.ok(executor instanceof QwenWebExecutor); +}); + +// ── Constructor Tests (Qwen Web) ───────────────────────────────────────────── + +test("Qwen Web sets correct provider", () => { + const executor = new QwenWebExecutor(); + assert.equal(executor.getProvider(), "qwen-web"); +}); + // ── HuggingChat Execution Tests ────────────────────────────────────────────── test("HuggingChat: streaming returns SSE chunks", async () => { From 2fb597911806d38e64155e65b806eba95cdb1c4e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 30 May 2026 21:18:25 -0300 Subject: [PATCH 39/82] =?UTF-8?q?fix(dashboard):=20v3.8.8=20screen=20fixes?= =?UTF-8?q?=20=E2=80=94=20agent-bridge=20SSR=20+=20audit/logs/memory/playg?= =?UTF-8?q?round=20(#2944)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.8 --- .../dashboard/audit/A2aAuditTab.tsx | 2 +- .../dashboard/audit/ComplianceTab.tsx | 2 +- .../costs/quota-share/components/PoolCard.tsx | 11 +- .../hooks/usePoolsUsageAggregate.ts | 4 +- src/app/(dashboard)/dashboard/logs/page.tsx | 45 +-- .../memory/components/MemoryEngineStatus.tsx | 7 +- .../memory/components/tabs/MemoriesTab.tsx | 10 + src/app/(dashboard)/dashboard/memory/page.tsx | 50 ++- .../components/StudioConfigPane.tsx | 59 +++- .../playground/components/tabs/BuildTab.tsx | 250 ++++++--------- .../playground/components/tabs/CompareTab.tsx | 58 +++- .../components/tabs/build/BuildWizard.tsx | 294 ++++++++++++++++++ .../components/SearchToolsTopBar.tsx | 8 +- .../components/tabs/CompareTab.tsx | 286 ++++++++++------- .../agent-bridge/AgentBridgePageClient.tsx | 4 +- .../agent-bridge/components/AgentCard.tsx | 4 +- .../agent-bridge/components/AgentList.tsx | 4 +- .../agent-bridge/components/SetupWizard.tsx | 4 +- .../dashboard/tools/agent-bridge/page.tsx | 2 +- src/i18n/messages/en.json | 74 ++++- src/i18n/messages/pt-BR.json | 74 ++++- src/mitm/types.ts | 7 + src/server/authz/policies/management.ts | 22 +- src/server/authz/routeGuard.ts | 33 ++ src/shared/components/Select.tsx | 19 +- .../agent-bridge-targets-serializable.test.ts | 33 ++ tests/unit/audit-eventtype-i18n.test.ts | 27 ++ tests/unit/route-guard-private-lan.test.ts | 57 ++++ tests/unit/v388-phase1-screen-fixes.test.ts | 35 +++ tests/unit/v388-phase3-memory.test.ts | 37 +++ tests/unit/v388-phase4-playground.test.ts | 41 +++ .../unit/v388-quota-share-usage-guard.test.ts | 23 ++ 32 files changed, 1204 insertions(+), 382 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx create mode 100644 tests/unit/agent-bridge-targets-serializable.test.ts create mode 100644 tests/unit/audit-eventtype-i18n.test.ts create mode 100644 tests/unit/route-guard-private-lan.test.ts create mode 100644 tests/unit/v388-phase1-screen-fixes.test.ts create mode 100644 tests/unit/v388-phase3-memory.test.ts create mode 100644 tests/unit/v388-phase4-playground.test.ts create mode 100644 tests/unit/v388-quota-share-usage-guard.test.ts diff --git a/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx b/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx index 1cd2f7c2dc..2c390c68cd 100644 --- a/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx +++ b/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx @@ -182,7 +182,7 @@ export default function A2aAuditTab() { - {task.state} + {t(`a2aState${task.state.charAt(0).toUpperCase()}${task.state.slice(1)}`)} {taskDuration(task)} diff --git a/src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx b/src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx index f2a0dc4329..a8f86190cc 100644 --- a/src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx +++ b/src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx @@ -330,7 +330,7 @@ export default function ComplianceTab() { - {entry.action} + {t.has(`eventTypes.${entry.action}`) ? t(`eventTypes.${entry.action}`) : entry.action} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx index 6bae71c651..bd131e0841 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx @@ -24,8 +24,9 @@ export interface PoolCardProps { } function computeStatus(usage: PoolUsageSnapshot | null): "green" | "amber" | "red" { - if (!usage || usage.dimensions.length === 0) return "green"; - const utilizations = usage.dimensions.map((d) => + const dims = usage?.dimensions ?? []; + if (dims.length === 0) return "green"; + const utilizations = dims.map((d) => d.limit > 0 ? (d.consumedTotal / d.limit) * 100 : 0 ); const avg = utilizations.reduce((s, u) => s + u, 0) / utilizations.length; @@ -54,7 +55,7 @@ export default function PoolCard({ const { icon: statusIcon, cls: statusCls } = STATUS_ICONS[status]; // Check for plan dimensions from usage - const hasDimensions = usage && usage.dimensions.length > 0; + const hasDimensions = !!usage?.dimensions?.length; return ( @@ -103,10 +104,10 @@ export default function PoolCard({
- {usage.dimensions.map((dim, i) => ( + {(usage?.dimensions ?? []).map((dim, i) => ( 0) { totalUtil += (dim.consumedTotal / dim.limit) * 100; utilCount += 1; } - for (const key of dim.perKey) { + for (const key of dim.perKey ?? []) { if (key.borrowing) borrowing += 1; } } diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx index 5ef549d357..40b1271958 100644 --- a/src/app/(dashboard)/dashboard/logs/page.tsx +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -1,9 +1,7 @@ "use client"; import { useState, useRef, useEffect } from "react"; -import { useSearchParams } from "next/navigation"; -import { ConfirmModal, RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components"; -import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer"; +import { ConfirmModal, RequestLoggerV2 } from "@/shared/components"; import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; import ActiveRequestsPanel from "@/shared/components/ActiveRequestsPanel"; import { useTranslations } from "next-intl"; @@ -15,18 +13,7 @@ const TIME_RANGES = [ { label: "24h", hours: 24 }, ]; -const TAB_TO_LOG_TYPE: Record = { - "request-logs": "request-logs", - "proxy-logs": "proxy-logs", - console: "call-logs", -}; - export default function LogsPage() { - const searchParams = useSearchParams(); - const requestedTab = searchParams.get("tab"); - const [activeTab, setActiveTab] = useState( - requestedTab && TAB_TO_LOG_TYPE[requestedTab] ? requestedTab : "request-logs" - ); const [showExport, setShowExport] = useState(false); const [exporting, setExporting] = useState(false); const [showCleanHistory, setShowCleanHistory] = useState(false); @@ -36,12 +23,6 @@ export default function LogsPage() { const dropdownRef = useRef(null); const t = useTranslations("logs"); - useEffect(() => { - if (requestedTab && TAB_TO_LOG_TYPE[requestedTab] && requestedTab !== activeTab) { - setActiveTab(requestedTab); - } - }, [activeTab, requestedTab]); - useEffect(() => { function handleClickOutside(e: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { @@ -56,7 +37,7 @@ export default function LogsPage() { setExporting(true); setShowExport(false); try { - const logType = TAB_TO_LOG_TYPE[activeTab] || "call-logs"; + const logType = "request-logs"; const res = await fetch(`/api/logs/export?hours=${hours}&type=${logType}`); if (!res.ok) throw new Error(t("exportFailed")); const blob = await res.blob(); @@ -108,15 +89,7 @@ export default function LogsPage() { return (
- +

{t("requestLogs")}

@@ -211,14 +184,10 @@ export default function LogsPage() {
)} - {activeTab === "request-logs" && ( -
- - -
- )} - {activeTab === "proxy-logs" && } - {activeTab === "console" && } +
+ + +
0 ? ( + status.vectorStore.backend === "none" ? ( + + terminal + {t("engine.vectorStoreInstallHint")} + + ) : status.vectorStore.needsReindex > 0 ? ( warning {t("engine.needsReindex", { count: status.vectorStore.needsReindex })} diff --git a/src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx b/src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx index 458c45c453..5df03c94b2 100644 --- a/src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx +++ b/src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx @@ -236,6 +236,16 @@ export default function MemoriesTab() { } }; + // Auto-run health check on mount + poll every 30s, so the indicator reflects + // engine health without requiring a manual click. + useEffect(() => { + void checkHealth(); + const id = setInterval(() => { + void checkHealth(); + }, 30_000); + return () => clearInterval(id); + }, []); + const openEdit = (m: Memory) => { setEditTarget(m); setEditOpen(true); diff --git a/src/app/(dashboard)/dashboard/memory/page.tsx b/src/app/(dashboard)/dashboard/memory/page.tsx index 6d1c382e47..cf228ede40 100644 --- a/src/app/(dashboard)/dashboard/memory/page.tsx +++ b/src/app/(dashboard)/dashboard/memory/page.tsx @@ -7,15 +7,18 @@ import MemoryConceptCard from "./components/MemoryConceptCard"; import MemoriesTab from "./components/tabs/MemoriesTab"; import PlaygroundTab from "./components/tabs/PlaygroundTab"; import EngineTab from "./components/tabs/EngineTab"; +import { useMemorySettings } from "./hooks/useMemorySettings"; type TabId = "memories" | "playground" | "engine"; -const TABS: TabId[] = ["memories", "playground", "engine"]; +const TABS: TabId[] = ["memories", "engine", "playground"]; function MemoryPageContent() { const t = useTranslations("memory"); const searchParams = useSearchParams(); const router = useRouter(); + const { settings, save } = useMemorySettings(); + const memoryEnabled = settings?.enabled ?? true; const rawTab = searchParams.get("tab") ?? ""; const activeTab: TabId = TABS.includes(rawTab as TabId) ? (rawTab as TabId) : "memories"; @@ -31,23 +34,44 @@ function MemoryPageContent() { {/* Concept card */} - {/* Tab navigation */} -
- {TABS.map((tab) => ( + {/* Tab navigation + memory enable toggle */} +
+
+ {TABS.map((tab) => ( + + ))} +
+
{/* Tab content */} diff --git a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx index decb51ed73..20440a705a 100644 --- a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx @@ -8,11 +8,14 @@ import type { PlaygroundEndpoint } from "@/lib/playground/codeExport"; import { endpointToPath } from "@/lib/playground/codeExport"; import PresetPicker from "./PresetPicker"; import ImprovePromptButton from "./ImprovePromptButton"; +import { useProviderOptions } from "@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions"; +import { useAvailableModels } from "@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels"; export interface ConfigState { endpoint: PlaygroundEndpoint; baseUrl: string; model: string; + provider?: string; systemPrompt: string; params: PlaygroundParams; } @@ -46,6 +49,10 @@ const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [ */ export default function StudioConfigPane({ configState, setConfigState }: StudioConfigPaneProps) { const [collapsed, setCollapsed] = useState(false); + const { provider, setProvider, providerOptions, loading: loadingProviders } = useProviderOptions( + configState.provider ?? "" + ); + const { availableModels, loading: loadingModels } = useAvailableModels(); function update(key: K, value: ConfigState[K]) { setConfigState({ ...configState, [key]: value }); @@ -108,18 +115,56 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
+ {/* Provider */} +
+ + +
+ {/* Model */}
- update("model", e.target.value)} - placeholder="e.g. openai/gpt-4o" - className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main" - /> + {availableModels.length > 0 ? ( + + ) : ( + update("model", e.target.value)} + placeholder="e.g. openai/gpt-4o" + className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main" + /> + )}
{/* System prompt */} diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx index 40bfb78c98..a3a5c521a1 100644 --- a/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx @@ -6,9 +6,8 @@ import { useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { useToolsBuilder } from "../../hooks/useToolsBuilder"; import { useStructuredOutput } from "../../hooks/useStructuredOutput"; -import ToolsBuilder from "../ToolsBuilder"; -import StructuredOutputEditor from "../StructuredOutputEditor"; import MarkdownMessage from "../MarkdownMessage"; +import BuildWizard from "./build/BuildWizard"; import type { ConfigState } from "../StudioConfigPane"; interface BuildTabProps { @@ -225,177 +224,104 @@ export default function BuildTab({ configState }: BuildTabProps) { await runRequest(newMessages); } - function clearConversation() { - setMessages([]); - setToolCalls([]); - setToolResultDrafts([]); - setValidationResult(null); - setPrompt(""); - } - - return ( -
- {/* Left panel: conversation + run */} -
- {/* Toolbar */} -
- - - {messages.length > 0 && ( - - )} - -
- {toolsBuilder.tools.length > 0 && ( - - {toolsBuilder.tools.length} tool{toolsBuilder.tools.length !== 1 ? "s" : ""} - - )} - {structuredOutput.enabled && ( - - JSON mode - + {msg.role === "user" ? ( + {msg.content} + ) : ( + )}
+ ))} - {/* Conversation history */} -
- {messages.map((msg, idx) => ( -
+ {/* Tool call UI */} + {toolCalls.length > 0 && ( +
+ {toolCalls.map((tc) => { + const draft = toolResultDrafts.find((d) => d.toolCallId === tc.id); + return (
- {msg.role === "user" ? ( - {msg.content} - ) : ( - - )} -
-
- ))} - - {/* Tool call UI */} - {toolCalls.length > 0 && ( -
- {toolCalls.map((tc) => { - const draft = toolResultDrafts.find((d) => d.toolCallId === tc.id); - return ( -
+ + function + + + {tc.function.name} + +
+
+                  {tc.function.arguments}
+                
+
+ +