Compare commits

..

2 commits

112 changed files with 506 additions and 9244 deletions

View file

@ -1,89 +1,3 @@
# v0.5.55 (2026-08-14)
## Features
- **Auth**: native SAML 2.0 SSO alongside OIDC — AuthnRequest generation, ACS
assertion handling, SP metadata export, admin config test, replay-protected
via a `saml_state` cookie matched against `InResponseTo`
- **Providers**: add Alibaba Token Plan (`token-plan.ap-southeast-1`) — the
fourth Alibaba key type, Singapore-only and OpenAI-compatible transport only
- **Providers**: add `glm-5.3` to GLM Coding and GLM (China)
- **Providers**: Kimchi accepts API keys as well as OAuth (dual auth), with a
working Test Connection for both modes
- **Antigravity**: add Gemini 3.7 Flash and its tiered high/medium/low variants
(also in the Gemini registry) with pricing and quota tracking
- **TTS**: add Fish Audio — model id travels in an HTTP `model` header, voice
is a `reference_id` (preset or cloned voice model)
- **OpenCode-Go**: route by request format via declared transports instead of
forcing every client into `/messages` — Codex/OpenAI clients no longer pay a
lossy Responses→OpenAI→Claude double translation. Per-model `supportedFormats`
guard; the bespoke executor is gone (its shared `_lastModel` cache could cross
auth headers between concurrent requests)
- **Usage**: dedup + cache Claude quota calls (120s TTL keyed by access token,
in-flight promise dedup, last-good read on soft failure) to stop multiple
tabs tripping 429; manual refresh (↻) sends `force=1` to bypass the cache
## Fixes
- **Docker**: ship `sql.js` in the image so the pure-JS DB fallback can start —
file tracing carried the package's JS without `dist/sql-wasm.wasm`, so a
container with no native driver aborted with ENOENT and never got a database
(#3248)
- **Usage**: read Gemini `usageMetadata` out of the antigravity `{ response }`
envelope — every non-streaming antigravity request logged `IN 0 | OUT 0`
(#3260)
- **Claude**: re-anchor passthrough cache breakpoints — the client's own
`cache_control` markers point at pre-normalization offsets, so the tail was
re-cached every request. Last system block and last tool pinned at 1h TTL,
last assistant turn at 5m, mid-conversation system messages folded into the
neighbouring user turn instead of hoisted into `body.system`
- **Combos**: detect images from Hermes and attachment payloads (`images[]`,
`experimental_attachments`, message-level `image_url`/`audio_url`, inline
`data:` URIs) so the Vision Adapter auto-switch fires for Hermes/Ollama/
Vercel AI SDK shapes
- **Kiro**: intercept chat via `x-amz-target` — Kiro IDE 1.0.228+ moved
`GenerateAssistantResponse` to `POST /` + header, bypassing MITM. Also emit
the now-mandatory initial-response frame and map the `auto` model slot
- **Kiro**: report real output tokens and stop discarding usable turns
- **Qoder**: detect billing blocks at stream start and return a synthetic 403
so combo/account fallback triggers instead of leaking the error into chat
- **Antigravity**: strip competitive system prompts (Zed IDE's Claude-agent
prompt) that Antigravity flags with a 429 Quota Exhausted
- **OpenCode**: send the official client fingerprint on free-tier requests so
the Console stops classifying traffic as unidentified and rate-limiting it;
session id resolves conversation-stable to preserve prompt caching
- **Responses**: don't close the message on an empty `tool_calls` array — some
providers attach one to every chunk, and the truthy check ended the message
on the first content token (#3234)
- **Translator**: preserve `prompt_cache_key` when converting chat to responses
- **Models**: expose snake_case token limits on `/v1/models`
- **Combos**: strip `stream_options` from the Fusion panel fan-out to avoid a
DeepSeek 400 (#3024); raise the dashboard model-test probe budget to 1024 and
soft-pass reasoning-only responses (#3010)
- **Headroom**: the toggle reflects the `headroomEnabled` setting even when the
proxy is down — it previously showed OFF while the engine kept calling
`/v1/compress`; proxy status stays visible via the status chip
- **Hermes**: add the `api_key` parameter to the model block in YAML config
- **Providers**: add llm7 to provider test support
## Docs
- **i18n**: add Spanish, French, and Brazilian Portuguese README translations
## Security
- **Real IP**: `x-9r-real-ip` and the Host fallback were trusted from
client-controlled headers whenever `custom-server.js` was not in the request
path (`npm run start`, `start:bun`), letting a remote caller pose as local to
skip API key auth and reach `LOCAL_ONLY_PATHS` (`/api/mcp/*`,
`/api/tunnel/enable`, `/api/auth/reset-password`). The server now stamps a
per-process `x-9r-peer-token` on every request it sanitizes and only trusts
`x-9r-real-ip` behind it — falling back to Host in development and failing
closed in production (GHSA-pjm4-8fpg-f9p6). Also fixes IPv6 loopback
detection (`::1`, `::ffff:127.0.0.1`) and routes `npm run start` /
`start:bun` through `custom-server.js`
- **Search**: `resolveBaseUrl()` rejects client-supplied non-public baseUrls
(SSRF guard on `/v1/search`)
- **Login**: fresh-install remote login with the default password returns 403
without issuing a JWT
- **Usage**: `/api/usage/request-details` redacts request/response payloads
# v0.5.50 (2026-08-05)
## Features

View file

@ -37,9 +37,6 @@ COPY --from=builder /app/src/mitm ./src/mitm
COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge
# Ensure `next` is available at runtime in case tracing did not include it.
COPY --from=builder /app/node_modules/next ./node_modules/next
# sql.js loads dist/sql-wasm.wasm by path at runtime; tracing only follows JS imports,
# so the last-resort DB driver would abort with ENOENT on the missing binary.
COPY --from=builder /app/node_modules/sql.js ./node_modules/sql.js
RUN mkdir -p /app/data && chown -R node:node /app && \
mkdir -p /app/data-home && chown node:node /app/data-home && \

View file

@ -17,7 +17,7 @@
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
[🇧🇷 Português (Brasil)](./i18n/README.pt-BR.md) • [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md) • [🇪🇸 Español](./i18n/README.es.md) • [🇫🇷 Français](./i18n/README.fr.md)
[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md)
</div>

View file

@ -1,6 +1,6 @@
{
"name": "9router",
"version": "0.5.55",
"version": "0.5.50",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"

View file

@ -216,9 +216,7 @@ function buildCliPackage() {
fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
console.log("✅ Copied custom-server.js\n");
} else {
console.error("❌ custom-server.js not found — without it no request can be proven local,");
console.error(" so the packaged CLI would demand an API key for its own dashboard and /v1.");
process.exit(1);
console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n");
}
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.

View file

@ -53,9 +53,6 @@ const PROVIDER_MODELS = {
{ id: "glm-4.7" },
],
ag: [
{ id: "gemini-3.7-flash-high" },
{ id: "gemini-3.7-flash-medium" },
{ id: "gemini-3.7-flash-low" },
{ id: "gemini-3.6-flash-high" },
{ id: "gemini-3.6-flash-medium" },
{ id: "gemini-3.6-flash-low" },

View file

@ -1,18 +1,9 @@
const http = require("http");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const { pathToFileURL } = require("url");
const origCreate = http.createServer.bind(http);
// Per-process secret proving x-9r-real-ip was stamped below rather than sent by the client.
// A bare `next start` / `next dev` never loads this file, so it cannot produce a matching
// header even though the env var is inherited by child processes. Named like x-9r-cli-token
// so the request-detail header sanitizer redacts it too.
const PEER_TOKEN = crypto.randomBytes(24).toString("hex");
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
let backgroundRefreshStarted = false;
function startBackgroundTokenRefreshFromCustomServer() {
@ -66,9 +57,7 @@ http.createServer = (...args) => {
delete req.headers["x-9r-real-ip"];
delete req.headers["x-forwarded-for"];
delete req.headers["x-9r-via-proxy"];
delete req.headers["x-9r-peer-token"];
req.headers["x-9r-real-ip"] = ip;
req.headers["x-9r-peer-token"] = PEER_TOKEN;
if (viaProxy) req.headers["x-9r-via-proxy"] = "1";
return handler(req, res);
};
@ -125,15 +114,4 @@ http.createServer = (...args) => {
return server;
};
if (require.main === module) {
const standalone = path.join(__dirname, "server.js");
if (fs.existsSync(standalone)) {
require(standalone);
} else {
// Repo checkout has no standalone build next to us. `next start` builds its HTTP
// server in-process, so the wrapper above still sanitizes every request.
const nextBin = require.resolve("next/dist/bin/next");
process.argv = [process.argv[0], nextBin, "start", ...process.argv.slice(2)];
require(nextBin);
}
}
if (require.main === module) require("./server.js");

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@ import { PROVIDERS } from "./providers.js";
import REGISTRY from "../providers/registry/index.js";
// PROVIDER_MODELS now built from providers/registry (transport + models co-located)
import { PROVIDER_MODELS } from "../providers/index.js";
import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js";
import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js";
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
export { PROVIDER_MODELS };
@ -54,14 +54,6 @@ export function getModelTargetFormat(aliasOrId, modelId) {
return modelTargetFormat(findModel(models, modelId, aliasOrId));
}
// Declared upstream formats for a model (registry `supportedFormats`). Drives the
// per-model guard on the sourceFormat-matched transport; null when undeclared.
export function getModelSupportedFormats(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null;
return modelSupportedFormats(findModel(models, modelId, aliasOrId));
}
export function getModelType(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null;

View file

@ -245,18 +245,6 @@ export class AntigravityExecutor extends BaseExecutor {
// Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects
const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {};
stripBlacklisted(requestWithoutTools);
// Rewrite competitive system prompts (e.g. Zed IDE's Claude prompt) to prevent Antigravity from
// flagging the request and immediately blocking it with a 429 Quota Exhausted response.
if (requestWithoutTools.systemInstruction?.parts) {
const oldText = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
for (const part of requestWithoutTools.systemInstruction.parts) {
if (typeof part.text === "string" && part.text.includes(oldText)) {
part.text = part.text.split(oldText).join("");
}
}
}
const generationConfig = { ...(requestWithoutTools.generationConfig || {}) };
if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) {
generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS;

View file

@ -10,6 +10,7 @@ import { CodexExecutor } from "./codex.js";
import { CursorExecutor } from "./cursor.js";
import { VertexExecutor } from "./vertex.js";
import { OpenCodeExecutor } from "./opencode.js";
import { OpenCodeGoExecutor } from "./opencode-go.js";
import { GrokWebExecutor } from "./grok-web.js";
import { GrokCliExecutor } from "./grok-cli.js";
import { PerplexityWebExecutor } from "./perplexity-web.js";
@ -40,6 +41,7 @@ const executors = {
vertex: new VertexExecutor("vertex"),
"vertex-partner": new VertexExecutor("vertex-partner"),
opencode: new OpenCodeExecutor(),
"opencode-go": new OpenCodeGoExecutor(),
"grok-web": new GrokWebExecutor(),
"grok-cli": new GrokCliExecutor(),
gcli: new GrokCliExecutor(), // Alias
@ -84,6 +86,7 @@ export { CursorExecutor } from "./cursor.js";
export { VertexExecutor } from "./vertex.js";
export { DefaultExecutor } from "./default.js";
export { OpenCodeExecutor } from "./opencode.js";
export { OpenCodeGoExecutor } from "./opencode-go.js";
export { GrokWebExecutor } from "./grok-web.js";
export { GrokCliExecutor } from "./grok-cli.js";
export { PerplexityWebExecutor } from "./perplexity-web.js";

View file

@ -144,12 +144,6 @@ function normalizeStopReason(value) {
return reason || null;
}
// Of the reasons stopDisposition() folds into "terminal_incomplete", only these
// mean "usable as far as it got, then the budget ran out" -- the case
// finish_reason "length" exists for. cancelled / pause_turn are abandoned turns
// whose partial content must stay private, so they are deliberately absent.
const KIRO_TRUNCATION_STOP_REASONS = new Set(["model_context_window_exceeded", "max_tokens"]);
function stopDisposition(stopReason, hasToolCalls) {
if (["malformed_model_output", "invalid_model_output"].includes(stopReason)) return "retryable_protocol_failure";
if (["cancelled", "pause_turn", "model_context_window_exceeded"].includes(stopReason)) return "terminal_incomplete";
@ -717,25 +711,14 @@ export class KiroExecutor extends BaseExecutor {
};
const emitTools = (controller) => {
for (const tool of state.tools.values()) {
// Validate per tool, not per turn: one unusable fragment used to throw out
// of emitTools and take every other complete tool call in the same turn
// with it, which the client saw as a turn that answered nothing.
let input;
try {
input = parsedToolInput(tool);
if (tool.name === "tool_call") {
if (typeof input.name !== "string" || !input.name.trim()) {
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name");
}
if (!Object.prototype.hasOwnProperty.call(input, "arguments")) {
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments");
}
const input = parsedToolInput(tool);
if (tool.name === "tool_call") {
if (typeof input.name !== "string" || !input.name.trim()) {
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name");
}
if (!Object.prototype.hasOwnProperty.call(input, "arguments")) {
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments");
}
} catch (error) {
state.droppedTools = (state.droppedTools || 0) + 1;
state.toolValidationError ||= error.message;
console.error(`[Kiro] dropping unusable tool call ${tool.id} (${tool.name}): ${error.message}`);
continue;
}
const index = state.toolCounter++;
emitDelta(controller, {
@ -746,26 +729,14 @@ export class KiroExecutor extends BaseExecutor {
function: { name: tool.name, arguments: "" }
}]
});
const serializedInput = JSON.stringify(input);
emitDelta(controller, {
tool_calls: [{ index, function: { arguments: serializedInput } }]
tool_calls: [{ index, function: { arguments: JSON.stringify(input) } }]
});
// Tool arguments are billed output like any other completion bytes. They
// were never added to totalContentLength, so the /4 estimator in finish()
// reported OUT 0 -- or the Math.max floor of 1 -- for every turn whose
// entire answer was a tool call.
state.totalContentLength += tool.name.length + serializedInput.length;
state.hasToolCalls = true;
}
state.tools.clear();
state.bufferedToolBytes = 0;
// A declared tool turn that emitted no usable call is only fatal when the
// turn produced nothing else. Throwing unconditionally here escaped
// emitTools() with provenance "invalid_tool_call", which the integrity gate
// re-derived into a repair retry -- discarding text the client had already
// been promised.
if (state.stopReason === "tool_use" && !state.hasToolCalls &&
!state.hasText && !state.hasReasoning && !state.hasCode) {
if (state.stopReason === "tool_use" && !state.hasToolCalls) {
throw new Error("Kiro tool_use stop reason did not include a complete tool call");
}
};
@ -825,6 +796,7 @@ export class KiroExecutor extends BaseExecutor {
emitDelta(controller, { content: event.payload.content });
} else if (eventType === "toolUseEvent") {
state.sawToolUse = true;
if (state.toolValidationError) return true;
const values = Array.isArray(event.payload) ? event.payload : [event.payload];
if (!values[0]) throw new Error("Kiro toolUseEvent is empty");
for (const value of values) {
@ -952,10 +924,9 @@ export class KiroExecutor extends BaseExecutor {
} catch (error) {
const bufferExceeded = error.code === "KIRO_BUFFER_EXCEEDED";
if (!bufferExceeded) {
// Keep whatever is already buffered: the rejected fragment belongs to
// one tool, and clearing the map dropped the complete calls too.
state.toolValidationError ||= error.message;
console.error(`[Kiro] tool fragment rejected, keeping ${state.tools.size} buffered tool(s): ${error.message}`);
state.tools.clear();
state.bufferedToolBytes = 0;
continue;
}
fail(
@ -987,16 +958,7 @@ export class KiroExecutor extends BaseExecutor {
}
state.transportState = "clean_eof";
const declaredDisposition = stopDisposition(state.stopReason, state.sawToolUse);
// model_context_window_exceeded / max_tokens map to terminal_incomplete. When
// they arrive after the model already streamed content, fail() threw away a
// complete-enough answer; a truncated turn is what finish_reason "length" is
// for. chunkIndex > 0 means at least one delta already reached the client.
const declaredTruncatedAfterOutput = declaredDisposition === "terminal_incomplete" &&
KIRO_TRUNCATION_STOP_REASONS.has(state.stopReason) && state.chunkIndex > 0;
if (declaredTruncatedAfterOutput) {
console.error(`[Kiro] truncated after ${state.chunkIndex} chunk(s) (stop_reason=${state.stopReason}); keeping output`);
}
if (!declaredTruncatedAfterOutput && ["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) {
if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) {
const code = declaredDisposition === "retryable_protocol_failure"
? "kiro_retryable_protocol_failure"
: declaredDisposition === "terminal_refusal"
@ -1013,6 +975,16 @@ export class KiroExecutor extends BaseExecutor {
);
return;
}
if (state.toolValidationError) {
fail(
controller,
"invalid_tool_call",
"invalid_kiro_tool_call",
state.toolValidationError,
{ transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" }
);
return;
}
try {
emitTools(controller);
} catch (error) {
@ -1025,22 +997,6 @@ export class KiroExecutor extends BaseExecutor {
);
return;
}
// Fail only when the turn has nothing usable left. emitTools() validates
// per tool and drops just the unusable ones, so this has to run AFTER it:
// before, the rejected tool was still buffered and tools.size was never 0.
// A turn that also produced text keeps that text -- the dropped call is
// logged, not fatal.
if (state.toolValidationError && !state.hasToolCalls &&
!state.hasText && !state.hasReasoning && !state.hasCode) {
fail(
controller,
"invalid_tool_call",
"invalid_kiro_tool_call",
state.toolValidationError,
{ transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" }
);
return;
}
const hasOutput = state.hasText || state.hasReasoning || state.hasCode || state.hasToolCalls;
if (!hasOutput && !state.explicitStop) {
@ -1055,13 +1011,7 @@ export class KiroExecutor extends BaseExecutor {
}
const disposition = stopDisposition(state.stopReason, state.hasToolCalls);
// Same reasoning as declaredTruncatedAfterOutput above.
const truncatedAfterOutput = disposition === "terminal_incomplete" &&
KIRO_TRUNCATION_STOP_REASONS.has(state.stopReason) && state.chunkIndex > 0;
if (truncatedAfterOutput) {
console.error(`[Kiro] truncated after ${state.chunkIndex} chunk(s) (stop_reason=${state.stopReason}); closing as length`);
}
if (!truncatedAfterOutput && ["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) {
if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) {
const code = disposition === "retryable_protocol_failure"
? "kiro_retryable_protocol_failure"
: disposition === "terminal_refusal"
@ -1091,24 +1041,18 @@ export class KiroExecutor extends BaseExecutor {
total_tokens: prompt + completion
};
}
const finishReason = truncatedAfterOutput
? "length"
: state.hasToolCalls
? "tool_calls"
: disposition === "length"
? "length"
: "stop";
const finishReason = state.hasToolCalls
? "tool_calls"
: disposition === "length"
? "length"
: "stop";
controller.enqueue(sseChunk({}, finishReason, state.usage));
controller.enqueue(encoder.encode(SSE_DONE));
state.finished = true;
options.onTerminalState?.(diagnostics({
terminal_provenance: state.terminalProvenance || "clean_eventstream_eof",
transport_state: state.transportState,
// Report what this exit actually did, not the raw disposition. The
// integrity gate re-derives its verdict from stop_disposition, so
// reporting "terminal_incomplete" for a turn we deliberately kept made
// it discard the very bytes we just released to the client.
stop_disposition: truncatedAfterOutput ? "length" : disposition
stop_disposition: disposition
}));
};

View file

@ -0,0 +1,49 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
// Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth)
const MESSAGES_FORMAT_MODELS = new Set([
"minimax-m3",
"minimax-m2.7",
"minimax-m2.5",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
]);
const BASE = "https://opencode.ai/zen/go/v1";
export class OpenCodeGoExecutor extends BaseExecutor {
constructor() {
super("opencode-go", PROVIDERS["opencode-go"]);
}
// buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here
buildUrl(model) {
this._lastModel = model;
return MESSAGES_FORMAT_MODELS.has(model)
? `${BASE}/messages`
: `${BASE}/chat/completions`;
}
buildHeaders(credentials, stream = true) {
const key = credentials?.apiKey || credentials?.accessToken;
const headers = { "Content-Type": "application/json" };
if (MESSAGES_FORMAT_MODELS.has(this._lastModel)) {
headers["x-api-key"] = key;
headers["anthropic-version"] = ANTHROPIC_API_VERSION;
} else {
headers["Authorization"] = `Bearer ${key}`;
}
if (stream) headers["Accept"] = "text/event-stream";
return headers;
}
transformRequest(model, body) {
return injectReasoningContent({ provider: this.provider, model, body });
}
}

View file

@ -1,43 +1,16 @@
import crypto from "crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
import { resolveSessionId } from "../utils/sessionManager.js";
const OPENCODE_UA = "opencode";
// Models that use /zen/v1/messages (claude format)
const MESSAGES_MODELS = new Set();
function generateRequestId() {
return `msg_${crypto.randomUUID().replace(/-/g, "")}`;
}
function generateSessionId() {
return `ses_${crypto.randomUUID().replace(/-/g, "")}`;
}
// Normalize any resolved id into opencode's ses_ format (stable per-conversation)
function toOpencodeSession(id) {
const stripped = String(id || "").replace(/^ses_/, "").replace(/-/g, "");
return stripped ? `ses_${stripped}` : null;
}
function resolveOpencodeSession(body, credentials) {
return toOpencodeSession(resolveSessionId({
headers: credentials?.rawHeaders,
body,
connectionId: credentials?.connectionId,
scope: "opencode",
}));
}
export class OpenCodeExecutor extends BaseExecutor {
constructor() {
super("opencode", PROVIDERS.opencode);
this._currentSessionId = null;
}
transformRequest(model, body, stream, credentials) {
this._currentSessionId = resolveOpencodeSession(body, credentials);
transformRequest(model, body) {
return injectReasoningContent({ provider: this.provider, model, body });
}
@ -48,23 +21,12 @@ export class OpenCodeExecutor extends BaseExecutor {
: `${base}/zen/v1/chat/completions`;
}
buildHeaders(credentials, stream = true) {
const raw = credentials?.rawHeaders || {};
const lower = {};
for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v;
const downstreamUa = lower["user-agent"] || "";
const isOpencodeDownstream = downstreamUa.toLowerCase().includes("opencode");
buildHeaders() {
return {
"Content-Type": "application/json",
"Authorization": "Bearer public",
"User-Agent": isOpencodeDownstream ? downstreamUa : OPENCODE_UA,
"x-opencode-client": lower["x-opencode-client"] || "desktop",
"x-opencode-session": lower["x-opencode-session"] || this._currentSessionId || generateSessionId(),
"x-opencode-request": lower["x-opencode-request"] || generateRequestId(),
"x-opencode-project": lower["x-opencode-project"] || "global",
"Accept": stream ? "text/event-stream" : "*/*",
"x-opencode-client": "desktop",
"Accept": "text/event-stream"
};
}
}

View file

@ -215,52 +215,6 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
};
}
/**
* Check if a qoder error message indicates a billing/quota block.
* Signatures: code 112 (quota exhausted), code 10605 (queue throttle), pricingUrl field.
*/
function isBillingBlock(inner) {
if (!inner || typeof inner !== "string") return false;
const lowerMsg = inner.toLowerCase();
// Match: {"code":"112",...}, {"code":"10605",...}, or pricingUrl field
return /\"code\"\s*:\s*\"(112|10605)\"/.test(inner) || lowerMsg.includes("pricingurl");
}
/**
* Peek the first SSE frame to detect billing errors before piping.
* Returns { isBilling, statusVal, message, consumed } — `consumed` is every
* byte read so far (including the peeked line) so the caller can re-process
* it and nothing is dropped from the stream.
*/
async function peekFirstQoderFrame(reader, decoder) {
let consumed = "";
while (true) {
const { done, value } = await reader.read();
if (done) return { isBilling: false, consumed, upstreamDone: true };
consumed += decoder.decode(value, { stream: true });
const nl = consumed.indexOf("\n");
if (nl === -1) continue; // need a full line first
const line = consumed.slice(0, nl).replace(/\r$/, "").trim();
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trimStart();
if (data === "[DONE]") return { isBilling: false, consumed };
let envelope;
try { envelope = JSON.parse(data); } catch { return { isBilling: false, consumed }; }
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
const inner = typeof envelope.body === "string" ? envelope.body : "";
if (statusVal !== 200 && isBillingBlock(inner)) {
return { isBilling: true, statusVal, message: inner || `qoder billing block (${statusVal})` };
}
return { isBilling: false, consumed };
}
}
/**
* Wrap the upstream's `{statusCodeValue, body}` SSE envelope into plain
* OpenAI SSE chunks the rest of the chatCore pipeline understands.
@ -275,33 +229,15 @@ async function peekFirstQoderFrame(reader, decoder) {
* [DONE]/error frame (agent keepalive). Non-streaming clients drain via
* response.text() which hangs until the socket closes — so on terminal
* events we cancel the upstream reader and close our stream immediately.
*
* NEW: Peek first frame to detect billing blocks (code 112/10605/pricingUrl).
* If detected, return 403 response so chatCore marks connection unavailable
* and triggers combo fallback instead of leaking error text into chat.
*/
async function wrapQoderSSE(response, model) {
function wrapQoderSSE(response, model) {
if (!response.ok || !response.body) return response;
const decoder = new TextDecoder();
const reader = response.body.getReader();
// Peek first frame to detect billing block
const peek = await peekFirstQoderFrame(reader, decoder);
if (peek?.isBilling) {
// Billing block detected — return 403 so chatCore fails this connection
await reader.cancel().catch(() => {});
return new Response(
JSON.stringify({ error: { message: peek.message, code: peek.statusVal } }),
{ status: 403, headers: { "Content-Type": "application/json" } }
);
}
// Normal flow: re-process every byte the peek consumed, then continue.
let buffer = peek.consumed || "";
const upstreamDrained = peek.upstreamDone === true;
const encoder = new TextEncoder();
let buffer = "";
let doneEmitted = false;
const reader = response.body.getReader();
// Process one already-extracted SSE line (no trailing newline).
const processLine = (line, controller) => {
@ -351,28 +287,7 @@ async function wrapQoderSSE(response, model) {
// enqueueing would never be re-invoked, hanging consumers like .text().
async start(controller) {
try {
// Drain whatever the peek already pulled off the socket first.
let nlSeed;
while ((nlSeed = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nlSeed);
buffer = buffer.slice(nlSeed + 1);
processLine(line, controller);
if (doneEmitted) {
await reader.cancel().catch(() => {});
controller.close();
return;
}
}
if (upstreamDrained) {
// Peek hit end-of-stream: flush any trailing partial line.
buffer += decoder.decode();
if (buffer.length > 0) {
processLine(buffer, controller);
buffer = "";
}
}
while (!doneEmitted && !upstreamDrained) {
while (!doneEmitted) {
const { done, value } = await reader.read();
if (done) {
buffer += decoder.decode();
@ -557,7 +472,7 @@ export class QoderExecutor extends BaseExecutor {
return { response, url, headers, transformedBody: payload };
}
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`);
const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`);
return { response: wrapped, url, headers, transformedBody: payload };
}
@ -581,5 +496,4 @@ export const __test__ = {
normalizeMessages,
wrapQoderSSE,
buildQoderRequestBody,
isBillingBlock,
};

View file

@ -2,11 +2,11 @@ import { detectFormat, getTargetFormat, resolveTransport } from "../services/pro
import { translateRequest } from "../translator/index.js";
import { applyThinking, extractThinking, stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js";
import { FORMATS } from "../translator/formats.js";
import { normalizeClaudePassthrough, anchorClaudeCache } from "../translator/formats/claude.js";
import { normalizeClaudePassthrough } from "../translator/formats/claude.js";
import { createStreamController } from "../utils/streamHandler.js";
import { refreshWithRetry } from "../services/tokenRefresh.js";
import { createRequestLogger } from "../utils/requestLogger.js";
import { getModelTargetFormat, getModelSupportedFormats, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { PROVIDERS } from "../config/providers.js";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js";
@ -78,20 +78,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const modelTargetFormat = getModelTargetFormat(alias, model);
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation.
// Per-model guard: only use the transport when the model declares support for that
// sourceFormat — opencode-go models differ in endpoint support (kimi/glm only do
// /chat/completions), so without this guard a claude-format request would wrongly
// route kimi to /messages.
const modelSupportedFormats = getModelSupportedFormats(alias, model);
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation
const runtimeTransport = resolveTransport(provider, sourceFormat);
// Per-model guard: when a model declares supportedFormats, only use the
// sourceFormat-matched transport if that format is declared (opencode-go models
// differ — kimi/glm only do /chat/completions). Undeclared models keep the
// upstream default (use the transport), preserving behavior for glm/deepseek/...
const useTransport = (!modelSupportedFormats || modelSupportedFormats.includes(sourceFormat)) ? runtimeTransport : null;
const targetFormat = modelTargetFormat || useTransport?.format || getTargetFormat(provider, credentials);
if (useTransport && credentials) credentials.runtimeTransport = useTransport;
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider, credentials);
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
const stripList = getModelStrip(alias, model);
const upstreamModel = getModelUpstreamId(alias, model);
@ -285,10 +275,6 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
if (xf.length && log?.line) log.line(reqTag, "⚙", xf.join(" · "));
// Pin cache breakpoints to the final body — every saver above can reshape
// system/tools/messages, and a stale anchor costs a full prefix rewrite.
if (passthrough && clientTool === "claude") anchorClaudeCache(translatedBody);
const executor = getExecutor(provider);
trackPendingRequest(model, provider, connectionId, true);
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { });

View file

@ -44,14 +44,13 @@ export function extractUsageFromResponse(responseBody) {
};
}
// Gemini format. Antigravity / gemini-cli wrap the payload in { response: {...} }.
const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata;
if (usageMetadata) {
// Gemini format
if (responseBody.usageMetadata) {
return {
prompt_tokens: usageMetadata.promptTokenCount || 0,
completion_tokens: usageMetadata.candidatesTokenCount || 0,
cached_tokens: usageMetadata.cachedContentTokenCount || 0,
reasoning_tokens: usageMetadata.thoughtsTokenCount || 0
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
cached_tokens: responseBody.usageMetadata.cachedContentTokenCount || 0,
reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount || 0
};
}

View file

@ -29,8 +29,6 @@
* @property {Record<string,unknown>} [providerSpecificData]
*/
import { assertPublicUrl } from "../../../src/shared/utils/ssrfGuard.js";
// ── Helpers ─────────────────────────────────────────────────────────────
/**
@ -65,31 +63,12 @@ export function getProviderSetting(params, key) {
/**
* Resolve base URL with optional override from providerOptions.baseUrl.
*
* The override is client-controlled and therefore SSRF-hardened: only public
* http(s) URLs are accepted (internal/private/loopback/metadata addresses are
* rejected via assertPublicUrl). The provider's own configured baseUrl is
* trusted as-is (admin-controlled).
*
* @param {SearchProviderConfig} config
* @param {SearchRequestParams} params
* @returns {string}
*/
export function resolveBaseUrl(config, params) {
const override = getProviderSetting(params, "baseUrl");
if (override) {
// SSRF guard: client-supplied base URLs must be public http(s) only.
let parsed;
try {
parsed = new URL(override);
} catch {
throw new Error(`Invalid baseUrl: ${override}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Invalid baseUrl protocol: ${parsed.protocol}`);
}
assertPublicUrl(override);
}
return (override || config.baseUrl).replace(/\/+$/, "");
}

View file

@ -51,25 +51,6 @@ async function huggingface({ baseUrl, apiKey, text, modelId }) {
return responseToBase64(res, "wav");
}
// Fish Audio: model travels in an HTTP header, the voice is a reference_id, returns binary
async function fishAudio({ baseUrl, apiKey, text, modelId, voiceId }) {
const res = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
"model": modelId || "s2.1-pro-free",
},
body: JSON.stringify({
text,
format: "mp3",
...(voiceId ? { reference_id: voiceId } : {}),
}),
});
if (!res.ok) await throwUpstreamError(res);
return responseToBase64(res, "mp3");
}
// Inworld: Basic auth, JSON { audioContent }
async function inworld({ baseUrl, apiKey, text, modelId, voiceId }) {
const res = await fetch(baseUrl, {
@ -185,5 +166,4 @@ export const FORMAT_HANDLERS = {
tortoise,
openai: openaiCompat,
"minimax-tts": minimaxTts,
"fish-audio": fishAudio,
};

View file

@ -205,7 +205,6 @@ export const PATTERN_CAPABILITIES = [
// ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─
{ pattern: "*gemini*image*", caps: { vision: true, imageOutput: true, contextWindow: 1048576 } },
{ pattern: "*gemini-3.7*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini-3*pro*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65535 } },
{ pattern: "*gemini-3*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini-2.5*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-budget", thinkingRange: { min: 0, max: 24576 }, contextWindow: 1048576, maxOutput: 65536 } },

View file

@ -38,11 +38,3 @@ export function modelStrip(model) {
export function modelTargetFormat(model) {
return model?.targetFormat || MODEL_DEFAULTS.targetFormat;
}
// Per-model declared upstream formats (e.g. ["openai", "claude"]). Guards the
// sourceFormat-matched transport for multi-endpoint providers whose models differ
// in endpoint support (opencode-go: kimi/glm only do /chat/completions, minimax/qwen
// also do /messages, deepseek also does /responses).
export function modelSupportedFormats(model) {
return model?.supportedFormats || null;
}

View file

@ -57,10 +57,6 @@ export const MODEL_PRICING = {
"o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
// === Gemini ===
"gemini-3.7-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.7-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.7-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.7-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },

View file

@ -1,35 +0,0 @@
// Token Plan — credit subscription keys on token-plan.<region>.maas.aliyuncs.com.
// Fourth Alibaba key type: Coding Plan (alicode/alicode-intl) and Model Studio
// (alims-intl) both reject these keys, and they reject Model Studio keys back.
// Singapore is the only region that serves the plan; eu-central-1 answers
// IllegalEndpoint. The Anthropic surface (/apps/anthropic/v1/messages) is not
// authorized for this plan, so OpenAI-compatible mode is the only transport.
export default {
id: "alitp-intl",
priority: 11,
alias: "alitp-intl",
display: {
name: "Alibaba Token Plan",
icon: "cloud",
color: "#FF6A00",
textIcon: "ATP",
website: "https://www.alibabacloud.com/campaign/ai-landing-page-token",
notice: {
apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1",
},
},
category: "apikey",
transport: {
baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
headers: {},
quirks: { preserveCacheControl: true },
},
models: [
{ id: "qwen3.8-max-preview", name: "Qwen3.8 Max Preview" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max" },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus" },
{ id: "qwen3.6-flash", name: "Qwen3.6 Flash" },
{ id: "glm-5.2", name: "GLM 5.2" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
],
};

View file

@ -45,9 +45,6 @@ export default {
clientSecret: "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",
},
models: [
{ id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", upstreamModelId: "gemini-3.7-flash-tiered(high)" },
{ id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)", upstreamModelId: "gemini-3.7-flash-tiered(medium)" },
{ id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)", upstreamModelId: "gemini-3.7-flash-tiered(low)" },
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" },
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" },
{ id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" },
@ -75,7 +72,7 @@ export default {
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
],
apiEndpoint: "https://cloudcode-pa.googleapis.com",
apiEndpoint: "https://daily-cloudcode-pa.googleapis.com",
apiVersion: "v1internal",
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",

View file

@ -1,31 +0,0 @@
// Fish Audio TTS — the model id travels in an HTTP `model` header rather than the
// JSON body, and the voice is a reference_id (a cloned or preset voice model).
export default {
id: "fish-audio",
alias: "fish",
display: {
name: "Fish Audio",
icon: "record_voice_over",
color: "#1E9BF0",
textIcon: "FA",
website: "https://fish.audio",
notice: {
apiKeyUrl: "https://fish.audio/app/api-keys/",
},
},
category: "apikey",
authType: "apikey",
serviceKinds: ["tts"],
ttsConfig: {
baseUrl: "https://api.fish.audio/v1/tts",
authType: "apikey",
authHeader: "bearer",
format: "fish-audio",
models: [
{ id: "s2.1-pro-free", name: "S2.1 Pro Free" },
{ id: "s2.1-pro", name: "S2.1 Pro" },
{ id: "s2-pro", name: "S2 Pro" },
{ id: "s1", name: "S1" },
],
},
};

View file

@ -36,7 +36,6 @@ export default {
},
},
models: [
{ id: "gemini-3.7-flash", name: "Gemini 3.7 Flash" },
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" },
{ id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },

View file

@ -21,7 +21,6 @@ export default {
},
},
models: [
{ id: "glm-5.3", name: "GLM 5.3" },
{ id: "glm-5.2", name: "GLM 5.2" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "glm-5", name: "GLM 5" },

View file

@ -45,7 +45,6 @@ export default {
},
],
models: [
{ id: "glm-5.3", name: "GLM 5.3" },
{ id: "glm-5.2", name: "GLM 5.2" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "glm-5", name: "GLM 5" },

View file

@ -119,8 +119,6 @@ import p116 from "./tokenrouter.js";
import p117 from "./selfhosted-stt.js";
import p118 from "./selfhosted-tts.js";
import p119 from "./selfhosted-embedding.js";
import p120 from "./fish-audio.js";
import p121 from "./alitp-intl.js";
export default [
p0,
@ -241,6 +239,4 @@ export default [
p117,
p118,
p119,
p120,
p121,
];

View file

@ -14,7 +14,7 @@ export default {
},
},
category: "freeTier",
authModes: ["oauth", "apikey"],
authModes: ["oauth"],
hasOAuth: true,
transport: {
baseUrl: "https://llm.kimchi.dev/openai/v1/chat/completions",

View file

@ -22,28 +22,20 @@ export default {
baseUrl: "https://opencode.ai/zen/go/v1/chat/completions",
headers: {},
},
// Multi-endpoint: pick the transport matching the client sourceFormat to skip
// translation. Guarded per-model by `supportedFormats` (see chatCore) because
// opencode-go models differ in endpoint support.
transports: [
{ format: "openai", baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
{ format: "claude", baseUrl: "https://opencode.ai/zen/go/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } },
{ format: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
],
models: [
{ id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] },
{ id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] },
{ id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] },
{ id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] },
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] },
{ id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] },
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] },
{ id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] },
{ id: "glm-5.2", name: "GLM 5.2" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
{ id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ id: "mimo-v2.5", name: "MiMo V2.5" },
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
{ id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude" },
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", targetFormat: "claude" },
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", targetFormat: "claude" },
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", targetFormat: "claude" },
],
};

View file

@ -78,7 +78,7 @@ export const ANTHROPIC_COMPAT_BASE = "https://api.anthropic.com/v1";
// Keep this static even when 9router runs on Linux: the provider profile is
// intentionally matching the IDE client, not the server host.
export const ANTIGRAVITY_IDE_VERSION = "2.1.1";
export const ANTIGRAVITY_IDE_BASE_URL = "https://cloudcode-pa.googleapis.com";
export const ANTIGRAVITY_IDE_BASE_URL = "https://daily-cloudcode-pa.googleapis.com";
export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`;
// Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth)

View file

@ -185,9 +185,13 @@ export function resetAccountState(account) {
if (!account) return account;
return {
...account,
...buildClearModelLocksUpdate(account),
rateLimitedUntil: null,
backoffLevel: 0,
testStatus: "active",
lastError: null,
errorCode: null,
lastErrorAt: null,
status: "active"
};
}

View file

@ -138,42 +138,8 @@ export function detectRequiredCapabilities(body) {
if (Array.isArray(content)) for (const b of content) scanBlock(b);
};
const scanMessage = (m) => {
if (!m || typeof m !== "object") return;
// Ollama / Hermes images array (strings or objects)
if (Array.isArray(m.images) && m.images.length > 0) {
required.add("vision");
}
// Vercel AI SDK / Hermes attachments / experimental_attachments
const attachments = m.experimental_attachments || m.attachments;
if (Array.isArray(attachments)) {
for (const att of attachments) {
if (!att) continue;
const mime = att.contentType || att.mediaType || (typeof att.url === "string" && att.url.match(/^data:([^;,]+)/)?.[1]);
if (mime) addByMime(mime);
else if (att.url || att.data) required.add("vision");
}
}
// Direct message-level modality properties
if (m.image_url || m.image) required.add("vision");
if (m.audio_url || m.audio) required.add("audioInput");
// Scan array content blocks
scanContent(m.content);
// Scan string content for embedded data URIs
if (typeof m.content === "string") {
if (m.content.includes("data:image/")) required.add("vision");
else if (m.content.includes("data:audio/")) required.add("audioInput");
else if (m.content.includes("data:application/pdf")) required.add("pdf");
}
};
// Modalities: current user turn only (trailing user run across each known shape).
for (const m of trailingUserItems(body.messages)) scanMessage(m); // openai / claude / hermes / ollama
for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude
for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses
const contents = body.contents || body.request?.contents; // gemini / antigravity
for (const c of trailingUserItems(contents)) scanContent(c.parts);
@ -564,10 +530,7 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
log.info("FUSION", `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`);
// 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose).
const { tools, tool_choice, stream_options, ...rest } = body;
// Fusion runs panel models non-streaming; drop stream_options too, or providers
// like DeepSeek reject it with "stream_options should be set along with stream = true".
// See issue #3024.
const { tools, tool_choice, ...rest } = body;
const panelBody = { ...rest, stream: false };
// Flatten tool turns to prose so panel models keep context without emitting tool_calls.

View file

@ -33,7 +33,7 @@ const USAGE_HANDLERS = {
github: (c) => getGitHubUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
"gemini-cli": (c) => getGeminiUsage(c.accessToken, c.providerDataWithProjectId, c.proxyOptions),
antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }),
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions),
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
qoder: async (c) => {
@ -56,7 +56,7 @@ const USAGE_HANDLERS = {
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
};
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
export async function getUsageForProvider(connection, proxyOptions = null) {
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
const providerDataWithProjectId = {
...(providerSpecificData || {}),
@ -65,13 +65,5 @@ export async function getUsageForProvider(connection, proxyOptions = null, optio
const handler = USAGE_HANDLERS[provider];
if (!handler) return { message: `Usage API not implemented for ${provider}` };
return await handler({
provider,
accessToken,
apiKey,
providerSpecificData,
providerDataWithProjectId,
proxyOptions,
force: options.force === true,
});
return await handler({ provider, accessToken, apiKey, providerSpecificData, providerDataWithProjectId, proxyOptions });
}

View file

@ -19,43 +19,7 @@ const CLAUDE_CONFIG = {
const OAUTH_429_COOLDOWN_MS = 180000;
const oauthCooldown = new Map();
// Dedup + short TTL cache per access token. Many tabs / many accounts / auto-refresh
// all funnel through here; without this each call hits Anthropic and triggers 429.
const USAGE_CACHE_TTL_MS = 300000;
const usageCache = new Map(); // token -> { promise } | { result, expiresAt }
export async function getClaudeUsage(accessToken, proxyOptions = null, options = {}) {
const force = options?.force === true;
// Serve in-flight or fresh cached result (skip on manual force)
if (!force && accessToken) {
const hit = usageCache.get(accessToken);
if (hit?.promise) return hit.promise;
if (hit && hit.expiresAt > Date.now()) return hit.result;
}
const stale = (!force && accessToken && usageCache.get(accessToken)?.result) || null;
const promise = (async () => {
const result = await fetchClaudeUsageRaw(accessToken, proxyOptions);
// Only cache real quota data, not soft-failure {message: ...} payloads
if (accessToken && result?.quotas) {
usageCache.set(accessToken, {
result,
expiresAt: Date.now() + USAGE_CACHE_TTL_MS,
});
return result;
}
// Soft failure (429/error): prefer the last good read over a transient error
if (stale) return stale;
return result;
})();
if (accessToken) usageCache.set(accessToken, { promise });
return promise;
}
async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
export async function getClaudeUsage(accessToken, proxyOptions = null) {
try {
// Skip OAuth usage call while this token is cooling down from a recent 429
const cooldownUntil = oauthCooldown.get(accessToken);

View file

@ -161,9 +161,6 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
if (data.models) {
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
const importantModels = [
'gemini-3.7-flash-high',
'gemini-3.7-flash-medium',
'gemini-3.7-flash-low',
'gemini-3.6-flash-high',
'gemini-3.6-flash-medium',
'gemini-3.6-flash-low',

View file

@ -62,19 +62,6 @@ function stripOpenAI(body, caps) {
if (!Array.isArray(body.messages)) return;
const last = body.messages.length - 1;
body.messages.forEach((msg, i) => {
if (caps.vision === false) {
if (Array.isArray(msg.images)) delete msg.images;
if (Array.isArray(msg.experimental_attachments)) {
msg.experimental_attachments = msg.experimental_attachments.filter(
(a) => !(a?.contentType?.startsWith("image/") || (typeof a?.url === "string" && a.url.startsWith("data:image/")))
);
}
if (Array.isArray(msg.attachments)) {
msg.attachments = msg.attachments.filter(
(a) => !(a?.contentType?.startsWith("image/") || (typeof a?.url === "string" && a.url.startsWith("data:image/")))
);
}
}
if (!Array.isArray(msg.content)) return;
const removed = new Set();
msg.content = filterBlocks(msg.content, capForOpenAIBlock, caps, removed, i === last);

View file

@ -9,9 +9,6 @@ import { PROVIDERS } from "../../providers/index.js";
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js";
const CACHE_CONTROL_5M = { type: "ephemeral" };
const CACHE_CONTROL_1H = { type: "ephemeral", ttl: "1h" };
// Check if message has valid non-empty content
export function hasValidContent(msg) {
if (typeof msg.content === "string" && msg.content.trim()) return true;
@ -127,38 +124,32 @@ export function normalizeClaudePassthrough(body, model = "") {
if (Object.keys(body.output_config).length === 0) delete body.output_config;
}
// 2. Fold mid-conversation system messages into the neighbouring turn.
// Hoisting them into body.system would insert volatile content (token counters,
// reminders) ahead of the whole conversation and invalidate the prefix cache on
// every request. Folding in place keeps the cached prefix stable.
// 2. Hoist mid-conversation system messages into the top-level system field
if (Array.isArray(body.messages)) {
const systemBlocks = [];
const messages = [];
for (const msg of body.messages) {
if (msg.role !== ROLE.SYSTEM) {
messages.push(msg);
if (msg.role === ROLE.SYSTEM) {
const text = typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n")
: "";
if (text.trim()) systemBlocks.push({ type: CLAUDE_BLOCK.TEXT, text });
continue;
}
const text = typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n")
: "";
if (!text.trim()) continue;
// Copy-on-write: the caller's body is reused across account-fallback
// attempts, so folding must never mutate the original message.
const block = { type: CLAUDE_BLOCK.TEXT, text };
const prev = messages[messages.length - 1];
if (prev?.role === ROLE.USER) {
const content = typeof prev.content === "string"
? [{ type: CLAUDE_BLOCK.TEXT, text: prev.content }]
: Array.isArray(prev.content) ? [...prev.content] : [];
messages[messages.length - 1] = { ...prev, content: [...content, block] };
continue;
}
messages.push({ role: ROLE.USER, content: [block] });
messages.push(msg);
}
if (systemBlocks.length > 0) {
const existing = Array.isArray(body.system)
? body.system
: typeof body.system === "string" && body.system.trim()
? [{ type: "text", text: body.system }]
: [];
body.system = [...existing, ...systemBlocks];
body.messages = messages;
}
body.messages = messages;
}
// 3. Drop thinking blocks whose signature is not Claude's (combo mixes models,
@ -191,70 +182,6 @@ export function normalizeClaudePassthrough(body, model = "") {
return body;
}
// Put a 5m breakpoint on the last cache-eligible block of a message.
// thinking/redacted_thinking blocks do not accept cache_control.
function markLastCacheableBlock(msg) {
if (!Array.isArray(msg?.content)) return false;
for (let i = msg.content.length - 1; i >= 0; i--) {
const block = msg.content[i];
if (typeof block !== "object" || block === null) continue;
if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) continue;
block.cache_control = { ...CACHE_CONTROL_5M };
return true;
}
return false;
}
// Re-anchor cache breakpoints on a Claude passthrough body (same policy as
// prepareClaudeRequest): last tool + last system block at 1h, last assistant at 5m.
// The client's own markers point at pre-normalization offsets, so they are dropped.
// Must run LAST, after every step that can reshape system/tools/messages
// (normalize, tool dedupe, token savers) — otherwise the anchor drifts off the tail.
export function anchorClaudeCache(body) {
if (!body || typeof body !== "object") return body;
if (Array.isArray(body.system)) {
const last = body.system.length - 1;
body.system.forEach((block, i) => {
if (typeof block !== "object" || block === null) return;
if (i === last) block.cache_control = { ...CACHE_CONTROL_1H };
else delete block.cache_control;
});
}
if (Array.isArray(body.tools)) {
const last = body.tools.length - 1;
body.tools.forEach((tool, i) => {
if (i === last) tool.cache_control = { ...CACHE_CONTROL_1H };
else delete tool.cache_control;
});
}
if (Array.isArray(body.messages)) {
let anchored = null;
for (let i = body.messages.length - 1; i >= 0; i--) {
const msg = body.messages[i];
if (!Array.isArray(msg.content)) continue;
for (const block of msg.content) delete block.cache_control;
// Prefer the last assistant turn: it ends a completed exchange, so the
// prefix up to it stays byte-stable across the following requests.
if (anchored || msg.role !== ROLE.ASSISTANT) continue;
anchored = markLastCacheableBlock(msg);
}
// First turn of a conversation has no assistant yet — anchor the final
// message instead, so the opening prompt is cached rather than paid twice.
if (!anchored) {
for (let i = body.messages.length - 1; i >= 0 && !anchored; i--) {
anchored = markLastCacheableBlock(body.messages[i]);
}
}
}
return body;
}
// Prepare request for Claude format endpoints
// - Cleanup cache_control
// - Filter empty messages

View file

@ -311,26 +311,6 @@ function ensureObjectType(obj) {
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
export function cleanJSONSchemaForAntigravity(schema) {
if (!schema || typeof schema !== "object") return schema;
const defs = schema.$defs || schema.definitions || {};
function deref(obj) {
if (!obj || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(deref);
if (obj.$ref && typeof obj.$ref === "string") {
const name = obj.$ref.split("/").pop();
if (defs[name]) return deref(Object.assign({}, defs[name]));
}
const res = {};
for (const [k, v] of Object.entries(obj)) {
if (k === "$defs" || k === "definitions") continue;
res[k] = deref(v);
}
return res;
}
return _orig_cleanJSONSchemaForAntigravity(deref(schema));
}
function _orig_cleanJSONSchemaForAntigravity(schema) {
if (!schema || typeof schema !== "object") return schema;
// Mutate directly (schema is only used once per request)
let cleaned = schema;

View file

@ -287,18 +287,6 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
toolSpecs,
nameMap,
});
// canonicalizeKiroConversation() already ran its second-chance repair (flatten
// every structured tool turn to text, then re-validate). A body that is STILL
// invalid here cannot be made shippable, and Kiro answers it with
// 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"}.
// Fail locally instead: chatCore turns a falsy return into a 400 without
// spending an upstream call or a per-account cooldown. The taxonomy
// (role:N | pair:N | id:N | spec:N | orphan:0 | current) names the offending
// turn so the shape can be diagnosed from the log alone.
if (!canonical.valid) {
console.error(`[Kiro] refusing invalid conversation (claude → kiro): ${(canonical.errors || []).join(", ") || "unknown"} | turns=${(canonical.history || []).length + 1}`);
return null;
}
const replayCurrent = canonical.currentMessage.userInputMessage;
const userInputMessage = {
content: replayCurrent.content || "",

View file

@ -421,7 +421,6 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
if (body.reasoning !== undefined) result.reasoning = body.reasoning;
if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" };
if (body.service_tier !== undefined) result.service_tier = body.service_tier;
if (body.prompt_cache_key !== undefined) result.prompt_cache_key = body.prompt_cache_key;
return result;
}

View file

@ -96,6 +96,40 @@ export function openaiToClaudeRequest(model, body, stream) {
flushCurrentMessage();
// GUARD: some Claude auth channels (OAuth/Claude Code) reject requests
// that end on an assistant turn ("assistant message prefill" 400).
// Agentic loops (e.g. Hermes) sometimes resend their own last output as
// the new final message to request a continuation, with no new user
// turn in between. Normalize by appending a synthetic turn so the
// request always ends on `user`, regardless of auth channel or model.
// If the trailing assistant message has unresolved tool_use blocks,
// Anthropic separately requires a matching tool_result for each one
// (not just any user turn), so synthesize those instead of plain text.
{
const lastMsg = result.messages[result.messages.length - 1];
if (lastMsg && lastMsg.role === ROLE.ASSISTANT) {
const unresolvedToolUseIds = Array.isArray(lastMsg.content)
? lastMsg.content.filter(b => b.type === CLAUDE_BLOCK.TOOL_USE).map(b => b.id)
: [];
if (unresolvedToolUseIds.length > 0) {
result.messages.push({
role: ROLE.USER,
content: unresolvedToolUseIds.map(id => ({
type: CLAUDE_BLOCK.TOOL_RESULT,
tool_use_id: id,
content: "Continuing."
}))
});
} else {
result.messages.push({
role: ROLE.USER,
content: [{ type: CLAUDE_BLOCK.TEXT, text: "Continue." }]
});
}
}
}
// Add cache_control to last assistant message
for (let i = result.messages.length - 1; i >= 0; i--) {
const message = result.messages[i];

View file

@ -379,18 +379,6 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
toolSpecs,
nameMap,
});
// canonicalizeKiroConversation() already ran its second-chance repair (flatten
// every structured tool turn to text, then re-validate). A body that is STILL
// invalid here cannot be made shippable, and Kiro answers it with
// 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"}.
// Fail locally instead: chatCore turns a falsy return into a 400 without
// spending an upstream call or a per-account cooldown. The taxonomy
// (role:N | pair:N | id:N | spec:N | orphan:0 | current) names the offending
// turn so the shape can be diagnosed from the log alone.
if (!canonical.valid) {
console.error(`[Kiro] refusing invalid conversation (openai → kiro): ${(canonical.errors || []).join(", ") || "unknown"} | turns=${(canonical.history || []).length + 1}`);
return null;
}
const replayCurrent = canonical.currentMessage.userInputMessage;
const payload = {

View file

@ -75,15 +75,6 @@ export function kiroToClaudeResponse(chunk, state) {
? data.usage.completion_tokens
: 0;
state.usage = { input_tokens: promptTokens, output_tokens: outputTokens };
// Claude clients read cache_read/cache_creation to price a turn and to size
// their prompt cache. Both spellings are accepted because the Kiro executor
// emits the Chat shape and passthrough responses use the nested details form.
const cacheRead = data.usage.cache_read_input_tokens
?? data.usage.prompt_tokens_details?.cached_tokens;
const cacheCreation = data.usage.cache_creation_input_tokens
?? data.usage.prompt_tokens_details?.cache_creation_tokens;
if (typeof cacheRead === "number") state.usage.cache_read_input_tokens = cacheRead;
if (typeof cacheCreation === "number") state.usage.cache_creation_input_tokens = cacheCreation;
}
// First chunk → emit message_start.
@ -263,13 +254,6 @@ export function kiroToClaudeNonStreaming(data) {
usage: {
input_tokens: usage.prompt_tokens || 0,
output_tokens: usage.completion_tokens || 0,
// Same cache preservation as the streaming path above.
...(typeof (usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens) === "number"
? { cache_read_input_tokens: usage.cache_read_input_tokens ?? usage.prompt_tokens_details.cached_tokens }
: {}),
...(typeof (usage.cache_creation_input_tokens ?? usage.prompt_tokens_details?.cache_creation_tokens) === "number"
? { cache_creation_input_tokens: usage.cache_creation_input_tokens ?? usage.prompt_tokens_details.cache_creation_tokens }
: {}),
},
};
}

View file

@ -99,8 +99,8 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
}
}
// Handle tool_calls (empty array is truthy; require a real call)
if (delta.tool_calls && delta.tool_calls.length) {
// Handle tool_calls
if (delta.tool_calls) {
closeMessage(state, emit, idx);
for (const tc of delta.tool_calls) {
emitToolCall(state, emit, tc);

View file

@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.5.55",
"version": "0.5.50",
"description": "9Router web dashboard",
"private": true,
"scripts": {
@ -9,10 +9,10 @@
"build": "next build --webpack",
"postbuild": "node scripts/copy-standalone-assets.mjs",
"postbuild:bun": "node scripts/copy-standalone-assets.mjs",
"start": "node custom-server.js --port 20127",
"start": "next start --port 20127",
"dev:bun": "bun --bun next dev --webpack --port 20127",
"build:bun": "bun --bun next build --webpack",
"start:bun": "bun ./.next/standalone/custom-server.js",
"start:bun": "bun ./.next/standalone/server.js",
"cli:pack": "npm --prefix cli run pack:cli",
"cli:publish": "npm --prefix cli run publish:cli"
},
@ -23,10 +23,8 @@
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@next/third-parties": "^16.2.9",
"@node-saml/node-saml": "^5.1.0",
"@xyflow/react": "^12.10.1",
"bcryptjs": "^3.0.3",
"chalk": "^5.6.2",
"confbox": "^0.2.4",
"express": "^5.2.1",
"http-proxy-middleware": "^3.0.5",
@ -39,7 +37,6 @@
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
"ora": "^9.1.0",
"prop-types": "^15.8.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-is": "^16.13.1",

View file

@ -29,14 +29,6 @@ export function copyStandaloneAssets({ projectRoot = process.cwd(), distDir = pr
cpSync(publicSource, publicDestination, { recursive: true, force: true });
console.log(`[standalone-assets] Copied public assets to ${publicDestination}`);
}
// Without it beside server.js the standalone build serves requests unsanitized.
const serverWrapperSource = resolve(projectRoot, "custom-server.js");
const serverWrapperDestination = resolve(standaloneDir, "custom-server.js");
if (existsSync(serverWrapperSource)) {
cpSync(serverWrapperSource, serverWrapperDestination, { force: true });
console.log(`[standalone-assets] Copied custom-server.js to ${serverWrapperDestination}`);
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(dirname(fileURLToPath(import.meta.url)), "copy-standalone-assets.mjs")) {

View file

@ -170,7 +170,7 @@ export default function HermesToolCard({
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n api_key: \${OPENAI_API_KEY}\n`;
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n`;
const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
return [

View file

@ -21,7 +21,7 @@ function getLocaleFromCookie() {
export default function ProfilePage() {
const { theme, setTheme, isDark } = useTheme();
const [locale, setLocale] = useState(() => getLocaleFromCookie());
const [locale, setLocale] = useState("en");
const [langOpen, setLangOpen] = useState(false);
const [shutdownOpen, setShutdownOpen] = useState(false);
const [isShuttingDown, setIsShuttingDown] = useState(false);
@ -46,31 +46,8 @@ export default function ProfilePage() {
const [oidcLoading, setOidcLoading] = useState(false);
const [oidcTestLoading, setOidcTestLoading] = useState(false);
const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" });
const [oidcRedirectUri, setOidcRedirectUri] = useState("/api/auth/oidc/callback");
const [oidcExpanded, setOidcExpanded] = useState(false);
const origin = typeof window !== "undefined" ? window.location.origin : "";
const oidcRedirectUri = origin ? `${origin}/api/auth/oidc/callback` : "/api/auth/oidc/callback";
const samlAcsUrl = origin ? `${origin}/api/auth/saml/acs` : "/api/auth/saml/acs";
const samlMetadataUrl = origin ? `${origin}/api/auth/saml/metadata` : "/api/auth/saml/metadata";
// SAML State
const [ssoTypeTab, setSsoTypeTab] = useState("saml");
const [samlForm, setSamlForm] = useState({
samlEntryPoint: "",
samlIssuer: "urn:9router:sp",
samlCert: "",
samlLoginLabel: "Sign in with SAML SSO",
samlAttributeEmail: "email",
samlAttributeName: "name",
});
const [samlStatus, setSamlStatus] = useState({ type: "", message: "" });
const [samlLoading, setSamlLoading] = useState(false);
const [samlTestLoading, setSamlTestLoading] = useState(false);
const [samlTestStatus, setSamlTestStatus] = useState({ type: "", message: "" });
const [showSamlGuide, setShowSamlGuide] = useState(false);
const idpMetadataFileRef = useRef(null);
const certFileRef = useRef(null);
const importFileRef = useRef(null);
const [proxyForm, setProxyForm] = useState({
outboundProxyEnabled: false,
@ -81,6 +58,10 @@ export default function ProfilePage() {
const [proxyLoading, setProxyLoading] = useState(false);
const [proxyTestLoading, setProxyTestLoading] = useState(false);
useEffect(() => {
setLocale(getLocaleFromCookie());
}, [langOpen]);
useEffect(() => {
fetch("/api/settings")
.then((res) => res.json())
@ -94,23 +75,7 @@ export default function ProfilePage() {
oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC",
});
setOidcClientSecret("");
setSsoTypeTab(data?.ssoType || "saml");
setSamlForm({
samlEntryPoint: data?.samlEntryPoint || "",
samlIssuer: data?.samlIssuer || "urn:9router:sp",
samlCert: data?.samlCert || "",
samlLoginLabel: data?.samlLoginLabel || "Sign in with SAML SSO",
samlAttributeEmail: data?.samlAttributeEmail || "email",
samlAttributeName: data?.samlAttributeName || "name",
});
if (
data?.authMode === "sso" ||
data?.authMode === "saml" ||
data?.authMode === "oidc" ||
data?.authMode === "both"
) {
setOidcExpanded(true);
}
if (data?.authMode === "oidc" || data?.authMode === "both") setOidcExpanded(true);
setProxyForm({
outboundProxyEnabled: data?.outboundProxyEnabled === true,
outboundProxyUrl: data?.outboundProxyUrl || "",
@ -124,6 +89,12 @@ export default function ProfilePage() {
});
}, []);
useEffect(() => {
if (typeof window !== "undefined") {
setOidcRedirectUri(`${window.location.origin}/api/auth/oidc/callback`);
}
}, []);
const updateOutboundProxy = async (e) => {
e.preventDefault();
if (settings.outboundProxyEnabled !== true) return;
@ -360,7 +331,6 @@ export default function ProfilePage() {
try {
const payload = {
authMode,
ssoType: "oidc",
oidcIssuerUrl: issuerUrl,
oidcClientId: clientId,
oidcScopes: scopes || "openid profile email",
@ -475,159 +445,6 @@ export default function ProfilePage() {
}
};
const updateSamlForm = (field, value) => {
setSamlForm((prev) => ({ ...prev, [field]: value }));
};
const handleIdpMetadataUpload = (event) => {
const file = event.target.files?.[0];
if (idpMetadataFileRef.current) idpMetadataFileRef.current.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const xmlText = e.target?.result || "";
const parser = new DOMParser();
const doc = parser.parseFromString(xmlText, "text/xml");
const parserError = doc.querySelector("parsererror");
if (parserError) {
setSamlStatus({ type: "error", message: "Unable to parse valid SAML IdP metadata from XML file" });
return;
}
const entityID = doc.documentElement.getAttribute("entityID") || "";
const ssoNodes = Array.from(doc.querySelectorAll("SingleSignOnService, *|SingleSignOnService"));
let ssoUrl = "";
for (const node of ssoNodes) {
const binding = node.getAttribute("Binding") || "";
const location = node.getAttribute("Location") || "";
if (location) {
ssoUrl = location;
if (binding.includes("HTTP-Redirect")) break;
}
}
const certNodes = Array.from(doc.querySelectorAll("X509Certificate, *|X509Certificate"));
let certStr = "";
if (certNodes.length > 0) {
certStr = certNodes[0].textContent.trim();
}
setSamlForm((prev) => ({
...prev,
samlEntryPoint: ssoUrl || prev.samlEntryPoint,
samlIssuer: prev.samlIssuer || "urn:9router:sp",
samlCert: certStr || prev.samlCert,
}));
setSamlStatus({
type: "success",
message: `IdP Metadata imported! (SSO URL: ${ssoUrl ? "found" : "not found"}, EntityID: ${entityID ? "found" : "not found"}, Cert: ${certStr ? "found" : "not found"})`,
});
} catch (err) {
setSamlStatus({ type: "error", message: "Error reading IdP Metadata XML file" });
}
};
reader.readAsText(file);
};
const handleCertFileUpload = (event) => {
const file = event.target.files?.[0];
if (certFileRef.current) certFileRef.current.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result || "";
setSamlForm((prev) => ({ ...prev, samlCert: text.trim() }));
setSamlStatus({ type: "success", message: "Certificate file loaded into configuration." });
};
reader.readAsText(file);
};
const saveSamlSettings = async (targetAuthMode = oidcForm.authMode || "password") => {
setSamlLoading(true);
setSamlStatus({ type: "", message: "" });
setSamlTestStatus({ type: "", message: "" });
try {
const payload = {
authMode: targetAuthMode,
ssoType: "saml",
samlEntryPoint: samlForm.samlEntryPoint.trim(),
samlIssuer: samlForm.samlIssuer.trim() || "urn:9router:sp",
samlCert: samlForm.samlCert.trim(),
samlLoginLabel: samlForm.samlLoginLabel.trim() || "Sign in with SAML SSO",
samlAttributeEmail: samlForm.samlAttributeEmail.trim() || "email",
samlAttributeName: samlForm.samlAttributeName.trim() || "name",
};
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) {
setSettings((prev) => ({ ...prev, ...data }));
setSamlForm({
samlEntryPoint: data?.samlEntryPoint || payload.samlEntryPoint,
samlIssuer: data?.samlIssuer || payload.samlIssuer,
samlCert: data?.samlCert || payload.samlCert,
samlLoginLabel: data?.samlLoginLabel || payload.samlLoginLabel,
samlAttributeEmail: data?.samlAttributeEmail || payload.samlAttributeEmail,
samlAttributeName: data?.samlAttributeName || payload.samlAttributeName,
});
setSamlStatus({
type: "success",
message:
targetAuthMode === "sso" || targetAuthMode === "saml"
? "SAML SSO login enabled"
: targetAuthMode === "both"
? "Password and SAML SSO login enabled"
: "SAML 2.0 settings saved",
});
} else {
setSamlStatus({ type: "error", message: data.error || "Failed to save SAML settings" });
}
} catch {
setSamlStatus({ type: "error", message: "An error occurred while saving SAML settings" });
} finally {
setSamlLoading(false);
}
};
const testSamlConnection = async () => {
setSamlTestLoading(true);
setSamlStatus({ type: "", message: "" });
setSamlTestStatus({ type: "", message: "" });
try {
const res = await fetch("/api/auth/saml/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
samlEntryPoint: samlForm.samlEntryPoint.trim(),
samlIssuer: samlForm.samlIssuer.trim(),
samlCert: samlForm.samlCert.trim(),
}),
});
const data = await res.json();
if (res.ok && data.ok) {
setSamlTestStatus({ type: "success", message: data.message || "SAML configuration verified!" });
} else {
setSamlTestStatus({ type: "error", message: data.error || "SAML configuration test failed" });
}
} catch {
setSamlTestStatus({ type: "error", message: "An error occurred while testing SAML configuration" });
} finally {
setSamlTestLoading(false);
}
};
const updateObservabilityEnabled = async (enabled) => {
try {
const res = await fetch("/api/settings", {
@ -935,7 +752,7 @@ export default function ProfilePage() {
</div>
</Card>
{/* Single Sign-On (SSO) */}
{/* OIDC */}
<Card>
<button
type="button"
@ -946,13 +763,9 @@ export default function ProfilePage() {
<span className="material-symbols-outlined text-[20px]">lock_open</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold">Single Sign-On (SSO)</h3>
<h3 className="text-base sm:text-lg font-semibold">OIDC Dashboard Login</h3>
<p className="text-xs text-text-muted">
{settings.authMode === "sso" || settings.authMode === "oidc" || settings.authMode === "saml"
? `${settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"} SSO active`
: settings.authMode === "both"
? `Password + ${settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"} active`
: "Optional SSO via Okta, Entra ID, Keycloak, or OIDC"}
{settings.authMode === "oidc" ? "OIDC active" : settings.authMode === "both" ? "Password + OIDC active" : "Optional SSO via Authentik/Keycloak/Google"}
</p>
</div>
<span className="material-symbols-outlined text-text-muted shrink-0">
@ -960,472 +773,145 @@ export default function ProfilePage() {
</span>
</button>
{oidcExpanded && (
<div className="flex flex-col gap-4 mt-4">
<p className="text-xs sm:text-sm text-text-muted">
Configure enterprise Single Sign-On (SSO) for dashboard access using SAML 2.0 or OIDC.
</p>
<div className="flex flex-col gap-4 mt-4">
<p className="text-xs sm:text-sm text-text-muted">
Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.
</p>
{/* SSO Protocol Switcher Tabs */}
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">SSO Protocol</label>
<div className="flex p-1 rounded-lg bg-black/5 dark:bg-white/5 border border-border">
<button
type="button"
onClick={() => setSsoTypeTab("saml")}
className={cn(
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
ssoTypeTab === "saml"
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
)}
>
SAML 2.0
</button>
<button
type="button"
onClick={() => setSsoTypeTab("oidc")}
className={cn(
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
ssoTypeTab === "oidc"
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
)}
>
OIDC
</button>
</div>
</div>
{/* Auth Mode selection */}
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{[
{
value: "password",
title: "Password only",
desc: "Keep legacy password login.",
},
{
value: "sso",
title: `${ssoTypeTab === "saml" ? "SAML" : "OIDC"} only`,
desc: "Require SSO for dashboard access.",
},
{
value: "both",
title: "Both",
desc: "Allow password or SSO login.",
},
].map((option) => {
const currentMode = oidcForm.authMode;
const active =
option.value === "password"
? currentMode === "password"
: option.value === "sso"
? currentMode === "sso" || currentMode === "saml" || currentMode === "oidc"
: currentMode === "both";
return (
<button
key={option.value}
type="button"
onClick={() => updateOidcForm("authMode", option.value)}
className={cn(
"text-left rounded-lg border p-3 transition-colors",
active
? "border-primary bg-primary/5"
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
)}
disabled={loading || oidcLoading || samlLoading}
>
<p className="font-medium text-sm sm:text-base">{option.title}</p>
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
</button>
);
})}
</div>
</div>
{ssoTypeTab === "saml" ? (
/* SAML Configuration Panel */
<div className="flex flex-col gap-4 pt-2 border-t border-border/50">
{/* IdP Setup Guidelines Banner & Collapsible Drawer */}
<div className="rounded-lg border border-border bg-bg/80 overflow-hidden">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{[
{
value: "password",
title: "Password only",
desc: "Keep the legacy password login.",
},
{
value: "oidc",
title: "OIDC only",
desc: "Require OIDC for dashboard access.",
},
{
value: "both",
title: "Both",
desc: "Allow either password or OIDC.",
},
].map((option) => {
const active = oidcForm.authMode === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => setShowSamlGuide((prev) => !prev)}
className="w-full p-3 flex items-center justify-between gap-2 text-left hover:bg-surface/50 transition-colors"
onClick={() => updateOidcForm("authMode", option.value)}
className={cn(
"text-left rounded-lg border p-3 transition-colors",
active
? "border-primary bg-primary/5"
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
)}
disabled={loading || oidcLoading}
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-lg">menu_book</span>
<div>
<p className="font-semibold text-xs sm:text-sm text-text-main">
IdP Setup Guidelines & Provider Configuration Instructions
</p>
<p className="text-[11px] text-text-muted">
Click to view setup steps for AWS IAM Identity Center, Okta, Entra ID, Keycloak, & Authentik
</p>
</div>
</div>
<span
className="material-symbols-outlined text-text-muted transition-transform text-lg"
style={{ transform: showSamlGuide ? "rotate(180deg)" : "none" }}
>
expand_more
</span>
<p className="font-medium text-sm sm:text-base">{option.title}</p>
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
</button>
{showSamlGuide && (
<div className="p-4 border-t border-border bg-surface/30 text-xs text-text-main flex flex-col gap-3">
<div className="p-2.5 rounded border border-primary/20 bg-primary/5 text-primary text-xs">
<p className="font-semibold mb-1">🔑 Required Service Provider (SP) Values for your IdP Setup:</p>
<ul className="list-disc pl-4 space-y-1 font-mono text-[11px]">
<li>
<b>Assertion Consumer Service (ACS) URL:</b>{" "}
<code className="bg-bg px-1 py-0.5 rounded break-all">{samlAcsUrl}</code>
</li>
<li>
<b>SP Entity ID / Audience URI:</b>{" "}
<code className="bg-bg px-1 py-0.5 rounded break-all">{samlForm.samlIssuer || "urn:9router:sp"}</code>
</li>
<li>
<b>NameID Format:</b>{" "}
<code className="bg-bg px-1 py-0.5 rounded">EmailAddress</code> or <code className="bg-bg px-1 py-0.5 rounded">Unspecified</code>
</li>
</ul>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-1">
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
<p className="font-semibold text-text-main flex items-center gap-1.5">
<span>☁️</span> AWS IAM Identity Center
</p>
<ol className="list-decimal pl-4 text-text-muted space-y-1">
<li>Applications → <b>Add application</b> → Select <b>Add custom SAML 2.0 application</b>.</li>
<li>Set <b>Application ACS URL</b> to <code className="text-text-main font-mono">{samlAcsUrl}</code>.</li>
<li>Set <b>Application SAML audience</b> to <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code>.</li>
<li>Under <i>Attribute mappings</i>, map <code className="text-text-main font-mono">Subject</code> or <code className="text-text-main font-mono">email</code> to <code className="text-text-main font-mono">${`{user:email}`}</code>.</li>
<li>Download <b>IAM Identity Center SAML metadata XML</b> file and use 1-Click Import below!</li>
</ol>
</div>
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
<p className="font-semibold text-text-main flex items-center gap-1.5">
<span>🔷</span> Microsoft Entra ID (Azure AD)
</p>
<ol className="list-decimal pl-4 text-text-muted space-y-1">
<li>Enterprise Applications → <b>New application</b> → <b>Create your own application</b>.</li>
<li>Select <b>Single sign-on</b> → <b>SAML</b>.</li>
<li><b>Identifier (Entity ID):</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
<li><b>Reply URL (ACS):</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
<li>Download <b>Federation Metadata XML</b> and import or copy X.509 Certificate.</li>
</ol>
</div>
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
<p className="font-semibold text-text-main flex items-center gap-1.5">
<span>🟢</span> Okta / Auth0
</p>
<ol className="list-decimal pl-4 text-text-muted space-y-1">
<li>Applications → <b>Create App Integration</b> → Select <b>SAML 2.0</b>.</li>
<li><b>Single Sign-On URL:</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
<li><b>Audience URI (SP Entity ID):</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
<li>Name ID format: <i>EmailAddress</i>.</li>
<li>Download Identity Provider metadata XML or copy the X.509 cert.</li>
</ol>
</div>
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
<p className="font-semibold text-text-main flex items-center gap-1.5">
<span>🛡️</span> Keycloak / Authentik
</p>
<ol className="list-decimal pl-4 text-text-muted space-y-1">
<li>Clients → <b>Create client</b> → Select <b>SAML</b>.</li>
<li><b>Client ID:</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
<li><b>Master SAML Processing URL:</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
<li>Export SAML Descriptor XML or copy IDP Certificate PEM.</li>
</ol>
</div>
</div>
</div>
)}
</div>
{/* Quick Import Card */}
<div className="p-3 rounded-lg border border-dashed border-primary/40 bg-primary/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<p className="font-medium text-sm text-text-main">1-Click IdP Metadata XML Import</p>
<p className="text-xs text-text-muted">Auto-fill SSO URL, Issuer & Cert from XML metadata</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
icon="upload_file"
onClick={() => idpMetadataFileRef.current?.click()}
>
Upload Metadata XML
</Button>
<input
ref={idpMetadataFileRef}
type="file"
accept=".xml,application/xml,text/xml"
className="hidden"
onChange={handleIdpMetadataUpload}
/>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Single Sign-On Service URL (samlEntryPoint)</label>
<Input
placeholder="https://idp.example.com/app/saml/sso/..."
value={samlForm.samlEntryPoint}
onChange={(e) => updateSamlForm("samlEntryPoint", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">SP Entity ID / Audience (samlIssuer)</label>
<Input
placeholder="urn:9router:sp"
value={samlForm.samlIssuer}
onChange={(e) => updateSamlForm("samlIssuer", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="font-medium text-sm sm:text-base">IdP X.509 Certificate (samlCert)</label>
<Button
type="button"
variant="outline"
size="sm"
icon="file_upload"
onClick={() => certFileRef.current?.click()}
>
Upload Certificate
</Button>
<input
ref={certFileRef}
type="file"
accept=".crt,.pem,.cer,text/plain"
className="hidden"
onChange={handleCertFileUpload}
/>
</div>
<textarea
rows={4}
placeholder="-----BEGIN CERTIFICATE-----&#10;MIIC...&#10;-----END CERTIFICATE-----"
value={samlForm.samlCert}
onChange={(e) => updateSamlForm("samlCert", e.target.value)}
className="w-full p-2.5 rounded-lg border border-border bg-bg text-xs font-mono text-text-main focus:outline-none focus:border-primary"
disabled={loading || samlLoading}
/>
<p className="text-xs text-text-muted">Paste raw Base64 certificate or PEM block.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
<Input
placeholder="Sign in with SAML SSO"
value={samlForm.samlLoginLabel}
onChange={(e) => updateSamlForm("samlLoginLabel", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Email Claim Attribute</label>
<Input
placeholder="email"
value={samlForm.samlAttributeEmail}
onChange={(e) => updateSamlForm("samlAttributeEmail", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Display Name Claim</label>
<Input
placeholder="name"
value={samlForm.samlAttributeName}
onChange={(e) => updateSamlForm("samlAttributeName", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-bg text-xs sm:text-sm text-text-muted">
<div className="flex items-center justify-between gap-2">
<div>
<p className="font-medium text-text-main">ACS Callback URL</p>
<code className="block break-all font-mono text-xs">{samlAcsUrl}</code>
</div>
<Button
type="button"
variant="outline"
size="sm"
icon="content_copy"
onClick={() => {
navigator.clipboard.writeText(samlAcsUrl);
setSamlStatus({ type: "success", message: "ACS URL copied to clipboard!" });
}}
>
Copy
</Button>
</div>
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border/50">
<div>
<p className="font-medium text-text-main">SP XML Metadata</p>
<code className="block break-all font-mono text-xs">{samlMetadataUrl}</code>
</div>
<a
href={samlMetadataUrl}
target="_blank"
rel="noopener noreferrer"
download="9router-sp-metadata.xml"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
Download XML
</a>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
<Button
type="button"
variant="primary"
loading={samlLoading}
onClick={() => saveSamlSettings(oidcForm.authMode)}
className="w-full sm:w-auto"
>
Save SAML settings
</Button>
<Button
type="button"
variant="outline"
loading={samlTestLoading}
onClick={testSamlConnection}
className="w-full sm:w-auto"
>
Test SAML settings
</Button>
</div>
{samlTestStatus.message && (
<p className={`text-xs sm:text-sm ${samlTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{samlTestStatus.message}
</p>
)}
{samlStatus.message && (
<p className={`text-xs sm:text-sm ${samlStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{samlStatus.message}
</p>
)}
</div>
) : (
/* OIDC Panel */
<div className="flex flex-col gap-4 pt-2 border-t border-border/50">
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Issuer URL</label>
<Input
placeholder="https://auth.example.com/application/o/9router/"
value={oidcForm.oidcIssuerUrl}
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client ID</label>
<Input
placeholder="9router-dashboard"
value={oidcForm.oidcClientId}
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client Secret</label>
<Input
type="password"
placeholder="Leave blank to keep existing secret"
value={oidcClientSecret}
onChange={(e) => setOidcClientSecret(e.target.value)}
disabled={loading || oidcLoading}
/>
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Scopes</label>
<Input
placeholder="openid profile email"
value={oidcForm.oidcScopes}
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
<Input
placeholder="Sign in with OIDC"
value={oidcForm.oidcLoginLabel}
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
</div>
<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
<p className="font-medium text-text-main mb-1">Redirect URI</p>
<code className="block break-all font-mono">{oidcRedirectUri}</code>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto">
Save OIDC settings
</Button>
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto">
Test connection
</Button>
</div>
{oidcTestStatus.message && (
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcTestStatus.message}
</p>
)}
{oidcStatus.message && (
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcStatus.message}
</p>
)}
</div>
)}
{settings.authMode === "oidc" || settings.authMode === "saml" || settings.authMode === "sso" ? (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
SSO login ({settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"}) is currently active. Password login is disabled until you switch back.
</p>
) : null}
{settings.authMode === "both" && (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
Password and SSO login ({settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"}) are both active.
</p>
)}
);
})}
</div>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Issuer URL</label>
<Input
placeholder="https://auth.example.com/application/o/9router/"
value={oidcForm.oidcIssuerUrl}
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client ID</label>
<Input
placeholder="9router-dashboard"
value={oidcForm.oidcClientId}
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client Secret</label>
<Input
type="password"
placeholder="Leave blank to keep existing secret"
value={oidcClientSecret}
onChange={(e) => setOidcClientSecret(e.target.value)}
disabled={loading || oidcLoading}
/>
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Scopes</label>
<Input
placeholder="openid profile email"
value={oidcForm.oidcScopes}
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
<Input
placeholder="Sign in with OIDC"
value={oidcForm.oidcLoginLabel}
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
</div>
<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
<p className="font-medium text-text-main mb-1">Redirect URI</p>
<code className="block break-all font-mono">{oidcRedirectUri}</code>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto">
Save auth mode
</Button>
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto">
Test connection
</Button>
</div>
{oidcTestStatus.message && (
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcTestStatus.message}
</p>
)}
{oidcStatus.message && (
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcStatus.message}
</p>
)}
{settings.authMode === "oidc" && (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
OIDC login is currently active. Password login is disabled until you switch back.
</p>
)}
{settings.authMode === "both" && (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
Password and OIDC login are both active.
</p>
)}
</div>
)}
</Card>

View file

@ -529,7 +529,8 @@ export default function TokenSaverClient() {
</p>
</div>
<Toggle
checked={headroomEnabled}
checked={headroomEnabled && headroomRunning}
disabled={!headroomRunning}
onChange={() => handleHeadroomEnabled(!headroomEnabled)}
/>
</div>

View file

@ -216,7 +216,7 @@ export default function ProviderLimits() {
);
// Fetch quota for a specific connection
const fetchQuota = useCallback(async (connectionId, provider, { force = false } = {}) => {
const fetchQuota = useCallback(async (connectionId, provider) => {
setLoading((prev) => ({ ...prev, [connectionId]: true }));
setErrors((prev) => ({ ...prev, [connectionId]: null }));
@ -224,8 +224,7 @@ export default function ProviderLimits() {
console.log(
`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`,
);
const url = `/api/usage/${connectionId}${force ? "?force=1" : ""}`;
const response = await fetch(url);
const response = await fetch(`/api/usage/${connectionId}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
@ -296,7 +295,7 @@ export default function ProviderLimits() {
// Refresh quota for a specific provider
const refreshProvider = useCallback(
async (connectionId, provider) => {
await fetchQuota(connectionId, provider, { force: true });
await fetchQuota(connectionId, provider);
setLastUpdated(new Date());
},
[fetchQuota],

View file

@ -4,7 +4,7 @@ import { getModelsByProviderId } from "open-sse/config/providerModels.js";
export const QUOTA_CACHE_KEY = "quotaCacheData";
export const REFRESH_INTERVAL_MS = 60000;
// Claude usage/quota endpoint rate-limits; poll it less often than other providers
export const CLAUDE_REFRESH_INTERVAL_MS = 600000;
export const CLAUDE_REFRESH_INTERVAL_MS = 180000;
export const DEPLETED_QUOTA_THRESHOLD = 5;
export const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
export const CONNECTIONS_PAGE_SIZE = 20;
@ -36,17 +36,6 @@ export function getConnectionQuotaRemaining(connection, quotaData) {
return Number.POSITIVE_INFINITY;
}
// Stable group-by-provider: first-seen provider order, original order within group.
function groupByProviderStable(connections) {
const seen = new Map();
for (const conn of connections) {
const key = conn.provider || "";
if (!seen.has(key)) seen.set(key, []);
seen.get(key).push(conn);
}
return Array.from(seen.values()).flat();
}
export function sortVisibleConnections(
connections,
quotaData,
@ -69,7 +58,7 @@ export function sortVisibleConnections(
});
}
if (!expiringFirst) return groupByProviderStable(connections);
if (!expiringFirst) return connections;
const getEarliestResetTime = (connection) => {
const resetTimes = (quotaData[connection.id]?.quotas || [])

View file

@ -4,7 +4,6 @@ import bcrypt from "bcryptjs";
import { cookies } from "next/headers";
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
import { isOidcConfigured } from "@/lib/auth/oidc";
import { isSamlConfigured } from "@/lib/auth/saml.js";
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
import { isLocalRequest } from "@/dashboardGuard";
@ -40,14 +39,8 @@ export async function POST(request) {
// Default password is '123456' if not set
const storedHash = settings.password;
if (settings.authMode === "sso" || settings.authMode === "saml" || settings.authMode === "oidc") {
const ssoType = settings.ssoType || (settings.authMode === "saml" ? "saml" : "oidc");
if (ssoType === "saml" && isSamlConfigured(settings)) {
return NextResponse.json({ error: "Password login is disabled. Use SAML SSO sign in." }, { status: 403 });
}
if (ssoType === "oidc" && isOidcConfigured(settings)) {
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
}
if (settings.authMode === "oidc" && isOidcConfigured(settings)) {
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
}
let isValid = false;
@ -61,36 +54,15 @@ export async function POST(request) {
if (isValid) {
recordSuccess(ip);
const cookieStore = await cookies();
await setDashboardAuthCookie(cookieStore, request);
// Default password still in use on a remote client → force a password
// change before the dashboard is exposed remotely (keeps local UX intact).
const mustChangePassword =
!storedHash && !process.env.INITIAL_PASSWORD && !isLocalRequest(request);
if (mustChangePassword) {
// Do NOT issue a session token: a fresh install's default password is
// public knowledge ("123456"), so handing out a valid JWT would let any
// remote attacker authenticate and (e.g.) PATCH /api/settings to disable
// authentication entirely (CVE-2026-56679 class). Require the password
// to be changed first.
//
// NOTE: this intentionally leaves no remote self-service password-change
// path — the change-password flow (PATCH /api/settings) requires a JWT,
// which we deliberately withhold. A remote fresh-install user must either
// change the password from the local machine or set INITIAL_PASSWORD
// before first launch. This is a deliberate security trade-off, not an
// oversight: issuing any credential before the default password is
// rotated re-opens the exact attack chain this branch closes.
return NextResponse.json(
{ success: false, error: "Default password must be changed before remote access. Change it from the local machine (or set INITIAL_PASSWORD).", mustChangePassword },
{ status: 403, headers: NO_STORE_HEADERS }
);
}
const cookieStore = await cookies();
await setDashboardAuthCookie(cookieStore, request);
return NextResponse.json({ success: true, mustChangePassword: false }, { headers: NO_STORE_HEADERS });
return NextResponse.json({ success: true, mustChangePassword }, { headers: NO_STORE_HEADERS });
}
const { remainingBeforeLock } = recordFail(ip);

View file

@ -1,69 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSettings } from "@/lib/localDb";
import {
getSamlBaseUrl,
isSamlConfigured,
pickSamlDisplayName,
pickSamlEmail,
validateSamlResponse,
} from "@/lib/auth/saml.js";
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
export async function POST(request) {
const settings = await getSettings();
const origin = getSamlBaseUrl(request, settings);
const ip = getClientIp(request);
const lock = checkLock(ip);
if (lock.locked) {
return NextResponse.redirect(
new URL(
`/login?error=${encodeURIComponent(`Too many failed attempts. Try again in ${lock.retryAfter}s.`)}`,
origin
)
);
}
const cookieStore = await cookies();
const storedRequestId = cookieStore.get("saml_state")?.value || "";
// Always clear saml_state cookie after attempt
cookieStore.delete("saml_state");
try {
const formData = await request.formData();
const SAMLResponse = formData.get("SAMLResponse");
if (!SAMLResponse) {
recordFail(ip);
return NextResponse.redirect(new URL("/login?error=saml_missing_response", origin));
}
if (!isSamlConfigured(settings)) {
recordFail(ip);
return NextResponse.redirect(new URL("/login?error=saml_not_configured", origin));
}
const profile = await validateSamlResponse(request, { SAMLResponse }, storedRequestId, settings);
const samlEmail = pickSamlEmail(profile, settings) || null;
const samlName = pickSamlDisplayName(profile, settings) || "SAML user";
recordSuccess(ip);
await setDashboardAuthCookie(cookieStore, request, {
saml: true,
samlEmail,
samlName,
});
return NextResponse.redirect(new URL("/dashboard", origin));
} catch (error) {
recordFail(ip);
return NextResponse.redirect(
new URL(`/login?error=${encodeURIComponent(error.message || "saml_acs_failed")}`, origin)
);
}
}

View file

@ -1,25 +0,0 @@
import { getSettings } from "@/lib/localDb";
import { generateSamlMetadata } from "@/lib/auth/saml";
export async function GET(request) {
try {
const settings = await getSettings();
const origin = new URL(request.url).origin;
const metadataXml = generateSamlMetadata(origin, settings);
return new Response(metadataXml, {
status: 200,
headers: {
"Content-Type": "application/xml",
"Cache-Control": "no-cache",
},
});
} catch (error) {
return new Response(`<?xml version="1.0"?><Error>${error.message || "Failed to generate metadata"}</Error>`, {
status: 500,
headers: {
"Content-Type": "application/xml",
},
});
}
}

View file

@ -1,32 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSettings } from "@/lib/localDb";
import { buildSamlAuthorizeUrl, getSamlBaseUrl, isSamlConfigured } from "@/lib/auth/saml.js";
import { shouldUseSecureCookie } from "@/lib/auth/dashboardSession";
export async function GET(request) {
const settings = await getSettings();
const origin = getSamlBaseUrl(request, settings);
try {
if (!isSamlConfigured(settings)) {
return NextResponse.redirect(new URL("/login?error=saml_not_configured", origin));
}
const { authorizeUrl, requestId } = await buildSamlAuthorizeUrl(request, settings);
const cookieStore = await cookies();
cookieStore.set("saml_state", requestId, {
httpOnly: true,
secure: shouldUseSecureCookie(request),
sameSite: "lax",
path: "/",
maxAge: 10 * 60,
});
return NextResponse.redirect(authorizeUrl);
} catch (error) {
return NextResponse.redirect(
new URL(`/login?error=${encodeURIComponent(error.message || "saml_start_failed")}`, origin)
);
}
}

View file

@ -1,72 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSettings } from "@/lib/localDb";
import { formatX509Certificate } from "@/lib/auth/saml.js";
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
async function canAccessTestRoute() {
const settings = await getSettings();
if (settings.requireLogin === false) return true;
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
return await verifyDashboardAuthToken(token);
}
export async function POST(request) {
try {
if (!(await canAccessTestRoute())) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const settings = await getSettings();
const samlEntryPoint = String(body.samlEntryPoint || settings.samlEntryPoint || "").trim();
const samlIssuer = String(body.samlIssuer || settings.samlIssuer || "urn:9router:sp").trim();
const samlCert = String(
Object.prototype.hasOwnProperty.call(body, "samlCert")
? body.samlCert
: settings.samlCert || ""
).trim();
if (!samlEntryPoint) {
return NextResponse.json({ error: "Single Sign-On Service URL (samlEntryPoint) is required" }, { status: 400 });
}
try {
new URL(samlEntryPoint);
} catch {
return NextResponse.json({ error: "Single Sign-On Service URL must be a valid URL" }, { status: 400 });
}
if (!samlIssuer) {
return NextResponse.json({ error: "SP Entity ID / Issuer (samlIssuer) is required" }, { status: 400 });
}
if (!samlCert) {
return NextResponse.json({ error: "IdP X.509 Certificate (samlCert) is required" }, { status: 400 });
}
const formattedCert = formatX509Certificate(samlCert);
if (!formattedCert) {
return NextResponse.json({ error: "Invalid IdP X.509 Certificate format" }, { status: 400 });
}
const origin = new URL(request.url).origin;
const acsUrl = `${origin}/api/auth/saml/acs`;
const metadataUrl = `${origin}/api/auth/saml/metadata`;
return NextResponse.json({
ok: true,
samlEntryPoint,
samlIssuer,
certValid: true,
acsUrl,
metadataUrl,
message: "SAML 2.0 configuration verified successfully.",
});
} catch (error) {
return NextResponse.json({ error: error.message || "SAML test failed" }, { status: 500 });
}
}

View file

@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSettings } from "@/lib/localDb";
import { isOidcConfigured } from "@/lib/auth/oidc";
import { isSamlConfigured } from "@/lib/auth/saml.js";
import { getDashboardAuthSession } from "@/lib/auth/dashboardSession";
export async function GET() {
@ -12,29 +11,16 @@ export async function GET() {
const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value);
const requireLogin = settings.requireLogin !== false;
const authMode = settings.authMode || "password";
const ssoType = settings.ssoType || "oidc";
const oidcName = String(session?.oidcName || "").trim();
const oidcEmail = String(session?.oidcEmail || "").trim();
const samlName = String(session?.samlName || "").trim();
const samlEmail = String(session?.samlEmail || "").trim();
const displayName =
samlName ||
samlEmail ||
oidcName ||
oidcEmail ||
(session?.saml ? "SAML user" : session?.oidc ? "OIDC user" : "Password user");
const loginMethod = session?.saml ? "SAML" : session?.oidc ? "OIDC" : "Password";
const displayName = oidcName || oidcEmail || (session?.oidc ? "OIDC user" : "Password user");
const loginMethod = session?.oidc ? "OIDC" : "Password";
return NextResponse.json({
requireLogin,
authMode,
ssoType,
oidcConfigured: isOidcConfigured(settings),
oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC",
samlConfigured: isSamlConfigured(settings),
samlLoginLabel: (settings.samlLoginLabel || "Sign in with SAML SSO").trim() || "Sign in with SAML SSO",
hasPassword: !!settings.password,
displayName,
loginMethod,
@ -42,19 +28,13 @@ export async function GET() {
oidcName: oidcName || null,
oidcEmail: oidcEmail || null,
oidcLogin: !!session?.oidc,
samlName: samlName || null,
samlEmail: samlEmail || null,
samlLogin: !!session?.saml,
});
} catch {
return NextResponse.json({
requireLogin: true,
authMode: "password",
ssoType: "oidc",
oidcConfigured: false,
oidcLoginLabel: "Sign in with OIDC",
samlConfigured: false,
samlLoginLabel: "Sign in with SAML SSO",
hasPassword: false,
displayName: "Password user",
loginMethod: "Password",
@ -62,9 +42,6 @@ export async function GET() {
oidcName: null,
oidcEmail: null,
oidcLogin: false,
samlName: null,
samlEmail: null,
samlLogin: false,
});
}
}

View file

@ -20,7 +20,7 @@ const getHermesEnvPath = () => path.join(getHermesDir(), ".env");
const MODEL_BLOCK_RE = /^model:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m;
const buildModelBlock = (model, baseUrl) =>
`model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`;
`model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n`;
// Parse current model block back to fields (best-effort, simple key:value)
const parseModelBlock = (yaml) => {
@ -35,7 +35,6 @@ const parseModelBlock = (yaml) => {
default: get("default"),
provider: get("provider"),
base_url: get("base_url"),
api_key: get("api_key"),
};
};

View file

@ -135,11 +135,9 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
headers,
body: JSON.stringify({
model,
// 1024 tokens: reasoning models (ClinePass/kimi-k3, deepseek-v4-pro, etc.) spend
// their budget on chain-of-thought before emitting an answer. A tiny probe like
// max_tokens:16 starves the answer and yields a false "no choices" failure.
// See issue #3010.
max_tokens: 1024,
// Claude-on-Copilot returns empty choices at max_tokens:1 (budget is spent
// before a content token emits), so a 1-token probe yields a false negative.
max_tokens: 16,
stream: false,
messages: [{ role: "user", content: "hi" }],
}),
@ -182,21 +180,6 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
}
const hasChoices = Array.isArray(parsed?.choices) && parsed.choices.length > 0;
// Soft-pass (issue #3010): a reasoning model may burn its whole budget on
// chain-of-thought and return finish_reason:"length" with empty content but
// non-empty reasoning/thinking. That's a successful connection, not a failure.
const firstChoice = parsed?.choices?.[0] || {};
const hasReasoning =
firstChoice.message?.reasoning ||
firstChoice.message?.reasoning_content ||
firstChoice.message?.thinking ||
firstChoice.message?.thinking_content;
const contentEmpty = !String(firstChoice.message?.content || "").trim();
if (hasChoices && firstChoice.finish_reason === "length" && contentEmpty && hasReasoning) {
return { ok: true, latencyMs, error: null, status: res.status, note: "reasoning-only response (length-limited)" };
}
if (!hasChoices) {
return {
ok: false,

View file

@ -788,27 +788,6 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
);
return { valid: exRes.ok, error: exRes.ok ? null : "Invalid Personal Access Token" };
}
case "llm7": {
const baseUrl = connection.providerSpecificData?.baseUrl || "https://api.llm7.io/v1";
const res = await fetchWithConnectionProxy(`${baseUrl.replace(/\/$/, "")}/models`, {
headers: { Authorization: `Bearer ${connection.apiKey}` },
}, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
}
case "kimchi": {
// Dual-auth: same validation endpoint as the OAuth flow — the token (API key
// or OAuth access token) is sent as Authorization: Bearer.
const url = KIMCHI_CONFIG.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers";
const res = await fetchWithConnectionProxy(url, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${connection.apiKey}`,
"User-Agent": "kimchi/0.1.40",
},
}, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key", refreshed: false };
}
default:
return { valid: false, error: "Provider test not supported" };
}

View file

@ -123,7 +123,6 @@ export async function GET(request, { params }) {
let connection;
try {
const { connectionId } = await params;
const force = new URL(request.url).searchParams.get("force") === "1";
// Get connection from database
@ -169,7 +168,7 @@ export async function GET(request, { params }) {
}
// Fetch usage from provider API
let usage = await getUsageForProvider(connection, proxyOptions, { force });
let usage = await getUsageForProvider(connection, proxyOptions);
// If provider returned an auth-expired message instead of throwing,
// force-refresh token and retry once (OAuth only)
@ -177,7 +176,7 @@ export async function GET(request, { params }) {
try {
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
connection = retryResult.connection;
usage = await getUsageForProvider(connection, proxyOptions, { force });
usage = await getUsageForProvider(connection, proxyOptions);
} catch (retryError) {
console.warn(`[Usage] ${connection.provider}: force refresh failed: ${retryError.message}`);
}

View file

@ -47,23 +47,8 @@ export async function GET(request) {
if (endDate) filter.endDate = endDate;
const result = await getRequestDetails(filter);
// Redact conversation payloads: the stored details include full request
// bodies (user prompts, tool calls) and provider responses. Returning them
// wholesale lets any dashboard-authenticated user (or, if requireLogin is
// disabled, anyone) read every user's conversation history. Keep the
// metadata (model, tokens, latency, status) but drop message content.
const redactedDetails = (result.details || []).map((d) => {
const redacted = { ...d };
for (const key of ["request", "providerRequest", "providerResponse", "response"]) {
if (redacted[key] !== undefined) {
redacted[key] = { redacted: true };
}
}
return redacted;
});
return NextResponse.json({ ...result, details: redactedDetails });
return NextResponse.json(result);
} catch (error) {
console.error("[API] Failed to get request details:", error);
return NextResponse.json(

View file

@ -485,27 +485,6 @@ export async function buildModelsList(kindFilter, options = {}) {
|| capabilitiesFromServiceKind(customKind || liveKind)
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null);
if (caps) model.capabilities = caps;
// Token limits under the snake_case names the OpenAI/OpenRouter
// convention uses. `capabilities.contextWindow` is camelCase and nested,
// so clients matching context_length find nothing, fall back to guessing
// the window from the model name, and guess high — a 372k model read as
// 1.05M never reaches its compaction threshold and hard-fails upstream.
// Emitted at top level because not every client recurses into nested
// objects; the camelCase `capabilities` block stays for compatibility.
if (kind === LLM_KIND || allowAsLlm) {
let contextWindow = caps?.contextWindow;
let maxOutput = caps?.maxOutput;
// Live-catalog and service-kind capabilities are usually partial
// (often just { tools: true }), so fill the gaps from the static
// table rather than emitting null and leaving clients to guess.
if (!Number.isFinite(contextWindow) || !Number.isFinite(maxOutput)) {
const fallback = getCapabilitiesForModel(providerId, modelId);
if (!Number.isFinite(contextWindow)) contextWindow = fallback.contextWindow;
if (!Number.isFinite(maxOutput)) maxOutput = fallback.maxOutput;
}
if (Number.isFinite(contextWindow)) model.context_length = contextWindow;
if (Number.isFinite(maxOutput)) model.max_completion_tokens = maxOutput;
}
models.push(model);
}

View file

@ -11,11 +11,8 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false);
const [hasPassword, setHasPassword] = useState(null);
const [authMode, setAuthMode] = useState("password");
const [ssoType, setSsoType] = useState("oidc");
const [oidcConfigured, setOidcConfigured] = useState(false);
const [oidcLoginLabel, setOidcLoginLabel] = useState("Sign in with OIDC");
const [samlConfigured, setSamlConfigured] = useState(false);
const [samlLoginLabel, setSamlLoginLabel] = useState("Sign in with SAML SSO");
const [mustChange, setMustChange] = useState(false);
const [newPassword, setNewPassword] = useState("");
@ -46,11 +43,8 @@ export default function LoginPage() {
}
setHasPassword(!!data.hasPassword);
setAuthMode(data.authMode || "password");
setSsoType(data.ssoType || "oidc");
setOidcConfigured(data.oidcConfigured === true);
setOidcLoginLabel(data.oidcLoginLabel || "Sign in with OIDC");
setSamlConfigured(data.samlConfigured === true);
setSamlLoginLabel(data.samlLoginLabel || "Sign in with SAML SSO");
} else {
// Safe fallback on non-OK response to avoid infinite loading state.
setHasPassword(true);
@ -124,18 +118,8 @@ export default function LoginPage() {
window.location.href = "/api/auth/oidc/start";
};
const handleSamlLogin = () => {
window.location.href = "/api/auth/saml/start";
};
const isSsoEnabled = ["sso", "oidc", "saml", "both"].includes(authMode);
const activeSsoType = ssoType || (authMode === "saml" ? "saml" : "oidc");
const samlAvailable = isSsoEnabled && activeSsoType === "saml" && samlConfigured;
const oidcAvailable = isSsoEnabled && activeSsoType === "oidc" && oidcConfigured;
const ssoAvailable = samlAvailable || oidcAvailable;
const passwordAvailable = authMode === "password" || authMode === "both" || !ssoAvailable;
const oidcAvailable = oidcConfigured && ["oidc", "both"].includes(authMode);
const passwordAvailable = authMode !== "oidc" || !oidcConfigured;
// Show loading state while checking password
if (hasPassword === null) {
@ -157,9 +141,7 @@ export default function LoginPage() {
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary mb-2">9Router</h1>
<p className="text-text-muted">
{samlAvailable
? "Sign in with SAML 2.0 Single Sign-On"
: oidcAvailable
{authMode === "oidc" && oidcConfigured
? "Sign in with your OIDC provider to access the dashboard"
: "Enter your password to access the dashboard"}
</p>
@ -189,31 +171,25 @@ export default function LoginPage() {
</form>
) : (
<div className="flex flex-col gap-4">
{samlAvailable && (
<Button type="button" variant="primary" className="w-full" onClick={handleSamlLogin}>
{samlLoginLabel}
</Button>
)}
{oidcAvailable && (
<Button type="button" variant="primary" className="w-full" onClick={handleOidcLogin}>
{oidcLoginLabel}
</Button>
)}
{ssoAvailable && passwordAvailable && <div className="h-px bg-border/60" />}
{oidcAvailable && passwordAvailable && <div className="h-px bg-border/60" />}
{passwordAvailable ? (
<form onSubmit={handleLogin} className="flex flex-col gap-4">
{isSsoEnabled && !ssoAvailable && (
{((authMode === "oidc" && !oidcConfigured) || (authMode === "both" && !oidcConfigured)) && (
<p className="text-xs text-amber-600 dark:text-amber-400 text-center">
{activeSsoType === "saml" ? "SAML SSO" : "OIDC"} login is enabled, but configuration is incomplete. Password login is still available for recovery.
OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.
</p>
)}
{authMode === "both" && ssoAvailable && (
{authMode === "both" && oidcConfigured && (
<p className="text-xs text-text-muted text-center">
Password and {activeSsoType === "saml" ? "SAML SSO" : "OIDC"} login are both enabled.
Password and OIDC login are both enabled.
</p>
)}

View file

@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
import { getSettings, validateApiKey } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
import { hasTrustedPeerHeaders } from "@/lib/auth/trustedPeer";
const CLI_TOKEN_HEADER = "x-9r-cli-token";
const CLI_TOKEN_SALT = "9r-cli-auth";
@ -28,7 +27,6 @@ const PUBLIC_API_PATHS = [
"/api/auth/logout",
"/api/auth/status",
"/api/auth/oidc",
"/api/auth/saml",
"/api/version",
"/api/settings/require-login",
];
@ -88,40 +86,24 @@ const LOCAL_ONLY_PATHS = [
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
// Accepts a Host header, a URL hostname or a raw socket address. Splitting on the first
// colon only works for IPv4 and would reduce every IPv6 form to "", so a dual-stack
// listener handing back ::ffff:127.0.0.1 would not read as loopback.
function isLoopbackHostname(h) {
if (!h) return false;
let name = String(h).trim().toLowerCase();
if (name.startsWith("[")) {
const end = name.indexOf("]");
if (end === -1) return false;
name = name.slice(1, end);
} else if (name.indexOf(":") !== -1 && name.indexOf(":") === name.lastIndexOf(":")) {
name = name.slice(0, name.indexOf(":"));
}
if (name.startsWith("::ffff:")) name = name.slice(7);
const name = h.split(":")[0].replace(/^\[|\]$/g, "").toLowerCase();
return LOOPBACK_HOSTS.has(name);
}
function isLoopbackPeer(request) {
if (hasTrustedPeerHeaders(request)) {
return isLoopbackHostname(request.headers.get("x-9r-real-ip"));
}
// Bare `next dev` forks its server, so the wrapper never loads and no peer address
// reaches us. Host is spoofable, so this stays confined to development.
if (process.env.NODE_ENV === "development") {
return isLoopbackHostname(request.headers.get("host"));
}
return false;
}
export function isLocalRequest(request) {
// Stamped by custom-server.js when forwarding headers exist: request came through
// a reverse proxy, so the loopback socket is the proxy hop, not the end-user.
if (request.headers.get("x-9r-via-proxy")) return false;
if (!isLoopbackPeer(request)) return false;
// Trusted peer IP from TCP socket (custom-server.js); unspoofable. Primary anchor for "local".
const realIp = request.headers.get("x-9r-real-ip");
if (realIp) {
if (!isLoopbackHostname(realIp)) return false;
} else if (!isLoopbackHostname(request.headers.get("host"))) {
// Fallback for bare server.js (dev) without custom-server: legacy Host-based check.
return false;
}
const origin = request.headers.get("origin");
if (origin) {
try {

View file

@ -1,5 +1,4 @@
// In-memory progressive lockout for dashboard login. Resets on process restart.
import { hasTrustedPeerHeaders } from "./trustedPeer.js";
const MAX_FAILS_BEFORE_LOCK = 5;
const LOCK_STEPS_MS = [30_000, 120_000, 600_000, 1_800_000]; // 30s, 2m, 10m, 30m
@ -47,12 +46,9 @@ export function recordSuccess(ip) {
}
export function getClientIp(request) {
// Trusted only when custom-server.js proves it stamped the header from the TCP socket;
// otherwise a client could rotate the value to escape its own lockout bucket.
if (hasTrustedPeerHeaders(request)) {
const realIp = request.headers.get("x-9r-real-ip");
if (realIp) return realIp;
}
// Trusted: set from TCP socket by custom-server.js (client cannot spoof).
const realIp = request.headers.get("x-9r-real-ip");
if (realIp) return realIp;
// Behind a trusted reverse proxy that overwrites XFF with the real client IP.
if (process.env.TRUST_PROXY === "true") {
const xff = request.headers.get("x-forwarded-for");

View file

@ -1,268 +0,0 @@
import { SAML } from "@node-saml/node-saml";
import { getSettings } from "../db/repos/settingsRepo.js";
/**
* Formats a raw Base64 string or unformatted X.509 certificate into standard PEM format.
* @param {string} certStr
* @returns {string}
*/
export function formatX509Certificate(certStr) {
if (!certStr || typeof certStr !== "string") return "";
const clean = certStr
.replace(/-----BEGIN CERTIFICATE-----/gi, "")
.replace(/-----END CERTIFICATE-----/gi, "")
.replace(/[^A-Za-z0-9+/=]/g, "");
if (!clean) return "";
const lines = clean.match(/.{1,64}/g) || [];
return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----`;
}
/**
* Checks whether SAML configuration has essential parameters (entryPoint & cert).
* @param {object} settings
* @returns {boolean}
*/
export function isSamlConfigured(settings) {
return Boolean(settings?.samlEntryPoint && settings?.samlCert);
}
/**
* Fetches settings and returns runtime status + settings.
* @returns {Promise<{ configured: boolean, settings: object }>}
*/
export async function getSamlRuntimeConfig() {
const settings = await getSettings();
return {
configured: isSamlConfigured(settings),
settings,
};
}
/**
* Creates a configured `@node-saml/node-saml` SAML instance with security defaults.
* @param {object} settings
* @param {string} origin
* @returns {SAML}
*/
const DUMMY_FALLBACK_CERT =
"-----BEGIN CERTIFICATE-----\nMIIC...DUMMY...\n-----END CERTIFICATE-----";
function trimTrailingSlashes(str) {
return (str || "").replace(/\/+$/, "");
}
/**
* Resolves the public Base URL / Origin for SAML requests.
* Respects settings.baseUrl, process.env.BASE_URL, x-forwarded-proto, and x-forwarded-host.
* @param {Request} request
* @param {object} settings
* @returns {string}
*/
export function getSamlBaseUrl(request, settings) {
const configuredBaseUrl =
(settings?.baseUrl || "").trim() ||
process.env.BASE_URL ||
process.env.NEXT_PUBLIC_BASE_URL ||
"";
if (configuredBaseUrl) {
return trimTrailingSlashes(configuredBaseUrl);
}
if (request) {
const forwardedProto = request?.headers?.get?.("x-forwarded-proto") || "";
const forwardedHost = request?.headers?.get?.("x-forwarded-host") || "";
const host = forwardedHost || request?.headers?.get?.("host") || "";
if (host) {
const protocol = (forwardedProto || new URL(request.url).protocol || "http:").replace(/:$/, "");
return `${protocol}://${host}`.replace(/\/+$/, "");
}
if (request.url) {
return trimTrailingSlashes(new URL(request.url).origin);
}
}
return "http://localhost:20128";
}
export function createSamlInstance(settings, origin) {
const cert = formatX509Certificate(settings?.samlCert || "") || DUMMY_FALLBACK_CERT;
const callbackUrl = `${origin}/api/auth/saml/acs`;
return new SAML({
entryPoint: settings?.samlEntryPoint || "https://example.com/sso",
issuer: settings?.samlIssuer || "urn:9router:sp",
idpCert: cert,
cert: cert,
callbackUrl: callbackUrl,
acceptedClockSkewMs: 60000,
wantAssertionsSigned: true,
validateInResponseTo: "never",
requestIdExpirationMs: 28800000, // 8 hours
});
}
/**
* Builds SAML AuthnRequest redirect URL and returns { authorizeUrl, requestId }.
* @param {Request} request
* @param {object} settings
* @returns {Promise<{ authorizeUrl: string, requestId: string }>}
*/
export async function buildSamlAuthorizeUrl(request, settings) {
const origin = getSamlBaseUrl(request, settings);
const samlInstance = createSamlInstance(settings, origin);
const xml = await samlInstance.generateAuthorizeRequestAsync(false, false);
const match = xml.match(/ID="([^"]+)"/);
const requestId = match ? match[1] : "";
const authorizeUrl = await samlInstance._requestToUrlAsync(xml, null, "authorize", {});
return { authorizeUrl, requestId };
}
/**
* Validates SAML POST response from IdP ACS callback and returns user profile.
* @param {Request} request
* @param {object} body - Parsed form body or object containing SAMLResponse
* @param {string} expectedRequestId - Request ID stored in saml_state cookie
* @param {object} settings
* @returns {Promise<object>}
*/
export async function validateSamlResponse(request, body, expectedRequestId, settings) {
if (!settings?.samlCert) {
throw new Error("IdP X.509 Certificate (samlCert) is missing or not configured");
}
const origin = getSamlBaseUrl(request, settings);
const samlInstance = createSamlInstance(settings, origin);
const container = typeof body === "object" && body !== null ? body : { SAMLResponse: body };
const rawSamlResponse = container.SAMLResponse;
if (!rawSamlResponse) {
throw new Error("Missing SAMLResponse parameter in assertion POST body");
}
// Parse response XML to inspect InResponseTo for replay protection
if (expectedRequestId) {
const xml = Buffer.from(rawSamlResponse, "base64").toString("utf8");
const match = xml.match(/InResponseTo=["']([^"']+)["']/i);
const inResponseTo = match ? match[1] : null;
if (!inResponseTo || inResponseTo !== expectedRequestId) {
throw new Error(`InResponseTo mismatch: expected ${expectedRequestId}, received ${inResponseTo || "none"}`);
}
}
const result = await samlInstance.validatePostResponseAsync({ SAMLResponse: rawSamlResponse });
const profile = result?.profile || result;
return profile;
}
/**
* Generates standard SP XML Metadata.
* @param {string} origin
* @param {object} settings
* @returns {string}
*/
export function generateSamlMetadata(origin, settings) {
const samlInstance = createSamlInstance(settings, origin);
return samlInstance.generateServiceProviderMetadata();
}
/**
* Extracts email claim from SAML profile assertion.
* @param {object} profile
* @param {object} settings
* @returns {string}
*/
export function pickSamlEmail(profile = {}, settings = {}) {
if (!profile) return "";
// 1. Configured custom attribute
const customAttr = settings.samlAttributeEmail;
if (customAttr && profile[customAttr]) {
const val = profile[customAttr];
return Array.isArray(val) ? val[0] : String(val);
}
// 2. Common email claims
const emailKeys = [
"email",
"emailAddress",
"mail",
"nameID",
"nameId",
"upn",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn",
];
for (const key of emailKeys) {
if (profile[key]) {
const val = profile[key];
return Array.isArray(val) ? val[0] : String(val);
}
}
// 3. Fallback: check attributes object if present
if (profile.attributes) {
for (const key of emailKeys) {
if (profile.attributes[key]) {
const val = profile.attributes[key];
return Array.isArray(val) ? val[0] : String(val);
}
}
}
return "";
}
/**
* Extracts display name claim from SAML profile assertion.
* @param {object} profile
* @param {object} settings
* @returns {string}
*/
export function pickSamlDisplayName(profile = {}, settings = {}) {
if (!profile) return "";
// 1. Configured custom attribute
const customAttr = settings.samlAttributeName;
if (customAttr && profile[customAttr]) {
const val = profile[customAttr];
return Array.isArray(val) ? val[0] : String(val);
}
// 2. Common name claims
const nameKeys = [
"displayName",
"name",
"cn",
"commonName",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
];
for (const key of nameKeys) {
if (profile[key]) {
const val = profile[key];
return Array.isArray(val) ? val[0] : String(val);
}
}
// 3. Combined givenName + surname
if (profile.givenName || profile.sn || profile.surname) {
const given = profile.givenName || "";
const surname = profile.sn || profile.surname || "";
const combined = `${given} ${surname}`.trim();
if (combined) return combined;
}
// 4. Fallback to email
return pickSamlEmail(profile, settings);
}

View file

@ -1,7 +0,0 @@
// x-9r-real-ip is only trustworthy when custom-server.js stamped it from the TCP socket.
// It proves that by echoing the per-process secret it generated at boot, which a client
// cannot guess. Without the proof the header is just attacker-supplied input.
export function hasTrustedPeerHeaders(request) {
const token = process.env.NINEROUTER_PEER_TOKEN;
return Boolean(token) && request.headers.get("x-9r-peer-token") === token;
}

View file

@ -189,13 +189,14 @@ export async function createProviderConnection(data) {
}
// Critical: OAuth refresh token race — atomic merge inside transaction
export async function updateProviderConnection(id, data) {
export async function updateProviderConnection(id, update) {
const db = await getAdapter();
let result;
db.transaction(() => {
const row = db.get(`SELECT * FROM providerConnections WHERE id = ?`, [id]);
if (!row) { result = null; return; }
const existing = rowToConn(row);
const data = typeof update === "function" ? update(existing) : update;
const merged = { ...existing, ...data, updatedAt: new Date().toISOString() };
upsert(db, merged);
if (data.priority !== undefined) reorderInTx(db, existing.provider);

View file

@ -68,8 +68,6 @@ function sanitizeHeaders(headers) {
return sanitized;
}
export const __test__ = { sanitizeHeaders };
function generateDetailId(model) {
const timestamp = new Date().toISOString();
const random = Math.random().toString(36).substring(2, 8);

View file

@ -27,18 +27,11 @@ const DEFAULT_SETTINGS = {
requireApiKey: true,
tunnelDashboardAccess: true,
authMode: "password",
ssoType: "oidc",
oidcIssuerUrl: "",
oidcClientId: "",
oidcClientSecret: "",
oidcScopes: "openid profile email",
oidcLoginLabel: "Sign in with OIDC",
samlEntryPoint: "",
samlIssuer: "urn:9router:sp",
samlCert: "",
samlLoginLabel: "Sign in with SAML SSO",
samlAttributeEmail: "email",
samlAttributeName: "name",
enableObservability: false,
observabilityMaxRecords: 1000,
observabilityBatchSize: 20,
@ -70,7 +63,7 @@ async function readRaw() {
}
// Merge raw settings with defaults; backward-compat for missing keys
export function mergeWithDefaults(raw) {
function mergeWithDefaults(raw) {
const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
if (merged[key] === undefined) {

View file

@ -26,28 +26,10 @@ const TARGET_HOSTS = [
const URL_PATTERNS = {
antigravity: [":generateContent", ":streamGenerateContent"],
copilot: ["/chat/completions", "/v1/messages", "/responses"],
// Legacy path form. Kiro IDE 1.0.228+ posts to `/` with x-amz-target instead —
// see isChatRequest() for the header-based match.
kiro: ["/generateAssistantResponse"],
cursor: ["/BidiAppend", "/RunSSE", "/RunPoll", "/Run"],
};
/**
* Whether this request is a chat turn we should intercept (vs passthrough).
* Kiro Runtime moved GenerateAssistantResponse from path `/generateAssistantResponse`
* to `POST /` + `x-amz-target: KiroRuntimeService.GenerateAssistantResponse`
* (verified via live mitmproxy capture of Kiro IDE 1.0.228).
*/
function isChatRequest(tool, req) {
const patterns = URL_PATTERNS[tool] || [];
if (patterns.some((p) => (req.url || "").includes(p))) return true;
if (tool === "kiro") {
const target = String(req.headers?.["x-amz-target"] || "");
return target.includes("GenerateAssistantResponse");
}
return false;
}
// Synonym map: rawModel from request → canonical alias key in mitmAlias DB
const MODEL_SYNONYMS = {
antigravity: {
@ -55,9 +37,6 @@ const MODEL_SYNONYMS = {
"gemini-3.5-flash-high": "gemini-3-flash-agent",
"gemini-3.5-flash-medium": "gemini-3.5-flash-low",
"gemini-3.5-flash-extra-low": "gemini-3.5-flash-extra-low",
"gemini-3.7-flash-high": "gemini-3.7-flash-high",
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
"gemini-3.7-flash-low": "gemini-3.7-flash-low",
"gemini-3.1-pro-high": "gemini-pro-agent",
"gemini-3-pro-high": "gemini-pro-agent",
"gemini-3-pro-low": "gemini-3.1-pro-low",
@ -134,15 +113,13 @@ function extractModel(url, body) {
return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null;
}
const model = urlModel || parsed.model || null;
const cleanModelName = String(model).replace(/^models\//, "");
if (cleanModelName === "gemini-3.6-flash-tiered" || cleanModelName === "gemini-3.7-flash-tiered") {
const ver = cleanModelName.includes("3.7") ? "3.7" : "3.6";
if (String(model).replace(/^models\//, "") === "gemini-3.6-flash-tiered") {
const rawLevel = parsed.request?.generationConfig?.thinkingConfig?.thinkingLevel
|| parsed.generationConfig?.thinkingConfig?.thinkingLevel;
const level = ["high", "medium", "low"].includes(String(rawLevel).toLowerCase())
? String(rawLevel).toLowerCase()
: "medium";
return `gemini-${ver}-flash-${level}`;
return `gemini-3.6-flash-${level}`;
}
return model;
} catch {
@ -150,4 +127,4 @@ function extractModel(url, body) {
}
}
module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, isChatRequest, extractModel };
module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, extractModel };

View file

@ -43,8 +43,7 @@ function initKiroState(modelId) {
finishSent: false, // Whether termination has been emitted
usage: null, // Accumulated usage from usage-only chunks
inThink: false, // Whether inside a <thinking> block
thinkBuf: "", // Buffer for partial thinking content
initialSent: false, // Whether initial-response frame was emitted
thinkBuf: "" // Buffer for partial thinking content
};
}
@ -131,9 +130,9 @@ function encodeHeader(name, value) {
* The SmithyMessageDecoderStream layer requires three system headers on every frame:
* :message-type = "event" (or "exception" / "error")
* :event-type = e.g. "assistantResponseEvent"
* :content-type = "application/json" (initial-response uses x-amz-json-1.0)
* :content-type = "application/json"
*/
function buildEventStreamFrame(eventType, payload, contentType = "application/json") {
function buildEventStreamFrame(eventType, payload) {
const payloadBuf = Buffer.from(
typeof payload === "string" ? payload : JSON.stringify(payload),
"utf8"
@ -143,7 +142,7 @@ function buildEventStreamFrame(eventType, payload, contentType = "application/js
const headersBuf = Buffer.concat([
encodeHeader(":message-type", "event"),
encodeHeader(":event-type", eventType),
encodeHeader(":content-type", contentType),
encodeHeader(":content-type", "application/json"),
]);
const headersLen = headersBuf.length;
@ -160,24 +159,6 @@ function buildEventStreamFrame(eventType, payload, contentType = "application/js
return frame;
}
/** Real Kiro Runtime always starts the stream with this frame (capture of IDE 1.0.228). */
function buildInitialResponseFrame(conversationId = "") {
return buildEventStreamFrame(
"initial-response",
{ conversationId: conversationId || "" },
"application/x-amz-json-1.0"
);
}
/** Prepend initial-response once per stream so Smithy decoder is happy. */
function withInitialFrame(state, frames) {
const list = frames == null ? [] : Array.isArray(frames) ? frames : [frames];
if (state.initialSent) return list.length === 0 ? null : list.length === 1 ? list[0] : list;
state.initialSent = true;
const out = [buildInitialResponseFrame(""), ...list];
return out.length === 1 ? out[0] : out;
}
// ─── CodeWhisperer → OpenAI conversion ───────────────────────────────────────
/**
@ -340,12 +321,12 @@ function convertOpenAIToKiro(chunk, state) {
state.inThink = false;
const thinking = state.thinkBuf;
state.thinkBuf = "";
return withInitialFrame(state, buildEventStreamFrame("reasoningContentEvent", {
return buildEventStreamFrame("reasoningContentEvent", {
content: thinking,
modelId: state.modelId || "kiro-unknown"
}));
});
}
return withInitialFrame(state, buildEventStreamFrame("messageStopEvent", {}));
return buildEventStreamFrame("messageStopEvent", {});
}
const frames = [];
@ -427,12 +408,8 @@ function convertOpenAIToKiro(chunk, state) {
}
}
if (frames.length === 0) {
// اولین چانک ممکنه فقط role/empty باشه — initial رو همون‌جا بفرست
if (!state.initialSent) return withInitialFrame(state, null);
return null;
}
return withInitialFrame(state, frames.length === 1 ? frames[0] : frames);
if (frames.length === 0) return null;
return frames.length === 1 ? frames[0] : frames;
}
/**

View file

@ -7,7 +7,7 @@ const dns = require("dns");
const { promisify } = require("util");
const { execSync } = require("child_process");
const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger");
const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, isChatRequest, extractModel } = require("./config");
const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, extractModel } = require("./config");
const { DATA_DIR, MITM_DIR } = require("./paths");
const { generateCert, getCertForDomain } = require("./cert/generate");
const { getMitmAlias } = require("./dbReader");
@ -311,8 +311,9 @@ const server = https.createServer(sslOptions, async (req, res) => {
const tool = getToolForHost(req.headers.host);
if (!tool) return passthrough(req, res, bodyBuffer);
// Kiro IDE posts chat to `/` with x-amz-target (not path /generateAssistantResponse)
if (!isChatRequest(tool, req)) return passthrough(req, res, bodyBuffer);
const patterns = URL_PATTERNS[tool] || [];
const isChat = patterns.some(p => req.url.includes(p));
if (!isChat) return passthrough(req, res, bodyBuffer);
// Cursor uses binary proto — model extraction not possible at this layer.
// Delegate directly to handler which decodes proto internally.

View file

@ -198,7 +198,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
if (!res.ok) return;
const data = await res.json();
if (!cancelled) {
setDisplayName(data?.displayName || data?.samlName || data?.samlEmail || data?.oidcName || data?.oidcEmail || "");
setDisplayName(data?.displayName || data?.oidcName || data?.oidcEmail || "");
setLoginMethod(data?.loginMethod || "");
}
} catch {
@ -303,15 +303,12 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
{/* Right actions */}
<div className="flex items-center gap-1 shrink-0">
{displayName && (loginMethod === "OIDC" || loginMethod === "SAML") && (
<div
className="hidden sm:flex items-center max-w-[220px] px-3 py-1.5 rounded-full border border-border bg-surface/70 text-xs text-text-muted truncate"
title={displayName}
>
{displayName && loginMethod === "OIDC" && (
<div className="hidden sm:flex items-center max-w-[220px] px-3 py-1.5 rounded-full border border-border bg-surface/70 text-xs text-text-muted truncate">
<span className="material-symbols-outlined text-[14px] mr-1.5 text-primary">person</span>
<span className="truncate">{displayName}</span>
<span className="ml-2 shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
{loginMethod}
OIDC
</span>
</div>
)}

View file

@ -8,7 +8,7 @@ export const MITM_TOOLS = {
description: "Google Antigravity IDE with MITM",
configType: "mitm",
mitmDomain: "daily-cloudcode-pa.googleapis.com",
modelAliases: ["gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", "gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
modelAliases: ["gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
defaultModels: [
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", alias: "gemini-3.6-flash-high" },
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", alias: "gemini-3.6-flash-medium" },
@ -57,13 +57,8 @@ export const MITM_TOOLS = {
color: "#FF6B00",
description: "Kiro IDE with MITM",
configType: "mitm",
mitmDomain: "runtime.us-east-1.kiro.dev",
mitmDomain: "q.us-east-1.amazonaws.com",
defaultModels: [
// Kiro's agent/"vibe" mode sends modelId "auto" for the main turn and "simple-task"
// for background sub-tasks (verified via MITM request dump of generateAssistantResponse).
// Both need a mappable slot — otherwise getMappedModel returns null and the chat call
// is passed through to AWS instead of being routed to the chosen provider.
{ id: "auto", name: "Auto (Kiro Agent)", alias: "auto" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", alias: "claude-sonnet-5" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4.5" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", alias: "claude-sonnet-4" },

View file

@ -1,6 +1,6 @@
import {
extractApiKey, isValidApiKey,
getProviderCredentials, markAccountUnavailable,
getProviderCredentials, markAccountUnavailable, clearAccountError,
} from "../services/auth.js";
import { getSettings } from "@/lib/localDb";
import { getModelInfo } from "../services/model.js";
@ -74,7 +74,10 @@ export async function handleStt(request) {
const result = await handleSttCore({ provider, model, formData, credentials, sttConfig: AI_PROVIDERS[provider]?.sttConfig });
if (result.success) return result.response;
if (result.success) {
await clearAccountError(credentials.connectionId, credentials, model);
return result.response;
}
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
if (shouldFallback) {

View file

@ -1,6 +1,6 @@
import {
extractApiKey, isValidApiKey,
getProviderCredentials, markAccountUnavailable,
getProviderCredentials, markAccountUnavailable, clearAccountError,
} from "../services/auth.js";
import { getSettings } from "@/lib/localDb";
import { getModelInfo, getComboModels } from "../services/model.js";
@ -101,7 +101,10 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language, st
const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat, language, style });
if (result.success) return result.response;
if (result.success) {
await clearAccountError(credentials.connectionId, credentials, model);
return result.response;
}
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
if (shouldFallback) {

View file

@ -1,6 +1,6 @@
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb";
import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy";
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js";
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil, resetAccountState } from "open-sse/services/accountFallback.js";
import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js";
import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js";
import * as log from "../utils/logger.js";
@ -274,43 +274,8 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
*/
export async function clearAccountError(connectionId, currentConnection, model = null) {
if (!connectionId || connectionId === "noauth") return;
const conn = currentConnection._connection || currentConnection;
const now = Date.now();
const allLockKeys = Object.keys(conn).filter(k => k.startsWith("modelLock_"));
if (!conn.testStatus && !conn.lastError && allLockKeys.length === 0) return;
// Keys to clear: current model's lock + all expired locks
const keysToClear = allLockKeys.filter(k => {
if (model && k === `modelLock_${model}`) return true; // succeeded model
if (model && k === "modelLock___all") return true; // account-level lock
const expiry = conn[k];
return expiry && new Date(expiry).getTime() <= now; // expired
});
if (keysToClear.length === 0 && conn.testStatus !== "unavailable" && !conn.lastError) return;
// Check if any active locks remain after clearing
const remainingActiveLocks = allLockKeys.filter(k => {
if (keysToClear.includes(k)) return false;
const expiry = conn[k];
return expiry && new Date(expiry).getTime() > now;
});
const clearObj = Object.fromEntries(keysToClear.map(k => [k, null]));
// Only reset error state if no active locks remain
if (remainingActiveLocks.length === 0) {
Object.assign(clearObj, {
testStatus: "active",
lastError: null,
errorCode: null,
lastErrorAt: null,
backoffLevel: 0
});
}
await updateProviderConnection(connectionId, clearObj);
// Reset inside transaction so concurrent 429 writes cannot leave stale locks.
await updateProviderConnection(connectionId, resetAccountState);
}
/**

View file

@ -122,7 +122,6 @@
"alicode": "alicode",
"alicode-intl": "alicode-intl",
"alims-intl": "alims-intl",
"alitp-intl": "alitp-intl",
"anthropic": "anthropic",
"antigravity": "ag",
"api-airforce": "af",
@ -207,7 +206,6 @@
"alicode",
"alicode-intl",
"alims-intl",
"alitp-intl",
"anthropic",
"assemblyai",
"black-forest-labs",

View file

@ -708,37 +708,7 @@
"opencode-go": {
"baseUrl": "https://opencode.ai/zen/go/v1/chat/completions",
"headers": {},
"format": "openai",
"transports": [
{
"format": "openai",
"baseUrl": "https://opencode.ai/zen/go/v1/chat/completions",
"auth": {
"combined": true,
"header": "Authorization",
"scheme": "bearer"
}
},
{
"format": "claude",
"baseUrl": "https://opencode.ai/zen/go/v1/messages",
"auth": {
"combined": true,
"header": "x-api-key",
"scheme": "raw",
"anthropicVersion": true
}
},
{
"format": "openai-responses",
"baseUrl": "https://opencode.ai/zen/go/v1/responses",
"auth": {
"combined": true,
"header": "Authorization",
"scheme": "bearer"
}
}
]
"format": "openai"
},
"opencode": {
"baseUrl": "https://opencode.ai",
@ -998,15 +968,7 @@
"tokenrouter": {
"baseUrl": "https://api.tokenrouter.com/v1/chat/completions",
"validateUrl": "https://api.tokenrouter.com/v1/models",
"thinkingFormat": "tokenrouter",
"format": "openai"
},
"alitp-intl": {
"baseUrl": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
"headers": {},
"quirks": {
"preserveCacheControl": true
},
"thinkingFormat": "openai",
"format": "openai"
}
}

View file

@ -1,40 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
formatX509Certificate,
isSamlConfigured,
generateSamlMetadata,
pickSamlEmail,
pickSamlDisplayName,
} from "../../src/lib/auth/saml.js";
test("formatX509Certificate normalizes Base64 strings into PEM blocks", () => {
const rawBase64 = "MIIC1234567890123456789012345678901234567890123456789012345678901234567890";
const formatted = formatX509Certificate(rawBase64);
assert.match(formatted, /-----BEGIN CERTIFICATE-----/);
assert.match(formatted, /-----END CERTIFICATE-----/);
assert.equal(formatX509Certificate(""), "");
});
test("isSamlConfigured checks required fields", () => {
assert.equal(isSamlConfigured({ samlEntryPoint: "https://idp.com/sso", samlCert: "cert" }), true);
assert.equal(isSamlConfigured({ samlEntryPoint: "https://idp.com/sso" }), false);
assert.equal(isSamlConfigured({}), false);
});
test("generateSamlMetadata produces valid SP XML", () => {
const settings = {
samlEntryPoint: "https://idp.example.com/sso",
samlIssuer: "urn:9router:sp",
samlCert: "MIIC123456789012345678901234567890123456789012345678901234567890",
};
const xml = generateSamlMetadata("https://localhost:20127", settings);
assert.match(xml, /entityID="urn:9router:sp"/);
assert.match(xml, /Location="https:\/\/localhost:20127\/api\/auth\/saml\/acs"/);
});
test("Claims Extraction pickSamlEmail & pickSamlDisplayName", () => {
const profile = { email: "test@example.com", name: "Test User" };
assert.equal(pickSamlEmail(profile, {}), "test@example.com");
assert.equal(pickSamlDisplayName(profile, {}), "Test User");
});

View file

@ -38,25 +38,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > alicode-intl → hea
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > alims-intl → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > anthropic → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -85,31 +66,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > anthropic → header
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > api-airforce → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -129,44 +85,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → heade
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > baidu → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > bazaarlink → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -186,25 +104,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > bluesminds → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > byteplus → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -369,52 +268,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > cline → headers (a
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > clinepass → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://cline.bot",
"User-Agent": "9Router/0.5.50",
"X-CLIENT-TYPE": "9router",
"X-CLIENT-VERSION": "0.5.50",
"X-CORE-VERSION": "0.5.50",
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "linux",
"X-PLATFORM-VERSION": "v24.15.0",
"X-Title": "Cline",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://cline.bot",
"User-Agent": "9Router/0.5.50",
"X-CLIENT-TYPE": "9router",
"X-CLIENT-VERSION": "0.5.50",
"X-CORE-VERSION": "0.5.50",
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "linux",
"X-PLATFORM-VERSION": "v24.15.0",
"X-Title": "Cline",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://cline.bot",
"User-Agent": "9Router/0.5.50",
"X-CLIENT-TYPE": "9router",
"X-CLIENT-VERSION": "0.5.50",
"X-CORE-VERSION": "0.5.50",
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "linux",
"X-PLATFORM-VERSION": "v24.15.0",
"X-Title": "Cline",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -471,43 +324,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-cn → hea
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-intl → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
"X-IDE-Name": "IDE",
"X-IDE-Type": "IDE",
"X-Product": "SaaS",
"x-codebuddy-request": "1",
"x-requested-with": "XMLHttpRequest",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
"X-IDE-Name": "IDE",
"X-IDE-Type": "IDE",
"X-Product": "SaaS",
"x-codebuddy-request": "1",
"x-requested-with": "XMLHttpRequest",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
"X-IDE-Name": "IDE",
"X-IDE-Type": "IDE",
"X-Product": "SaaS",
"x-codebuddy-request": "1",
"x-requested-with": "XMLHttpRequest",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > cohere → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -565,25 +381,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > deepseek → headers
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > featherless → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -685,34 +482,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers (
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > grok-cli → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
"x-grok-client-identifier": "grok-shell",
"x-grok-client-version": "0.2.99",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
"x-grok-client-identifier": "grok-shell",
"x-grok-client-version": "0.2.99",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
"x-grok-client-identifier": "grok-shell",
"x-grok-client-version": "0.2.99",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -751,25 +520,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > hyperbolic → heade
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > kilo-gateway → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -789,28 +539,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > kimchi → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "kimchi/0.1.50",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "kimchi/0.1.50",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "kimchi/0.1.50",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -873,25 +601,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > kimi-coding → head
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > llm7 → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > minimax → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -980,25 +689,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > mmf → headers (api
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > morph → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > nanobanana → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -1138,63 +828,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > perplexity → heade
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > perplexity-agent → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > poolside → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > sambanova → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -1214,25 +847,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → head
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > tencent → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > together → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -1252,44 +866,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > together → headers
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > tokenrouter → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > venice → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -1366,28 +942,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > xiaomi-mimo → head
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > zed → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "<CRED>",
"Content-Type": "application/json",
"content-type": "application/json",
},
"nonStream": {
"Authorization": "<CRED>",
"Content-Type": "application/json",
"content-type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "<CRED>",
"Content-Type": "application/json",
"content-type": "application/json",
},
}
`;
exports[`GOLDEN buildUrl (default executor providers) > alicode → url (stream + non-stream) 1`] = `
{
"nonStream": "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
@ -1402,13 +956,6 @@ exports[`GOLDEN buildUrl (default executor providers) > alicode-intl → url (st
}
`;
exports[`GOLDEN buildUrl (default executor providers) > alims-intl → url (stream + non-stream) 1`] = `
{
"nonStream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
"stream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.anthropic.com/v1/messages",
@ -1416,13 +963,6 @@ exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (strea
}
`;
exports[`GOLDEN buildUrl (default executor providers) > api-airforce → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.airforce/v1/chat/completions",
"stream": "https://api.airforce/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.assemblyai.com/v1/audio/transcriptions",
@ -1430,20 +970,6 @@ exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stre
}
`;
exports[`GOLDEN buildUrl (default executor providers) > baidu → url (stream + non-stream) 1`] = `
{
"nonStream": "https://qianfan.baidubce.com/v2/chat/completions",
"stream": "https://qianfan.baidubce.com/v2/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > bazaarlink → url (stream + non-stream) 1`] = `
{
"nonStream": "https://bazaarlink.ai/api/v1/chat/completions",
"stream": "https://bazaarlink.ai/api/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.blackbox.ai/chat/completions",
@ -1451,13 +977,6 @@ exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream
}
`;
exports[`GOLDEN buildUrl (default executor providers) > bluesminds → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.bluesminds.com/v1/chat/completions",
"stream": "https://api.bluesminds.com/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > byteplus → url (stream + non-stream) 1`] = `
{
"nonStream": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions",
@ -1493,13 +1012,6 @@ exports[`GOLDEN buildUrl (default executor providers) > cline → url (stream +
}
`;
exports[`GOLDEN buildUrl (default executor providers) > clinepass → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.cline.bot/api/v1/chat/completions",
"stream": "https://api.cline.bot/api/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > cloudflare-ai → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions",
@ -1514,13 +1026,6 @@ exports[`GOLDEN buildUrl (default executor providers) > codebuddy-cn → url (st
}
`;
exports[`GOLDEN buildUrl (default executor providers) > codebuddy-intl → url (stream + non-stream) 1`] = `
{
"nonStream": "https://www.codebuddy.ai/v2/chat/completions",
"stream": "https://www.codebuddy.ai/v2/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > cohere → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.cohere.ai/v1/chat/completions",
@ -1542,13 +1047,6 @@ exports[`GOLDEN buildUrl (default executor providers) > deepseek → url (stream
}
`;
exports[`GOLDEN buildUrl (default executor providers) > featherless → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.featherless.ai/v1/chat/completions",
"stream": "https://api.featherless.ai/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.fireworks.ai/inference/v1/chat/completions",
@ -1584,13 +1082,6 @@ exports[`GOLDEN buildUrl (default executor providers) > glm-cn → url (stream +
}
`;
exports[`GOLDEN buildUrl (default executor providers) > grok-cli → url (stream + non-stream) 1`] = `
{
"nonStream": "https://cli-chat-proxy.grok.com/v1/responses",
"stream": "https://cli-chat-proxy.grok.com/v1/responses",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.groq.com/openai/v1/chat/completions",
@ -1605,13 +1096,6 @@ exports[`GOLDEN buildUrl (default executor providers) > hyperbolic → url (stre
}
`;
exports[`GOLDEN buildUrl (default executor providers) > kilo-gateway → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.kilo.ai/api/gateway/chat/completions",
"stream": "https://api.kilo.ai/api/gateway/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.kilo.ai/api/openrouter/chat/completions",
@ -1619,13 +1103,6 @@ exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream
}
`;
exports[`GOLDEN buildUrl (default executor providers) > kimchi → url (stream + non-stream) 1`] = `
{
"nonStream": "https://llm.kimchi.dev/openai/v1/chat/completions",
"stream": "https://llm.kimchi.dev/openai/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.kimi.com/coding/v1/messages?beta=true",
@ -1640,13 +1117,6 @@ exports[`GOLDEN buildUrl (default executor providers) > kimi-coding → url (str
}
`;
exports[`GOLDEN buildUrl (default executor providers) > llm7 → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.llm7.io/v1/chat/completions",
"stream": "https://api.llm7.io/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > minimax → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.minimax.io/anthropic/v1/messages?beta=true",
@ -1675,13 +1145,6 @@ exports[`GOLDEN buildUrl (default executor providers) > mmf → url (stream + no
}
`;
exports[`GOLDEN buildUrl (default executor providers) > morph → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.morphllm.com/v1/chat/completions",
"stream": "https://api.morphllm.com/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > nanobanana → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.nanobananaapi.ai/v1/chat/completions",
@ -1731,27 +1194,6 @@ exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stre
}
`;
exports[`GOLDEN buildUrl (default executor providers) > perplexity-agent → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.perplexity.ai/v1/responses",
"stream": "https://api.perplexity.ai/v1/responses",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > poolside → url (stream + non-stream) 1`] = `
{
"nonStream": "https://inference.poolside.ai/v1/chat/completions",
"stream": "https://inference.poolside.ai/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > sambanova → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.sambanova.ai/v1/chat/completions",
"stream": "https://api.sambanova.ai/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.siliconflow.com/v1/chat/completions",
@ -1759,13 +1201,6 @@ exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (str
}
`;
exports[`GOLDEN buildUrl (default executor providers) > tencent → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions",
"stream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > together → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.together.xyz/v1/chat/completions",
@ -1773,20 +1208,6 @@ exports[`GOLDEN buildUrl (default executor providers) > together → url (stream
}
`;
exports[`GOLDEN buildUrl (default executor providers) > tokenrouter → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.tokenrouter.com/v1/chat/completions",
"stream": "https://api.tokenrouter.com/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > venice → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.venice.ai/api/v1/chat/completions",
"stream": "https://api.venice.ai/api/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = `
{
"nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions",
@ -1814,10 +1235,3 @@ exports[`GOLDEN buildUrl (default executor providers) > xiaomi-mimo → url (str
"stream": "https://api.xiaomimimo.com/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > zed → url (stream + non-stream) 1`] = `
{
"nonStream": "https://cloud.zed.dev/completions",
"stream": "https://cloud.zed.dev/completions",
}
`;

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { applyErrorState, resetAccountState } from "../../open-sse/services/accountFallback.js";
describe("resetAccountState", () => {
it("clears every model lock and backoff after success", () => {
const reset = resetAccountState({ backoffLevel: 9, modelLock_alpha: "2099-01-01T00:00:00.000Z", modelLock___all: "2099-01-01T00:00:00.000Z", lastError: "429", errorCode: 429 });
expect(reset.backoffLevel).toBe(0);
expect(reset.modelLock_alpha).toBeNull();
expect(reset.modelLock___all).toBeNull();
expect(reset.lastError).toBeNull();
});
it("keeps 429 on error path ratcheting upward", () => {
expect(applyErrorState({ backoffLevel: 3 }, 429, "rate limited").backoffLevel).toBe(4);
});
});

View file

@ -1,45 +0,0 @@
import { describe, expect, it } from "vitest";
import REGISTRY from "../../open-sse/providers/registry/index.js";
import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js";
describe("Alibaba Token Plan provider", () => {
const entry = REGISTRY.find((e) => e.id === "alitp-intl");
it("is registered as an OpenAI-compatible apikey provider", () => {
expect(entry).toBeDefined();
expect(entry.category).toBe("apikey");
expect(PROVIDERS["alitp-intl"]).toBeDefined();
expect(PROVIDERS["alitp-intl"].format).toBe("openai");
});
it("targets the Singapore Token Plan host in compatible mode", () => {
// eu-central-1 answers IllegalEndpoint; the plan is Singapore-only.
expect(PROVIDERS["alitp-intl"].baseUrl).toBe(
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
);
});
it("does not collide with the other three Alibaba key types", () => {
const hosts = ["alicode", "alicode-intl", "alims-intl", "alitp-intl"]
.map((id) => new URL(PROVIDERS[id].baseUrl).host);
expect(new Set(hosts).size).toBe(hosts.length);
});
it("exposes the models the plan actually serves", () => {
const ids = (PROVIDER_MODELS["alitp-intl"] || []).map((m) => m.id);
expect(ids).toEqual(expect.arrayContaining([
"qwen3.8-max-preview",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-flash",
"glm-5.2",
"deepseek-v4-pro",
]));
});
it("keeps every registry id unique after adding the provider", () => {
const ids = REGISTRY.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
});

View file

@ -1,54 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/usageDb.js", () => ({
appendRequestLog: vi.fn(async () => {}),
saveRequestDetail: vi.fn(async () => {}),
saveRequestUsage: vi.fn(async () => {})
}));
const { extractUsageFromResponse } = await import("../../open-sse/handlers/chatCore/requestDetail.js");
const USAGE_METADATA = {
promptTokenCount: 1234,
candidatesTokenCount: 56,
cachedContentTokenCount: 78,
thoughtsTokenCount: 90,
};
const EXPECTED = {
prompt_tokens: 1234,
completion_tokens: 56,
cached_tokens: 78,
reasoning_tokens: 90,
};
describe("#3260 non-streaming usage extraction for enveloped Gemini responses", () => {
it("reads usageMetadata out of the antigravity { response } envelope", () => {
expect(extractUsageFromResponse({ response: { usageMetadata: USAGE_METADATA } })).toEqual(EXPECTED);
});
it("still reads a top-level usageMetadata", () => {
expect(extractUsageFromResponse({ usageMetadata: USAGE_METADATA })).toEqual(EXPECTED);
});
it("prefers the top-level metadata when both are present", () => {
const enveloped = { ...USAGE_METADATA, promptTokenCount: 1 };
const out = extractUsageFromResponse({
usageMetadata: USAGE_METADATA,
response: { usageMetadata: enveloped },
});
expect(out.prompt_tokens).toBe(1234);
});
it("leaves the OpenAI and Claude shapes alone", () => {
expect(extractUsageFromResponse({ usage: { prompt_tokens: 10, completion_tokens: 2 } }))
.toMatchObject({ prompt_tokens: 10, completion_tokens: 2 });
expect(extractUsageFromResponse({ usage: { input_tokens: 10, output_tokens: 2 } }))
.toMatchObject({ prompt_tokens: 10, completion_tokens: 2 });
});
it("returns null when there is no usage anywhere", () => {
expect(extractUsageFromResponse({ response: { candidates: [] } })).toBeNull();
expect(extractUsageFromResponse(null)).toBeNull();
});
});

View file

@ -1,61 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const proxyAwareFetch = vi.fn(async (url) => ({
ok: true,
status: 200,
json: async () => url.includes(":loadCodeAssist")
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
: {
models: {
"gemini-3.7-flash-high": {
displayName: "Gemini 3.7 Flash (High)",
quotaInfo: { remainingFraction: 0.85, resetTime: "2026-08-25T12:00:00Z" },
},
"gemini-3.7-flash-medium": {
displayName: "Gemini 3.7 Flash (Medium)",
quotaInfo: { remainingFraction: 0.6, resetTime: "2026-08-25T12:00:00Z" },
},
"gemini-3.7-flash-low": {
displayName: "Gemini 3.7 Flash (Low)",
quotaInfo: { remainingFraction: 0.35, resetTime: "2026-08-25T12:00:00Z" },
},
"internal-model": {
displayName: "Internal",
isInternal: true,
quotaInfo: { remainingFraction: 0.5 },
},
},
},
text: async () => "{}",
}));
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
proxyAwareFetch,
}));
describe("Antigravity quota tracker: Gemini 3.7 Flash usage bars", () => {
beforeEach(() => proxyAwareFetch.mockClear());
it("returns Gemini 3.7 Flash tier quotas so the dashboard can render usage bars", async () => {
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
const usage = await getAntigravityUsage("access-token", {});
expect(usage.quotas["gemini-3.7-flash-high"]).toMatchObject({
used: 150,
total: 1000,
remainingPercentage: 85,
displayName: "Gemini 3.7 Flash (High)",
});
expect(usage.quotas["gemini-3.7-flash-medium"]).toMatchObject({
used: 400,
total: 1000,
remainingPercentage: 60,
});
expect(usage.quotas["gemini-3.7-flash-low"]).toMatchObject({
used: 650,
total: 1000,
remainingPercentage: 35,
});
});
});

View file

@ -1,86 +0,0 @@
// custom-server.js is the only thing that makes x-9r-real-ip trustworthy. Boot a real
// HTTP server through it and confirm a client cannot smuggle its own peer headers in.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createRequire } from "node:module";
import http from "node:http";
import { __test__ as requestDetails } from "@/lib/db/repos/requestDetailsRepo.js";
const require = createRequire(import.meta.url);
let server;
let baseUrl;
let seenHeaders;
beforeAll(async () => {
require("../../custom-server.js");
server = http.createServer((req, res) => {
seenHeaders = req.headers;
res.end("ok");
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
afterAll(async () => {
await new Promise((resolve) => server.close(resolve));
});
async function get(headers = {}) {
await fetch(baseUrl, { headers });
return seenHeaders;
}
describe("custom-server peer header sanitizing", () => {
it("generates a peer trust token at boot", () => {
expect(process.env.NINEROUTER_PEER_TOKEN).toMatch(/^[0-9a-f]{48}$/);
});
it("replaces a client-supplied x-9r-real-ip with the socket address", async () => {
const headers = await get({ "x-9r-real-ip": "203.0.113.55" });
expect(headers["x-9r-real-ip"]).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
});
it("stamps the trust token so downstream can tell the wrapper ran", async () => {
const headers = await get();
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
});
it("drops a client-supplied peer trust token", async () => {
const headers = await get({ "x-9r-peer-token": "forged-token" });
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
expect(headers["x-9r-peer-token"]).not.toBe("forged-token");
});
it("drops a client-supplied x-9r-via-proxy marker", async () => {
const headers = await get({ "x-9r-via-proxy": "1" });
expect(headers["x-9r-via-proxy"]).toBeUndefined();
});
it("marks via-proxy and adopts the forwarded IP for a loopback proxy hop", async () => {
const headers = await get({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" });
expect(headers["x-9r-via-proxy"]).toBe("1");
expect(headers["x-9r-real-ip"]).toBe("203.0.113.9");
expect(headers["x-forwarded-for"]).toBeUndefined();
});
// chat.js snapshots every client header into the request detail. Anything that grants
// access must not survive into a record the dashboard renders and cloud sync uploads.
it("keeps the peer token out of persisted request details", () => {
const sanitized = requestDetails.sanitizeHeaders({
"x-9r-peer-token": "secret",
"x-9r-cli-token": "secret",
"authorization": "Bearer sk-x",
"x-9r-real-ip": "127.0.0.1",
});
expect(sanitized["x-9r-peer-token"]).toBeUndefined();
expect(sanitized["x-9r-cli-token"]).toBeUndefined();
expect(sanitized["authorization"]).toBeUndefined();
expect(sanitized["x-9r-real-ip"]).toBe("127.0.0.1");
});
});

View file

@ -35,8 +35,6 @@ vi.mock("@/lib/auth/dashboardSession", () => ({
const { proxy, __test__ } = await import("../../src/dashboardGuard.js");
const PEER_TOKEN = "peer-token-fixture";
function request(pathname, headers = {}) {
const normalizedHeaders = new Headers(headers);
return {
@ -47,16 +45,9 @@ function request(pathname, headers = {}) {
};
}
// A request that actually came through custom-server.js: peer IP stamped from the TCP
// socket and proven by the per-process secret.
function localRequest(pathname, headers = {}) {
return request(pathname, { "x-9r-peer-token": PEER_TOKEN, "x-9r-real-ip": "127.0.0.1", ...headers });
}
describe("dashboard guard public LLM API access", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@ -64,14 +55,14 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback public LLM API without API key", async () => {
const response = await proxy(localRequest("/v1/chat/completions", { host: "localhost:20128" }));
const response = await proxy(request("/v1/chat/completions", { host: "localhost:20128" }));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
});
it("rejects remote Host-spoof when real peer IP is non-loopback", async () => {
const response = await proxy(localRequest("/v1/chat/completions", {
const response = await proxy(request("/v1/chat/completions", {
host: "localhost",
"x-9r-real-ip": "10.204.111.34",
}));
@ -81,7 +72,7 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback peer IP regardless of Host", async () => {
const response = await proxy(localRequest("/v1/chat/completions", {
const response = await proxy(request("/v1/chat/completions", {
host: "localhost:20128",
"x-9r-real-ip": "127.0.0.1",
}));
@ -98,7 +89,7 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback rewritten public LLM API without API key", async () => {
const response = await proxy(localRequest("/api/v1/chat/completions", { host: "localhost:20128" }));
const response = await proxy(request("/api/v1/chat/completions", { host: "localhost:20128" }));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
@ -200,7 +191,6 @@ describe("dashboard guard public LLM API access", () => {
describe("dashboard guard local-only access", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@ -217,7 +207,7 @@ describe("dashboard guard local-only access", () => {
});
it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => {
const response = await proxy(localRequest("/api/mcp/filesystem/sse", {
const response = await proxy(request("/api/mcp/filesystem/sse", {
host: "localhost:20128",
origin: "http://localhost:20128",
}));
@ -229,7 +219,7 @@ describe("dashboard guard local-only access", () => {
it("allows local-only route on loopback when requireLogin=false", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
host: "localhost:20128",
origin: "http://localhost:20128",
}));
@ -250,7 +240,7 @@ describe("dashboard guard local-only access", () => {
it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
host: "localhost:20128",
origin: "http://evil.example.com",
}));

View file

@ -1,109 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import REGISTRY from "../../open-sse/providers/registry/index.js";
import { PROVIDER_MEDIA } from "../../open-sse/providers/index.js";
import { FORMAT_HANDLERS } from "../../open-sse/handlers/ttsProviders/genericFormats.js";
import { AI_PROVIDERS } from "@/shared/constants/providers";
const AUDIO = new Uint8Array(256).fill(7);
function okResponse() {
return {
ok: true,
status: 200,
headers: new Headers({ "content-type": "audio/mpeg" }),
arrayBuffer: async () => AUDIO.buffer,
};
}
describe("Fish Audio TTS provider", () => {
const entry = REGISTRY.find((e) => e.id === "fish-audio");
it("is registered as a TTS-only apikey provider", () => {
expect(entry).toBeDefined();
expect(entry.category).toBe("apikey");
expect(entry.serviceKinds).toEqual(["tts"]);
expect(PROVIDER_MEDIA["fish-audio"]?.ttsConfig?.baseUrl).toBe("https://api.fish.audio/v1/tts");
});
it("is visible to the generic dispatcher, which reads AI_PROVIDERS", () => {
// synthesizeViaConfig() looks the provider up here, not in PROVIDER_MEDIA.
expect(AI_PROVIDERS["fish-audio"]?.ttsConfig?.format).toBe("fish-audio");
expect(typeof FORMAT_HANDLERS["fish-audio"]).toBe("function");
});
it("exposes the four documented models", () => {
const ids = (entry.ttsConfig.models || []).map((m) => m.id);
expect(ids).toEqual(["s2.1-pro-free", "s2.1-pro", "s2-pro", "s1"]);
});
it("keeps registry ids and aliases unique", () => {
const ids = REGISTRY.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
const aliases = REGISTRY.map((e) => e.alias).filter(Boolean);
expect(new Set(aliases).size).toBe(aliases.length);
});
});
describe("Fish Audio TTS request shape", () => {
const handler = FORMAT_HANDLERS["fish-audio"];
let fetchMock;
beforeEach(() => {
fetchMock = vi.fn(async () => okResponse());
global.fetch = fetchMock;
});
const callArgs = () => {
const [url, init] = fetchMock.mock.calls.at(-1);
return { url, init, body: JSON.parse(init.body) };
};
it("sends the model as an HTTP header, not in the body", async () => {
await handler({
baseUrl: "https://api.fish.audio/v1/tts",
apiKey: "sk-test",
text: "xin chào",
modelId: "s1",
voiceId: "",
});
const { url, init, body } = callArgs();
expect(url).toBe("https://api.fish.audio/v1/tts");
expect(init.headers.model).toBe("s1");
expect(init.headers.Authorization).toBe("Bearer sk-test");
expect(body).toEqual({ text: "xin chào", format: "mp3" });
});
it("maps the voice onto reference_id, and omits it when unset", async () => {
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "voice-abc" });
expect(callArgs().body.reference_id).toBe("voice-abc");
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" });
expect(callArgs().body).not.toHaveProperty("reference_id");
});
it("defaults to the free model when none is given", async () => {
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "", voiceId: "" });
expect(callArgs().init.headers.model).toBe("s2.1-pro-free");
});
it("returns base64 audio with its format", async () => {
const out = await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" });
expect(out.format).toBe("mp3");
expect(typeof out.base64).toBe("string");
expect(out.base64.length).toBeGreaterThan(0);
});
it("surfaces the upstream error message", async () => {
global.fetch = vi.fn(async () => ({
ok: false,
status: 402,
text: async () => JSON.stringify({ message: "Insufficient credit" }),
}));
await expect(
handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" }),
).rejects.toThrow("Insufficient credit");
});
});

View file

@ -1,83 +0,0 @@
// Issue #3024 — Fusion combo must strip `stream_options` from panel requests
// when running non-streaming, or DeepSeek rejects with
// "stream_options should be set along with stream = true".
import { describe, it, expect, vi } from "vitest";
import { handleFusionChat } from "../../open-sse/services/combo.js";
// Minimal logger stub (combo.js calls log.info/warn).
const log = { info: () => {}, warn: () => {}, error: () => {} };
function makeBody(extra = {}) {
return {
model: "combo/gemseek",
stream: true,
stream_options: { include_usage: true },
messages: [{ role: "user", content: "hi" }],
...extra,
};
}
describe("Fusion strips stream_options (#3024)", () => {
it("removes stream_options before fanning out to panel models", async () => {
let capturedPanelBody = null;
const handleSingleModel = vi.fn(async (panelBody, model, isPanel) => {
if (isPanel) capturedPanelBody = panelBody;
// Simulate a successful non-stream JSON answer for the panel.
if (isPanel) {
return new Response(JSON.stringify({ choices: [{ message: { content: `ans-${model}` } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// Judge leg: return a final answer.
return new Response(JSON.stringify({ choices: [{ message: { content: "final" } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
const res = await handleFusionChat({
body: makeBody(),
models: ["ds/deepseek-v4-flash", "gemini/gemini-3.5-flash-lite"],
handleSingleModel,
log,
comboName: "GemSeek",
judgeModel: "gemini/gemini-3.5-flash-lite",
});
expect(res).toBeInstanceOf(Response);
expect(capturedPanelBody).not.toBeNull();
// Critical assertion: stream_options must NOT leak into panel requests.
expect(capturedPanelBody.stream_options).toBeUndefined();
expect(capturedPanelBody.stream).toBe(false);
// Ensure the original client body still had it (proves we stripped deliberately).
expect(makeBody().stream_options).toBeDefined();
});
it("does not throw for a 2-model fusion with stream_options present", async () => {
const handleSingleModel = vi.fn(async (panelBody, model, isPanel) => {
if (isPanel) {
return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ choices: [{ message: { content: "final" } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
const res = await handleFusionChat({
body: makeBody({ stream_options: { include_usage: true } }),
models: ["ds/deepseek-v4-pro", "ds/deepseek-v4-flash"],
handleSingleModel,
log,
comboName: "GemSeek",
judgeModel: "ds/deepseek-v4-pro",
});
expect(res.status).toBe(200);
});
});

View file

@ -1,36 +0,0 @@
import { describe, it, expect } from "vitest";
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
import antigravityRegistry from "../../open-sse/providers/registry/antigravity.js";
import geminiRegistry from "../../open-sse/providers/registry/gemini.js";
import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
describe("Gemini 3.7 Flash Support & Config (#3286, #3281)", () => {
it("registers gemini-3.7-flash tiered models in antigravity provider registry", () => {
const agIds = antigravityRegistry.models.map(m => m.id);
expect(agIds).toContain("gemini-3.7-flash-high");
expect(agIds).toContain("gemini-3.7-flash-medium");
expect(agIds).toContain("gemini-3.7-flash-low");
expect(agIds).not.toContain("gemini-3.7-flash");
});
it("registers gemini-3.7-flash in gemini provider registry", () => {
const geminiIds = geminiRegistry.models.map(m => m.id);
expect(geminiIds).toContain("gemini-3.7-flash");
});
it("resolves capabilities correctly for gemini-3.7 models with official limits", () => {
const caps = getCapabilitiesForModel("antigravity", "gemini-3.7-flash-high");
expect(caps.vision).toBe(true);
expect(caps.reasoning).toBe(true);
expect(caps.thinkingFormat).toBe("gemini-level");
expect(caps.contextWindow).toBe(1048576);
expect(caps.maxOutput).toBe(65536);
});
it("defines pricing matching gemini-3.6-flash baseline", () => {
expect(MODEL_PRICING["gemini-3.7-flash"]).toEqual(MODEL_PRICING["gemini-3.6-flash"]);
expect(MODEL_PRICING["gemini-3.7-flash-high"]).toEqual(MODEL_PRICING["gemini-3.6-flash-high"]);
expect(MODEL_PRICING["gemini-3.7-flash-medium"]).toEqual(MODEL_PRICING["gemini-3.6-flash-medium"]);
expect(MODEL_PRICING["gemini-3.7-flash-low"]).toEqual(MODEL_PRICING["gemini-3.6-flash-low"]);
});
});

View file

@ -1,115 +0,0 @@
import { describe, it, expect } from "vitest";
import { detectRequiredCapabilities } from "../../open-sse/services/combo.js";
import { augmentModelsWithCapacityAdapter } from "../../open-sse/services/capacityAdapter.js";
import { stripUnsupportedModalities } from "../../open-sse/translator/concerns/modality.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
describe("Hermes Vision Image Detection", () => {
it("detects vision from Ollama / Hermes images array", () => {
const body = {
messages: [
{
role: "user",
content: "Please analyze this image from Hermes",
images: ["iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="],
},
],
};
const caps = detectRequiredCapabilities(body);
expect(caps.has("vision")).toBe(true);
});
it("detects vision from Vercel AI SDK / Hermes experimental_attachments", () => {
const body = {
messages: [
{
role: "user",
content: "Describe this attachment",
experimental_attachments: [
{
contentType: "image/png",
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
},
],
},
],
};
const caps = detectRequiredCapabilities(body);
expect(caps.has("vision")).toBe(true);
});
it("detects vision from Hermes attachments array", () => {
const body = {
messages: [
{
role: "user",
content: "Look at this photo",
attachments: [
{
mediaType: "image/jpeg",
url: "https://example.com/photo.jpg",
},
],
},
],
};
const caps = detectRequiredCapabilities(body);
expect(caps.has("vision")).toBe(true);
});
it("detects vision from embedded data:image URI in string content", () => {
const body = {
messages: [
{
role: "user",
content: "Here is an inline image: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
},
],
};
const caps = detectRequiredCapabilities(body);
expect(caps.has("vision")).toBe(true);
});
it("auto-switches non-vision model (deepseek-v4-pro) to Vision Adapter model (Kimi-K3)", () => {
const body = {
messages: [
{
role: "user",
content: "Analyze image",
images: ["base64data..."],
},
],
};
const reqCaps = detectRequiredCapabilities(body);
const settings = {
capacityAdapter: {
vision: {
enabled: true,
models: ["cmc/moonshotai/Kimi-K3"],
},
},
};
const augmented = augmentModelsWithCapacityAdapter(["cmc/deepseek/deepseek-v4-pro"], reqCaps, settings);
expect(augmented).toEqual(["cmc/moonshotai/Kimi-K3", "cmc/deepseek/deepseek-v4-pro"]);
});
it("strips msg.images and attachments when model does not support vision", () => {
const body = {
messages: [
{
role: "user",
content: "Test text",
images: ["base64..."],
experimental_attachments: [{ contentType: "image/png", url: "data:image/png;base64,..." }],
},
],
};
const noVisionCaps = { vision: false, pdf: false, audioInput: false };
stripUnsupportedModalities(body, FORMATS.OPENAI, noVisionCaps);
expect(body.messages[0].images).toBeUndefined();
expect(body.messages[0].experimental_attachments).toHaveLength(0);
});
});

View file

@ -14,13 +14,6 @@ describe("Kiro MITM model slots", () => {
expect(Array.isArray(kiro.defaultModels)).toBe(true);
});
it("offers a mappable slot for the agent default model id 'auto'", () => {
// اسلات auto برای vibe mode لازمه — وگرنه درخواست میره AWS
const auto = kiro.defaultModels.find((m) => m.id === "auto");
expect(auto).toBeTruthy();
expect(auto.alias).toBe("auto");
});
it("offers a mappable slot for Claude Sonnet 5", () => {
const sonnet5 = kiro.defaultModels.find((m) => m.id === "claude-sonnet-5");
expect(sonnet5).toBeTruthy();

Some files were not shown because too many files have changed in this diff Show more