Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab4914ee6a | |||
| e7c73c76dd | |||
| 3591a3fe5c | |||
| fbdce049b2 | |||
| 9a8520a2de | |||
| 0b2c488a61 | |||
| 76e135077b | |||
| 6078cd2eab | |||
| 3482dade71 | |||
| 04d0c350db | |||
| b6a5c91045 | |||
| 7a37c79ebc | |||
| ba227c5ec3 | |||
| b492c5ac1a | |||
| 03a860dd6f | |||
| 007b5d7f50 | |||
| c6eadc504b |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "2.3.13",
|
||||
"version": "3.3.7",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "समापन बिंदु प्रॉक्सी",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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 ...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user