Compare commits

...

17 Commits

Author SHA1 Message Date
diegosouzapw ab4914ee6a chore(release): v3.3.7 — OpenCode config fix, i18n keys fix
Build Electron Desktop App / Validate version (push) Failing after 31s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
2026-03-30 19:30:18 -03:00
diegosouzapw e7c73c76dd chore(release): bump version to v3.3.7 2026-03-30 19:28:20 -03:00
diegosouzapw 3591a3fe5c fix: resolve opencode json structure to use record mapping instead of array (#816) 2026-03-30 19:23:25 -03:00
diegosouzapw fbdce049b2 fix: add missing cloudflaredUrlNotice i18n keys (#823) 2026-03-30 19:23:14 -03:00
diegosouzapw 9a8520a2de fix: add missing cloudflaredUrlNotice i18n keys to prevent MISSING_MESSAGE console errors (#823) 2026-03-30 19:16:50 -03:00
diegosouzapw 0b2c488a61 chore(release): bump version to v3.3.6
Build Electron Desktop App / Validate version (push) Failing after 30s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
2026-03-30 18:24:15 -03:00
diegosouzapw 76e135077b Resolve merge conflicts with main natively built Prompt Cache UI 2026-03-30 18:20:19 -03:00
Diego Rodrigues de Sa e Souza 6078cd2eab Merge pull request #829 from rdself/coder/fix-cloudflared-startup
Fix cloudflared quick tunnel startup in Docker
2026-03-30 18:18:03 -03:00
Diego Rodrigues de Sa e Souza 3482dade71 Merge pull request #828 from rdself/coder/fix-combo-test-false-negative
Fix combo test false negatives and parallelize model probes
2026-03-30 18:18:00 -03:00
diegosouzapw 04d0c350db build: sync monorepo package versions across electron and open-sse 2026-03-30 18:02:33 -03:00
R.D. b6a5c91045 Install CA certificates in runtime image 2026-03-30 17:01:50 -04:00
diegosouzapw 7a37c79ebc ci: fix pipeline errors and enforce route lint validatation 2026-03-30 17:54:44 -03:00
R.D. ba227c5ec3 Run combo health probes concurrently 2026-03-30 16:49:01 -04:00
R.D. b492c5ac1a Fix cloudflared startup TLS handling 2026-03-30 16:31:07 -04:00
R.D. 03a860dd6f Fix combo smoke tests for reasoning responses 2026-03-30 16:23:53 -04:00
tombii 007b5d7f50 fix(test): split CacheStatsCard check into cache page test
Integration test was failing because CacheStatsCard was moved from
settings page to cache page in previous commit. Split the test into
two separate describe blocks for accurate page-specific verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:49:57 +02:00
tombii c6eadc504b fix(usage): include cache tokens in input token counts
- Fix getLoggedInputTokens to return full prompt_tokens (input + cache_read + cache_creation)
- Fix usageExtractor for non-streaming Claude responses to calculate total correctly
- Add formatUsageLog helper to show CR=<cache_read> in logs
- Add migration 012 to fix historical token counts in usage_history
- Move prompt cache metrics from Settings to /dashboard/cache page

Per Claude API docs:
Total input tokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens

Fixes issue where totalInputTokens (71k) was less than totalCacheCreationTokens (1.35M).

Tested:
- All 1134 unit tests pass
- Cache metrics API returns correct totals
- Migration is idempotent and tracked in _omniroute_migrations
- Logs show cache read tokens: 'in=6055 | out=211 | CR=22399'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:24:26 +02:00
61 changed files with 750 additions and 255 deletions
+12 -1
View File
@@ -96,7 +96,18 @@ Keep an empty `## [Unreleased]` section above it.
// turbo
```bash
VERSION=$(node -p "require('./package.json').version") && sed -i "s/ version: .*/ version: $VERSION/" docs/openapi.yaml && echo "✓ openapi.yaml → $VERSION"
VERSION=$(node -p "require('./package.json').version")
sed -i "s/ version: .*/ version: $VERSION/" docs/openapi.yaml
echo "✓ openapi.yaml → $VERSION"
for dir in electron open-sse; do
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
echo "$dir/package.json → $VERSION"
fi
done
# Re-run install to assert the workspace lockfile is updated
npm install
```
### 6. Update README.md and i18n docs
+9 -8
View File
@@ -57,17 +57,18 @@ jobs:
- name: Resolve version and dist-tag
id: resolve
run: |
case "${{ github.event_name }}" in
workflow_dispatch|workflow_call)
VERSION="${{ inputs.version }}"
TAG="${{ inputs.tag }}"
;;
release)
VERSION="${{ inputs.version }}"
TAG="${{ inputs.tag }}"
if [ -z "$VERSION" ]; then
if [ "${{ github.event_name }}" = "release" ]; then
VERSION="${GITHUB_REF_NAME}"
;;
esac
fi
fi
# Strip v prefix if present
VERSION="${VERSION#v}"
# Default dist-tag logic
if [ -z "$TAG" ]; then
if [[ "$VERSION" == *-* ]]; then
+19 -1
View File
@@ -4,6 +4,25 @@
---
## [3.3.7] - 2026-03-30
### 🐛 Bug Fixes
- **OpenCode Config:** Restructured generated `opencode.json` to use the `@ai-sdk/openai-compatible` record-based schema with `options` and `models` as object maps instead of flat arrays, fixing config validation failures (#816)
- **i18n Missing Keys:** Added missing `cloudflaredUrlNotice` translation key across all 30 language files to prevent `MISSING_MESSAGE` console errors in the Endpoint page (#823)
---
## [3.3.6] - 2026-03-30
### 🐛 Bug Fixes
- **Token Accounting:** Included prompt cache tokens safely in historical usage inputs calculations for correct quota deductions (PR #822)
- **Combo Test Probes:** Fixed combo testing logic false negatives by resolving parsing for reasoning-only responses and enabled massive parallelization via Promise.all (PR #828)
- **Docker Quick Tunnels:** Embedded required ca-certificates inside the base runtime container to resolve Cloudflared TLS startup failures, and surfaced stdout network errors replacing generic exit codes (PR #829)
---
## [3.3.5] - 2026-03-30
### ✨ New Features
@@ -13,7 +32,6 @@
### 🐛 Bug Fixes
- **Token Accounting:** Included prompt cache tokens safely in historical usage inputs calculations for correct quota deductions (PR #822)
- **User Experience:** Removed invasive auto-opening OAuth modal loops on barren provider detailed pages (PR #820)
- **Dependency Updates:** Bumped and locked down dependencies for development and production trees including Next.js 16.2.1, Recharts, and TailwindCSS 4.2.2 (PR #826, #827)
+2 -2
View File
@@ -2,7 +2,7 @@ FROM node:22-bookworm-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libsecret-1-0 \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
@@ -30,7 +30,7 @@ ENV NODE_OPTIONS="--max-old-space-size=256"
# Data directory inside Docker — must match the volume mount in docker-compose.yml
ENV DATA_DIR=/app/data
RUN apt-get update \
&& apt-get install -y --no-install-recommends libsecret-1-0 \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /app/data
+1
View File
@@ -882,6 +882,7 @@ Notes:
- Quick Tunnel URLs are temporary and change after every restart.
- Managed install currently supports Linux, macOS, and Windows on `x64` / `arm64`.
- Docker images bundle system CA roots and pass them to managed `cloudflared`, which avoids TLS trust failures when the tunnel bootstraps inside the container.
- Set `CLOUDFLARED_BIN=/absolute/path/to/cloudflared` if you want OmniRoute to use an existing binary instead of downloading one.
**Using Docker Compose with Caddy (HTTPS Auto-TLS):**
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.3.5
version: 3.3.7
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "2.3.13",
"version": "3.3.7",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {
+6 -2
View File
@@ -32,7 +32,11 @@ import {
appendRequestLog,
saveCallLog,
} from "@/lib/usageDb";
import { getLoggedInputTokens, getLoggedOutputTokens } from "@/lib/usage/tokenAccounting";
import {
getLoggedInputTokens,
getLoggedOutputTokens,
formatUsageLog,
} from "@/lib/usage/tokenAccounting";
import { recordCost } from "@/domain/costRules";
import { calculateCost } from "@/lib/usage/costCalculator";
import { CLAUDE_OAUTH_TOOL_PREFIX } from "../translator/request/openai-to-claude.ts";
@@ -1432,7 +1436,7 @@ export async function handleChatCore({
// Save structured call log with full payloads
const cacheUsageLogMeta = buildCacheUsageLogMeta(usage);
if (usage && typeof usage === "object") {
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | in=${getLoggedInputTokens(usage)} | out=${getLoggedOutputTokens(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}`);
// Track cache token metrics
+1 -1
View File
@@ -408,7 +408,7 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco
const choiceObj = toRecord(choice);
const messageObj = toRecord(choiceObj.message);
const content = [];
const content: JsonRecord[] = [];
let hasTextOrReasoning = false;
+10 -3
View File
@@ -46,11 +46,18 @@ export function extractUsageFromResponse(responseBody, provider) {
(responseBody.usage.input_tokens !== undefined ||
responseBody.usage.output_tokens !== undefined)
) {
const inputTokens = responseBody.usage.input_tokens || 0;
const cacheRead = responseBody.usage.cache_read_input_tokens || 0;
const cacheCreation = responseBody.usage.cache_creation_input_tokens || 0;
// Total prompt tokens = input + cache_read + cache_creation (per Claude API docs)
const promptTokens = inputTokens + cacheRead + cacheCreation;
return {
prompt_tokens: responseBody.usage.input_tokens || 0,
prompt_tokens: promptTokens,
completion_tokens: responseBody.usage.output_tokens || 0,
cache_read_input_tokens: responseBody.usage.cache_read_input_tokens,
cache_creation_input_tokens: responseBody.usage.cache_creation_input_tokens,
cache_read_input_tokens: cacheRead,
cache_creation_input_tokens: cacheCreation,
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "0.0.1",
"version": "3.3.7",
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",
+13 -16
View File
@@ -515,23 +515,20 @@ async function getGeminiCliSubscriptionInfoCached(accessToken) {
*/
async function getGeminiCliSubscriptionInfo(accessToken) {
try {
const response = await fetch(
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
const response = await fetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
}
);
}),
});
if (!response.ok) return null;
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.3.5",
"version": "3.3.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.3.5",
"version": "3.3.7",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -20324,7 +20324,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "0.0.1"
"version": "3.3.7"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.3.5",
"version": "3.3.7",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
@@ -987,7 +987,6 @@ export default function ProviderDetailPage() {
}
}, [loading, connections, loadConnProxies]);
const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => {
const fullModel = `${providerAliasOverride}/${modelId}`;
try {
@@ -1469,7 +1468,10 @@ export default function ProviderDetailPage() {
logs: [
t("foundModelsStartingImport", { count: newModels.length }),
...(newModels.length < fetchedModels.length
? [t("skippingExistingModels", { count: fetchedModels.length - newModels.length }) || `Skipping ${fetchedModels.length - newModels.length} existing models`]
? [
t("skippingExistingModels", { count: fetchedModels.length - newModels.length }) ||
`Skipping ${fetchedModels.length - newModels.length} existing models`,
]
: []),
],
}));
@@ -16,8 +16,6 @@ import CodexServiceTierTab from "./components/CodexServiceTierTab";
import SystemPromptTab from "./components/SystemPromptTab";
import ModelAliasesTab from "./components/ModelAliasesTab";
import BackgroundDegradationTab from "./components/BackgroundDegradationTab";
import CacheStatsCard from "./components/CacheStatsCard";
import CacheSettingsTab from "./components/CacheSettingsTab";
import ResilienceTab from "./components/ResilienceTab";
@@ -89,7 +87,6 @@ export default function SettingsPage() {
<ThinkingBudgetTab />
<CodexServiceTierTab />
<SystemPromptTab />
<CacheStatsCard />
<CacheSettingsTab />
</div>
)}
+86 -87
View File
@@ -5,6 +5,87 @@ import { getComboByName } from "@/lib/localDb";
import { testComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
async function testComboModel(modelStr, internalUrl) {
const startTime = Date.now();
try {
// Send a minimal but real chat request through the same internal
// endpoint an external OpenAI-compatible client would use.
const testBody = buildComboTestRequestBody(modelStr);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
let res;
try {
res = await fetch(internalUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Internal dashboard tests still use the normal /v1 pipeline but
// bypass REQUIRE_API_KEY so admins can test with local session auth.
"X-Internal-Test": "combo-health-check",
// Force a fresh execution path so combo tests cannot be satisfied by
// OmniRoute's semantic cache or other request reuse layers.
"X-OmniRoute-No-Cache": "true",
"X-Request-Id": `combo-test-${randomUUID()}`,
},
body: JSON.stringify(testBody),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
const latencyMs = Date.now() - startTime;
if (res.ok) {
let responseBody = null;
try {
responseBody = await res.json();
} catch {
responseBody = null;
}
const responseText = extractComboTestResponseText(responseBody);
if (!responseText) {
return {
model: modelStr,
status: "error",
statusCode: res.status,
error: "Provider returned HTTP 200 but no text content.",
latencyMs,
};
}
return { model: modelStr, status: "ok", latencyMs, responseText };
}
let errorMsg = "";
try {
const errBody = await res.json();
errorMsg = errBody?.error?.message || errBody?.error || res.statusText;
} catch {
errorMsg = res.statusText;
}
return {
model: modelStr,
status: "error",
statusCode: res.status,
error: errorMsg,
latencyMs,
};
} catch (error) {
const latencyMs = Date.now() - startTime;
return {
model: modelStr,
status: "error",
error: error.name === "AbortError" ? "Timeout (20s)" : error.message,
latencyMs,
};
}
}
/**
* POST /api/combos/test - Quick test a combo
* Sends a real chat completion request through each model in the combo
@@ -44,93 +125,11 @@ export async function POST(request) {
return NextResponse.json({ error: "Combo has no models" }, { status: 400 });
}
const results = [];
let resolvedBy = null;
// Test each model sequentially
for (const modelStr of models) {
const startTime = Date.now();
try {
// Send a minimal but real chat request through the same internal
// endpoint an external OpenAI-compatible client would use.
const testBody = buildComboTestRequestBody(modelStr);
const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
let res;
try {
res = await fetch(internalUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Internal dashboard tests still use the normal /v1 pipeline but
// bypass REQUIRE_API_KEY so admins can test with local session auth.
"X-Internal-Test": "combo-health-check",
// Force a fresh execution path so combo tests cannot be satisfied by
// OmniRoute's semantic cache or other request reuse layers.
"X-OmniRoute-No-Cache": "true",
"X-Request-Id": `combo-test-${randomUUID()}`,
},
body: JSON.stringify(testBody),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
const latencyMs = Date.now() - startTime;
if (res.ok) {
let responseBody = null;
try {
responseBody = await res.json();
} catch {
responseBody = null;
}
const responseText = extractComboTestResponseText(responseBody);
if (!responseText) {
results.push({
model: modelStr,
status: "error",
statusCode: res.status,
error: "Provider returned HTTP 200 but no text content.",
latencyMs,
});
continue;
}
results.push({ model: modelStr, status: "ok", latencyMs, responseText });
if (!resolvedBy) resolvedBy = modelStr;
} else {
let errorMsg = "";
try {
const errBody = await res.json();
errorMsg = errBody?.error?.message || errBody?.error || res.statusText;
} catch {
errorMsg = res.statusText;
}
results.push({
model: modelStr,
status: "error",
statusCode: res.status,
error: errorMsg,
latencyMs,
});
}
} catch (error) {
const latencyMs = Date.now() - startTime;
results.push({
model: modelStr,
status: "error",
error: error.name === "AbortError" ? "Timeout (20s)" : error.message,
latencyMs,
});
}
}
const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`;
const results = await Promise.all(
models.map((modelStr) => testComboModel(modelStr, internalUrl))
);
const resolvedBy = results.find((result) => result.status === "ok")?.model || null;
return NextResponse.json({
comboName,
+3 -8
View File
@@ -504,8 +504,7 @@ export async function GET(
}
const psd = asRecord(connection.providerSpecificData);
const projectId =
connection.projectId || psd.projectId || null;
const projectId = connection.projectId || psd.projectId || null;
if (!projectId) {
return NextResponse.json(
@@ -538,8 +537,7 @@ export async function GET(
}
const quotaData = await quotaRes.json();
const buckets: Array<{ modelId?: string; tokenType?: string }> =
quotaData.buckets || [];
const buckets: Array<{ modelId?: string; tokenType?: string }> = quotaData.buckets || [];
const models = buckets
.filter((b) => b.modelId)
@@ -553,10 +551,7 @@ export async function GET(
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.log("[models] Gemini CLI model fetch error:", msg);
return NextResponse.json(
{ error: "Failed to fetch Gemini CLI models" },
{ status: 500 }
);
return NextResponse.json({ error: "Failed to fetch Gemini CLI models" }, { status: 500 });
}
}
+31 -8
View File
@@ -1,6 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { z } from "zod";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const cacheConfigUpdateSchema = z.object({
semanticCacheEnabled: z.boolean().optional(),
semanticCacheMaxSize: z.number().positive().optional(),
semanticCacheTTL: z.number().positive().optional(),
promptCacheEnabled: z.boolean().optional(),
promptCacheStrategy: z.enum(["auto", "system-only", "manual"]).optional(),
alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(),
});
const CACHE_CONFIG_KEYS = [
"semanticCacheEnabled",
@@ -43,25 +54,37 @@ export async function PUT(request: NextRequest) {
}
try {
const body = await request.json();
const updates: Record<string, unknown> = {};
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
if (typeof body.semanticCacheEnabled === "boolean") {
const validation = validateBody(cacheConfigUpdateSchema, rawBody);
if (isValidationFailure(validation)) {
return validation.response;
}
const updates: Record<string, unknown> = {};
const body = validation.data;
if (body.semanticCacheEnabled !== undefined) {
updates.semanticCacheEnabled = body.semanticCacheEnabled;
}
if (typeof body.semanticCacheMaxSize === "number" && body.semanticCacheMaxSize > 0) {
if (body.semanticCacheMaxSize !== undefined) {
updates.semanticCacheMaxSize = body.semanticCacheMaxSize;
}
if (typeof body.semanticCacheTTL === "number" && body.semanticCacheTTL > 0) {
if (body.semanticCacheTTL !== undefined) {
updates.semanticCacheTTL = body.semanticCacheTTL;
}
if (typeof body.promptCacheEnabled === "boolean") {
if (body.promptCacheEnabled !== undefined) {
updates.promptCacheEnabled = body.promptCacheEnabled;
}
if (["auto", "system-only", "manual"].includes(body.promptCacheStrategy)) {
if (body.promptCacheStrategy !== undefined) {
updates.promptCacheStrategy = body.promptCacheStrategy;
}
if (["auto", "always", "never"].includes(body.alwaysPreserveClientCache)) {
if (body.alwaysPreserveClientCache !== undefined) {
updates.alwaysPreserveClientCache = body.alwaysPreserveClientCache;
}
+10 -9
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
getCloudflaredTunnelStatus,
startCloudflaredTunnel,
@@ -40,27 +41,27 @@ export async function POST(request: NextRequest) {
return unauthorized();
}
let payload: unknown;
let rawBody: unknown;
try {
payload = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = actionSchema.safeParse(payload);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const validation = validateBody(actionSchema, rawBody);
if (isValidationFailure(validation)) {
return validation.response;
}
const parsed = validation.data;
try {
const status =
parsed.data.action === "enable"
? await startCloudflaredTunnel()
: await stopCloudflaredTunnel();
parsed.action === "enable" ? await startCloudflaredTunnel() : await stopCloudflaredTunnel();
return NextResponse.json({
success: true,
action: parsed.data.action,
action: parsed.action,
status,
});
} catch (error) {
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "جارٍ تحميل لوحة تحكم MCP...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Зареждане на таблото за управление на MCP...",
+2 -1
View File
@@ -1062,7 +1062,8 @@
"a2aQuickStartStep2": "Odešlete požadavky JSON-RPC na `POST /a2a` pomocí `message/send` nebo `message/stream`.",
"a2aQuickStartStep3": "Sledujte a ovládejte úkoly pomocí příkazů `tasks/get` a `tasks/cancel`.",
"completionsLegacy": "Completions (Zastaralé)",
"completionsLegacyDesc": "Zastaralé OpenAI text completion akceptuje oba formáty, prompt string i messages array."
"completionsLegacyDesc": "Zastaralé OpenAI text completion akceptuje oba formáty, prompt string i messages array.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"endpoints": {
"tabProxy": "Koncová Proxy",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Indlæser MCP-dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "MCP-Dashboard wird geladen...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Crea un Quick Tunnel temporal de Cloudflare. La URL cambia después de cada reinicio."
},
"mcpDashboard": {
"loading": "Cargando el panel de MCP...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Ladataan MCP-hallintapaneelia...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Chargement du tableau de bord MCP...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -919,7 +919,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1062,7 +1062,8 @@
"a2aQuickStartStep2": "`message/send` या `message/stream` का उपयोग करके JSON-RPC अनुरोधों को `POST /a2a` पर भेजें।",
"a2aQuickStartStep3": "`कार्य/प्राप्त करें` और `कार्य/रद्द करें` का उपयोग करके कार्यों को ट्रैक और नियंत्रित करें।",
"completionsLegacy": "पूर्णताएँ (विरासत)",
"completionsLegacyDesc": "लीगेसी ओपनएआई टेक्स्ट पूर्णताएँ - शीघ्र स्ट्रिंग और संदेश सरणी प्रारूप दोनों को स्वीकार करती हैं"
"completionsLegacyDesc": "लीगेसी ओपनएआई टेक्स्ट पूर्णताएँ - शीघ्र स्ट्रिंग और संदेश सरणी प्रारूप दोनों को स्वीकार करती हैं",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"endpoints": {
"tabProxy": "समापन बिंदु प्रॉक्सी",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Laster inn MCP-dashbordet ...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Nilo-load ang MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1023,7 +1023,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Cria um Quick Tunnel temporário do Cloudflare. A URL muda a cada reinício."
},
"mcpDashboard": {
"loading": "Carregando painel MCP...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"endpoints": {
"tabProxy": "Endpoint Proxy",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1060,7 +1060,8 @@
"a2aQuickStartStep2": "JSON-RPC isteklerini `message/send` veya `message/stream` kullanarak `POST /a2a` adresine gönderin.",
"a2aQuickStartStep3": "Görevleri `tasks/get` ve `tasks/cancel` ile izleyin ve yönetin.",
"completionsLegacy": "Tamamlamalar (Eski)",
"completionsLegacyDesc": "Eski OpenAI metin tamamlamaları — hem bilgi istemi dizesini hem de mesaj dizisi biçimini kabul eder"
"completionsLegacyDesc": "Eski OpenAI metin tamamlamaları — hem bilgi istemi dizesini hem de mesaj dizisi biçimini kabul eder",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"endpoints": {
"tabProxy": "Uç Nokta Proxy",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+2 -1
View File
@@ -1011,7 +1011,8 @@
"webSearch": "Web Search",
"webSearchDesc": "Unified web search across multiple providers with automatic failover and caching",
"searchProvider": "Search Provider",
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."
"searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.",
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
+89 -12
View File
@@ -13,6 +13,18 @@ const CLOUDFLARED_RELEASE_BASE =
"https://github.com/cloudflare/cloudflared/releases/latest/download";
const START_TIMEOUT_MS = 30000;
const STOP_TIMEOUT_MS = 5000;
const GENERIC_EXIT_ERROR_PREFIX = "cloudflared exited";
const DEFAULT_CERT_FILE_CANDIDATES = [
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/cert.pem",
"/private/etc/ssl/cert.pem",
] as const;
const DEFAULT_CERT_DIR_CANDIDATES = [
"/etc/ssl/certs",
"/etc/pki/tls/certs",
"/system/etc/security/cacerts",
] as const;
type CloudflaredInstallSource = "managed" | "path" | "env";
type TunnelPhase = "unsupported" | "not_installed" | "stopped" | "starting" | "running" | "error";
@@ -238,9 +250,55 @@ export function extractTryCloudflareUrl(text: string) {
return match ? match[0] : null;
}
function normalizeCloudflaredLogLine(line: string) {
return line
.trim()
.replace(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:INF|WRN|ERR)\s+/i, "")
.trim();
}
export function extractCloudflaredErrorMessage(text: string) {
const lines = String(text || "")
.split(/\r?\n/)
.map(normalizeCloudflaredLogLine)
.filter(Boolean);
for (let i = lines.length - 1; i >= 0; i--) {
if (/(?:\berror\b|\bfailed\b|\btls:\b|\bx509\b|\bcertificate\b)/i.test(lines[i])) {
return lines[i];
}
}
return null;
}
function isSpecificCloudflaredError(error: string | null | undefined) {
return !!error && !error.startsWith(GENERIC_EXIT_ERROR_PREFIX);
}
function getGenericExitError(code: number | null, signal: NodeJS.Signals | null) {
return `cloudflared exited unexpectedly (${code ?? "signal"}${signal ? `/${signal}` : ""})`;
}
export function getDefaultCloudflaredCertEnv(
existsSync: (candidate: string) => boolean = fsSync.existsSync,
certFileCandidates: readonly string[] = DEFAULT_CERT_FILE_CANDIDATES,
certDirCandidates: readonly string[] = DEFAULT_CERT_DIR_CANDIDATES
) {
const certEnv: NodeJS.ProcessEnv = {};
const certFile = certFileCandidates.find((candidate) => existsSync(candidate));
const certDir = certDirCandidates.find((candidate) => existsSync(candidate));
if (certFile) certEnv.SSL_CERT_FILE = certFile;
if (certDir) certEnv.SSL_CERT_DIR = certDir;
return certEnv;
}
export function buildCloudflaredChildEnv(
sourceEnv: NodeJS.ProcessEnv = process.env,
runtimeDirs: CloudflaredRuntimeDirs = getCloudflaredRuntimeDirs()
runtimeDirs: CloudflaredRuntimeDirs = getCloudflaredRuntimeDirs(),
defaultCertEnv: NodeJS.ProcessEnv = getDefaultCloudflaredCertEnv()
): NodeJS.ProcessEnv {
const childEnv: NodeJS.ProcessEnv = {};
@@ -262,6 +320,12 @@ export function buildCloudflaredChildEnv(
if (!childEnv.TMPDIR) childEnv.TMPDIR = runtimeDirs.tempDir;
if (!childEnv.TMP) childEnv.TMP = runtimeDirs.tempDir;
if (!childEnv.TEMP) childEnv.TEMP = runtimeDirs.tempDir;
if (!childEnv.SSL_CERT_FILE && defaultCertEnv.SSL_CERT_FILE) {
childEnv.SSL_CERT_FILE = defaultCertEnv.SSL_CERT_FILE;
}
if (!childEnv.SSL_CERT_DIR && defaultCertEnv.SSL_CERT_DIR) {
childEnv.SSL_CERT_DIR = defaultCertEnv.SSL_CERT_DIR;
}
return childEnv;
}
@@ -447,7 +511,9 @@ async function finalizeProcessExit(code: number | null, signal: NodeJS.Signals |
const lastError =
code === 0 || signal === "SIGTERM" || signal === "SIGINT"
? null
: `cloudflared exited unexpectedly (${code ?? "signal"}${signal ? `/${signal}` : ""})`;
: isSpecificCloudflaredError(currentState.lastError)
? currentState.lastError
: getGenericExitError(code, signal);
tunnelProcess = null;
tunnelPid = null;
@@ -562,14 +628,10 @@ export async function startCloudflaredTunnel(): Promise<CloudflaredTunnelStatus>
startedAt: new Date().toISOString(),
});
const child = spawn(
binary.binaryPath as string,
getCloudflaredStartArgs(targetUrl),
{
stdio: ["ignore", "pipe", "pipe"],
env: buildCloudflaredChildEnv(),
}
);
const child = spawn(binary.binaryPath as string, getCloudflaredStartArgs(targetUrl), {
stdio: ["ignore", "pipe", "pipe"],
env: buildCloudflaredChildEnv(),
});
tunnelProcess = child;
tunnelPid = child.pid ?? null;
@@ -597,6 +659,14 @@ export async function startCloudflaredTunnel(): Promise<CloudflaredTunnelStatus>
if (!text) return;
await appendTunnelLog(source, text);
const errorMessage = source === "stderr" ? extractCloudflaredErrorMessage(text) : null;
if (errorMessage) {
await updateStateFile({
pid: child.pid,
status: "error",
lastError: errorMessage,
});
}
const url = extractTryCloudflareUrl(text);
if (!url) return;
@@ -643,11 +713,18 @@ export async function startCloudflaredTunnel(): Promise<CloudflaredTunnelStatus>
try {
return await startPromise;
} catch (error) {
const currentState = await readStateFile();
const message = isSpecificCloudflaredError(currentState.lastError)
? currentState.lastError
: error instanceof Error
? error.message
: "Failed to start cloudflared tunnel";
await updateStateFile({
status: "error",
lastError: error instanceof Error ? error.message : "Failed to start cloudflared tunnel",
lastError: message,
});
throw error;
throw new Error(message);
} finally {
startPromise = null;
}
+96 -3
View File
@@ -4,6 +4,10 @@ function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function joinNonEmpty(parts: string[]) {
return parts.filter(Boolean).join("\n").trim();
}
function extractTextFromContent(content: unknown): string {
if (typeof content === "string") return content.trim();
@@ -28,12 +32,85 @@ function extractTextFromContent(content: unknown): string {
.trim();
}
function extractReasoningText(record: JsonRecord): string {
const reasoningDetails = Array.isArray(record.reasoning_details) ? record.reasoning_details : [];
const detailText = reasoningDetails
.map((detail) => {
const detailRecord = asRecord(detail);
const detailType = typeof detailRecord.type === "string" ? detailRecord.type : "";
const text =
typeof detailRecord.text === "string"
? detailRecord.text.trim()
: typeof detailRecord.content === "string"
? detailRecord.content.trim()
: "";
if (
text &&
(detailType === "" ||
detailType === "reasoning" ||
detailType === "reasoning.text" ||
detailType === "thinking")
) {
return text;
}
return "";
})
.filter(Boolean);
return joinNonEmpty([
typeof record.reasoning_content === "string" ? record.reasoning_content.trim() : "",
typeof record.reasoning === "string" ? record.reasoning.trim() : "",
typeof record.reasoning_text === "string" ? record.reasoning_text.trim() : "",
joinNonEmpty(detailText),
]);
}
function getUsageReasoningTokens(body: JsonRecord): number {
const usage = asRecord(body.usage);
if (!usage) return 0;
const completionDetails = asRecord(usage.completion_tokens_details);
const topLevelReasoning =
typeof usage.reasoning_tokens === "number" && Number.isFinite(usage.reasoning_tokens)
? usage.reasoning_tokens
: 0;
const detailedReasoning =
typeof completionDetails.reasoning_tokens === "number" &&
Number.isFinite(completionDetails.reasoning_tokens)
? completionDetails.reasoning_tokens
: 0;
return Math.max(topLevelReasoning, detailedReasoning);
}
function hasReasoningOnlyCompletion(body: JsonRecord): boolean {
if (!Array.isArray(body.choices) || body.choices.length === 0) return false;
if (getUsageReasoningTokens(body) <= 0) return false;
return body.choices.some((choice) => {
const choiceRecord = asRecord(choice);
const message = asRecord(choiceRecord.message);
const finishReason =
typeof choiceRecord.finish_reason === "string" ? choiceRecord.finish_reason : "";
if (!message || message.role !== "assistant") return false;
if (!finishReason) return false;
if (extractTextFromContent(message.content)) return false;
if (extractReasoningText(message)) return false;
return true;
});
}
export function buildComboTestRequestBody(modelStr: string) {
return {
model: modelStr,
messages: [{ role: "user", content: "Reply with OK only." }],
// Keep this close to a real client request without inflating cost.
max_tokens: 16,
// Give reasoning-heavy models enough headroom to emit a tiny visible answer
// without turning the smoke test into a full-cost real request.
max_tokens: 64,
temperature: 0,
stream: false,
};
}
@@ -52,6 +129,9 @@ export function extractComboTestResponseText(responseBody: unknown): string {
const messageText = extractTextFromContent(message.content);
if (messageText) return messageText;
const reasoningText = extractReasoningText(message);
if (reasoningText) return reasoningText;
if (typeof choiceRecord.text === "string" && choiceRecord.text.trim()) {
return choiceRecord.text.trim();
}
@@ -63,8 +143,21 @@ export function extractComboTestResponseText(responseBody: unknown): string {
const itemRecord = asRecord(item);
const contentText = extractTextFromContent(itemRecord.content);
if (contentText) return contentText;
const reasoningText = extractReasoningText(itemRecord);
if (reasoningText) return reasoningText;
}
}
return extractTextFromContent(body.content);
const topLevelText = extractTextFromContent(body.content);
if (topLevelText) return topLevelText;
const topLevelReasoning = extractReasoningText(body);
if (topLevelReasoning) return topLevelReasoning;
if (hasReasoningOnlyCompletion(body)) {
return "[reasoning-only completion]";
}
return "";
}
@@ -0,0 +1,15 @@
-- Migration 012: Fix tokens_input to include cache tokens
--
-- Problem: Historical data stored tokens_input as just the base input_tokens
-- from the API, not including cache_read and cache_creation tokens.
--
-- Per Claude API docs:
-- Total input tokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens
--
-- This migration corrects historical records by adding cache tokens to tokens_input.
-- Only affects records where cache tokens exist.
-- Update tokens_input to include cache tokens
UPDATE usage_history
SET tokens_input = tokens_input + tokens_cache_read + tokens_cache_creation
WHERE tokens_cache_read > 0 OR tokens_cache_creation > 0;
+15 -15
View File
@@ -52,19 +52,9 @@ export function getLoggedInputTokens(tokens: unknown): number {
return toFiniteNumber(tokenRecord.input_tokens);
}
// prompt_tokens from translator already includes input + cache_read + cache_creation
// Do NOT subtract cached tokens - we want the total billable prompt tokens
const promptTokens = toFiniteNumber(tokenRecord.prompt_tokens);
if (promptTokens <= 0) return 0;
const promptDetails = getPromptTokenDetails(tokenRecord);
const cachedFromDetails = toFiniteNumber(promptDetails.cached_tokens);
if (cachedFromDetails > 0) {
return Math.max(promptTokens - cachedFromDetails, 0);
}
if ("cached_tokens" in tokenRecord && !("cache_read_input_tokens" in tokenRecord)) {
return Math.max(promptTokens - toFiniteNumber(tokenRecord.cached_tokens), 0);
}
return promptTokens;
}
@@ -73,7 +63,17 @@ export function getLoggedOutputTokens(tokens: unknown): number {
if (tokenRecord.output !== undefined && tokenRecord.output !== null) {
return toFiniteNumber(tokenRecord.output);
}
return toFiniteNumber(
tokenRecord.completion_tokens ?? tokenRecord.output_tokens
);
return toFiniteNumber(tokenRecord.completion_tokens ?? tokenRecord.output_tokens);
}
export function formatUsageLog(tokens: unknown): string {
const input = getLoggedInputTokens(tokens);
const output = getLoggedOutputTokens(tokens);
const cacheRead = getPromptCacheReadTokens(tokens);
let msg = `in=${input} | out=${output}`;
if (cacheRead > 0) {
msg += ` | CR=${cacheRead}`;
}
return msg;
}
+14 -13
View File
@@ -4,14 +4,6 @@ type OpenCodeConfigInput = {
model?: string;
};
type OpenCodeProviderConfig = {
name: string;
api: "openai";
baseURL: string;
apiKey: string;
models: string[];
};
const OPENCODE_DEFAULT_MODELS = [
"claude-opus-4-5-thinking",
"claude-sonnet-4-5-thinking",
@@ -28,7 +20,7 @@ export const buildOpenCodeProviderConfig = ({
baseUrl,
apiKey,
model,
}: OpenCodeConfigInput): OpenCodeProviderConfig => {
}: OpenCodeConfigInput): Record<string, any> => {
const normalizedBaseUrl = String(baseUrl || "")
.trim()
.replace(/\/+$/, "");
@@ -36,12 +28,21 @@ export const buildOpenCodeProviderConfig = ({
const uniqueModels = [...new Set([normalizedModel, ...OPENCODE_DEFAULT_MODELS].filter(Boolean))];
const modelsRecord: Record<string, { name: string }> = {};
for (const m of uniqueModels) {
if (m) {
modelsRecord[m] = { name: m };
}
}
return {
npm: "@ai-sdk/openai-compatible",
name: "OmniRoute",
api: "openai",
baseURL: normalizedBaseUrl,
apiKey: apiKey || "sk_omniroute",
models: uniqueModels,
options: {
baseURL: normalizedBaseUrl,
apiKey: apiKey || "sk_omniroute",
},
models: modelsRecord,
};
};
@@ -270,9 +270,17 @@ describe("Page Integration — usage page wiring", () => {
describe("Page Integration — settings page wiring", () => {
const src = readProjectFile("src/app/(dashboard)/dashboard/settings/page.tsx");
it("should include resilience and cache cards in tabs", () => {
it("should include resilience tab in advanced settings", () => {
assert.ok(src, "src/app/(dashboard)/dashboard/settings/page.tsx should exist");
assert.match(src, /ResilienceTab/);
});
});
describe("Page Integration — cache page wiring", () => {
const src = readProjectFile("src/app/(dashboard)/dashboard/cache/page.tsx");
it("should include cache stats card for prompt cache metrics", () => {
assert.ok(src, "src/app/(dashboard)/dashboard/cache/page.tsx should exist");
assert.match(src, /CacheStatsCard/);
});
});
+68 -16
View File
@@ -3,7 +3,9 @@ import assert from "node:assert/strict";
import {
buildCloudflaredChildEnv,
extractCloudflaredErrorMessage,
extractTryCloudflareUrl,
getDefaultCloudflaredCertEnv,
getCloudflaredStartArgs,
getCloudflaredAssetSpec,
} from "../../src/lib/cloudflaredTunnel.ts";
@@ -20,6 +22,17 @@ test("extractTryCloudflareUrl returns null when no tunnel URL is present", () =>
assert.equal(extractTryCloudflareUrl("cloudflared starting without assigned URL"), null);
});
test("extractCloudflaredErrorMessage keeps the actionable stderr line", () => {
const error = extractCloudflaredErrorMessage(
'2026-03-30T19:56:12Z INF Requesting new quick Tunnel on trycloudflare.com...\n2026-03-30T19:56:12Z ERR failed to request quick Tunnel: Post "https://api.trycloudflare.com/tunnel": tls: failed to verify certificate: x509: certificate signed by unknown authority'
);
assert.equal(
error,
'failed to request quick Tunnel: Post "https://api.trycloudflare.com/tunnel": tls: failed to verify certificate: x509: certificate signed by unknown authority'
);
});
test("getCloudflaredAssetSpec resolves linux amd64 binary", () => {
const spec = getCloudflaredAssetSpec("linux", "x64");
@@ -49,22 +62,26 @@ test("getCloudflaredAssetSpec returns null for unsupported platforms", () => {
});
test("buildCloudflaredChildEnv keeps runtime essentials, isolates runtime dirs, and drops secrets", () => {
const env = buildCloudflaredChildEnv({
PATH: "/usr/bin",
HTTPS_PROXY: "http://proxy.internal:8080",
JWT_SECRET: "top-secret",
API_KEY_SECRET: "another-secret",
}, {
runtimeRoot: "/managed/runtime",
homeDir: "/managed/runtime/home",
configDir: "/managed/runtime/config",
cacheDir: "/managed/runtime/cache",
dataDir: "/managed/runtime/data",
tempDir: "/managed/runtime/tmp",
userProfileDir: "/managed/runtime/userprofile",
appDataDir: "/managed/runtime/userprofile/AppData/Roaming",
localAppDataDir: "/managed/runtime/userprofile/AppData/Local",
});
const env = buildCloudflaredChildEnv(
{
PATH: "/usr/bin",
HTTPS_PROXY: "http://proxy.internal:8080",
JWT_SECRET: "top-secret",
API_KEY_SECRET: "another-secret",
},
{
runtimeRoot: "/managed/runtime",
homeDir: "/managed/runtime/home",
configDir: "/managed/runtime/config",
cacheDir: "/managed/runtime/cache",
dataDir: "/managed/runtime/data",
tempDir: "/managed/runtime/tmp",
userProfileDir: "/managed/runtime/userprofile",
appDataDir: "/managed/runtime/userprofile/AppData/Roaming",
localAppDataDir: "/managed/runtime/userprofile/AppData/Local",
},
{}
);
assert.deepEqual(env, {
PATH: "/usr/bin",
@@ -82,6 +99,41 @@ test("buildCloudflaredChildEnv keeps runtime essentials, isolates runtime dirs,
});
});
test("getDefaultCloudflaredCertEnv detects common CA bundle paths", () => {
const env = getDefaultCloudflaredCertEnv((candidate) =>
["/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/certs"].includes(candidate)
);
assert.deepEqual(env, {
SSL_CERT_FILE: "/etc/ssl/certs/ca-certificates.crt",
SSL_CERT_DIR: "/etc/ssl/certs",
});
});
test("buildCloudflaredChildEnv injects discovered CA paths when the parent env omits them", () => {
const env = buildCloudflaredChildEnv(
{ PATH: "/usr/bin" },
{
runtimeRoot: "/managed/runtime",
homeDir: "/managed/runtime/home",
configDir: "/managed/runtime/config",
cacheDir: "/managed/runtime/cache",
dataDir: "/managed/runtime/data",
tempDir: "/managed/runtime/tmp",
userProfileDir: "/managed/runtime/userprofile",
appDataDir: "/managed/runtime/userprofile/AppData/Roaming",
localAppDataDir: "/managed/runtime/userprofile/AppData/Local",
},
{
SSL_CERT_FILE: "/etc/ssl/certs/ca-certificates.crt",
SSL_CERT_DIR: "/etc/ssl/certs",
}
);
assert.equal(env.SSL_CERT_FILE, "/etc/ssl/certs/ca-certificates.crt");
assert.equal(env.SSL_CERT_DIR, "/etc/ssl/certs");
});
test("getCloudflaredStartArgs relies on cloudflared protocol auto-negotiation", () => {
assert.deepEqual(getCloudflaredStartArgs("http://127.0.0.1:20128"), [
"tunnel",
+58 -1
View File
@@ -9,7 +9,8 @@ test("combo test helper builds a realistic smoke payload", () => {
assert.equal(body.model, "openrouter/openai/gpt-5.4");
assert.equal(body.messages[0].content, "Reply with OK only.");
assert.equal(body.max_tokens, 16);
assert.equal(body.max_tokens, 64);
assert.equal(body.temperature, 0);
assert.equal(body.stream, false);
});
@@ -46,6 +47,62 @@ test("combo test helper extracts text from block-based responses", () => {
assert.equal(text, "OK\nConfirmed.");
});
test("combo test helper extracts reasoning content when visible text is absent", () => {
const text = extractComboTestResponseText({
choices: [
{
message: {
role: "assistant",
content: null,
reasoning_content: "Working through the request.\nOK",
},
},
],
});
assert.equal(text, "Working through the request.\nOK");
});
test("combo test helper extracts reasoning_text aliases from GitHub-style responses", () => {
const text = extractComboTestResponseText({
choices: [
{
message: {
role: "assistant",
content: "",
reasoning_text: "Reasoning trace",
},
},
],
});
assert.equal(text, "Reasoning trace");
});
test("combo test helper treats reasoning-only completions as a healthy signal", () => {
const text = extractComboTestResponseText({
choices: [
{
finish_reason: "length",
message: {
role: "assistant",
content: "",
},
},
],
usage: {
prompt_tokens: 6,
completion_tokens: 12,
total_tokens: 18,
completion_tokens_details: {
reasoning_tokens: 12,
},
},
});
assert.equal(text, "[reasoning-only completion]");
});
test("combo test helper returns empty string when no text content exists", () => {
const text = extractComboTestResponseText({
choices: [
+105
View File
@@ -86,6 +86,8 @@ test("combo test route marks a model healthy only when it returns assistant text
assert.match(fetchCalls[0].init.headers["X-Request-Id"], /^combo-test-/);
assert.equal(forwardedBody.model, "openrouter/openai/gpt-5.4");
assert.equal(forwardedBody.messages[0].content, "Reply with OK only.");
assert.equal(forwardedBody.max_tokens, 64);
assert.equal(forwardedBody.temperature, 0);
assert.equal(body.resolvedBy, "openrouter/openai/gpt-5.4");
assert.equal(body.results[0].status, "ok");
assert.equal(body.results[0].responseText, "OK");
@@ -122,6 +124,45 @@ test("combo test route treats empty successful responses as failures", async ()
assert.match(body.results[0].error, /no text content/i);
});
test("combo test route accepts reasoning-only completions as healthy smoke-test responses", async () => {
await createTestCombo();
globalThis.fetch = async () =>
new Response(
JSON.stringify({
choices: [
{
finish_reason: "length",
message: {
role: "assistant",
content: "",
},
},
],
usage: {
prompt_tokens: 6,
completion_tokens: 12,
total_tokens: 18,
completion_tokens_details: {
reasoning_tokens: 12,
},
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
}
);
const response = await route.POST(makeRequest());
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.resolvedBy, "openrouter/openai/gpt-5.4");
assert.equal(body.results[0].status, "ok");
assert.equal(body.results[0].responseText, "[reasoning-only completion]");
});
test("combo test route surfaces provider errors instead of downgrading them to reachability", async () => {
await createTestCombo();
@@ -148,3 +189,67 @@ test("combo test route surfaces provider errors instead of downgrading them to r
assert.equal(body.results[0].error, "Upstream rejected this request shape");
assert.equal("probeMethod" in body.results[0], false);
});
test("combo test route launches model probes concurrently while preserving combo order", async () => {
await createTestCombo(["provider/first", "provider/second", "provider/third"]);
const fetchCalls = [];
const resolvers = [];
globalThis.fetch = (url, init = {}) =>
new Promise((resolve) => {
fetchCalls.push({ url: String(url), init });
resolvers.push(resolve);
});
const responsePromise = route.POST(makeRequest());
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(fetchCalls.length, 3);
assert.deepEqual(
fetchCalls.map(({ init }) => JSON.parse(init.body).model),
["provider/first", "provider/second", "provider/third"]
);
resolvers[2](
new Response(
JSON.stringify({
choices: [{ message: { role: "assistant", content: "THIRD" } }],
}),
{ status: 200, headers: { "content-type": "application/json" } }
)
);
resolvers[1](
new Response(
JSON.stringify({
choices: [{ message: { role: "assistant", content: "SECOND" } }],
}),
{ status: 200, headers: { "content-type": "application/json" } }
)
);
resolvers[0](
new Response(
JSON.stringify({
choices: [{ message: { role: "assistant", content: "FIRST" } }],
}),
{ status: 200, headers: { "content-type": "application/json" } }
)
);
const response = await responsePromise;
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.resolvedBy, "provider/first");
assert.deepEqual(
body.results.map((result) => ({
model: result.model,
status: result.status,
responseText: result.responseText,
})),
[
{ model: "provider/first", status: "ok", responseText: "FIRST" },
{ model: "provider/second", status: "ok", responseText: "SECOND" },
{ model: "provider/third", status: "ok", responseText: "THIRD" },
]
);
});
@@ -49,9 +49,9 @@ test("T40: OpenCode config generator includes endpoint and selected API key", ()
apiKey: "sk_test_opencode",
model: "claude-sonnet-4-5-thinking",
});
assert.equal(providerConfig.baseURL, "http://localhost:20128/v1");
assert.equal(providerConfig.apiKey, "sk_test_opencode");
assert.ok(providerConfig.models.includes("claude-sonnet-4-5-thinking"));
assert.equal(providerConfig.options.baseURL, "http://localhost:20128/v1");
assert.equal(providerConfig.options.apiKey, "sk_test_opencode");
assert.ok(providerConfig.models["claude-sonnet-4-5-thinking"]);
const mergedConfig = mergeOpenCodeConfig(
{ provider: { custom: { name: "Custom Provider" } } },
@@ -62,8 +62,8 @@ test("T40: OpenCode config generator includes endpoint and selected API key", ()
}
);
assert.ok(mergedConfig.provider.custom);
assert.equal(mergedConfig.provider.omniroute.baseURL, "http://localhost:20128/v1");
assert.equal(mergedConfig.provider.omniroute.apiKey, "sk_test_opencode");
assert.equal(mergedConfig.provider.omniroute.options.baseURL, "http://localhost:20128/v1");
assert.equal(mergedConfig.provider.omniroute.options.apiKey, "sk_test_opencode");
});
test("T40: Windsurf card documents current official limitations honestly", () => {