Compare commits
32 commits
preserve/o
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 39ddda477e | |||
|
|
699edac327 | ||
|
|
540ebbe682 | ||
|
|
e1115e2839 | ||
|
|
27f3710c8b | ||
|
|
59d858b639 | ||
|
|
92259214db | ||
|
|
b04c03c6b5 | ||
|
|
8b2b2fefb5 | ||
|
|
86694ed8d0 | ||
|
|
8af5e752da | ||
|
|
8ed9da7165 | ||
|
|
7e5f5a8813 | ||
|
|
345cdcf6a5 | ||
|
|
65197ad11c | ||
|
|
e02bde4a70 | ||
|
|
30fec4318e | ||
|
|
67271d859e | ||
|
|
b566b20ade | ||
|
|
6d30ce6de5 | ||
|
|
5b417f9bf2 | ||
|
|
b57c041345 | ||
|
|
8a527fec91 | ||
|
|
70ba0024b0 | ||
|
|
80afb59907 | ||
|
|
10a923da11 | ||
|
|
01858feca0 | ||
|
|
e2a4fe048f | ||
|
|
b44bb09f72 | ||
|
|
456f2a2635 | ||
|
|
cd4003bc8b | ||
|
|
71dcdc1053 |
112 changed files with 9244 additions and 506 deletions
86
CHANGELOG.md
86
CHANGELOG.md
|
|
@ -1,3 +1,89 @@
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ 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 && \
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
|
||||
|
||||
[🇻🇳 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)
|
||||
[🇧🇷 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)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "9router",
|
||||
"version": "0.5.50",
|
||||
"version": "0.5.55",
|
||||
"description": "9Router CLI - Start and manage 9Router server",
|
||||
"bin": {
|
||||
"9router": "./cli.js"
|
||||
|
|
|
|||
|
|
@ -216,7 +216,9 @@ function buildCliPackage() {
|
|||
fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
|
||||
console.log("✅ Copied custom-server.js\n");
|
||||
} else {
|
||||
console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n");
|
||||
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);
|
||||
}
|
||||
|
||||
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ 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" },
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
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() {
|
||||
|
|
@ -57,7 +66,9 @@ 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);
|
||||
};
|
||||
|
|
@ -114,4 +125,15 @@ http.createServer = (...args) => {
|
|||
return server;
|
||||
};
|
||||
|
||||
if (require.main === module) require("./server.js");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
docs/images/saml-admin-dashboard.png
Normal file
BIN
docs/images/saml-admin-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/images/saml-login-screen.png
Normal file
BIN
docs/images/saml-login-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1445
i18n/README.es.md
Normal file
1445
i18n/README.es.md
Normal file
File diff suppressed because it is too large
Load diff
1445
i18n/README.fr.md
Normal file
1445
i18n/README.fr.md
Normal file
File diff suppressed because it is too large
Load diff
1526
i18n/README.pt-BR.md
Normal file
1526
i18n/README.pt-BR.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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, normalizeModelId } from "../providers/models/schema.js";
|
||||
import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js";
|
||||
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
|
||||
export { PROVIDER_MODELS };
|
||||
|
||||
|
|
@ -54,6 +54,14 @@ 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;
|
||||
|
|
|
|||
|
|
@ -245,6 +245,18 @@ 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;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ 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";
|
||||
|
|
@ -41,7 +40,6 @@ 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
|
||||
|
|
@ -86,7 +84,6 @@ 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";
|
||||
|
|
|
|||
|
|
@ -144,6 +144,12 @@ 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";
|
||||
|
|
@ -711,14 +717,25 @@ export class KiroExecutor extends BaseExecutor {
|
|||
};
|
||||
const emitTools = (controller) => {
|
||||
for (const tool of state.tools.values()) {
|
||||
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");
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
} 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, {
|
||||
|
|
@ -729,14 +746,26 @@ export class KiroExecutor extends BaseExecutor {
|
|||
function: { name: tool.name, arguments: "" }
|
||||
}]
|
||||
});
|
||||
const serializedInput = JSON.stringify(input);
|
||||
emitDelta(controller, {
|
||||
tool_calls: [{ index, function: { arguments: JSON.stringify(input) } }]
|
||||
tool_calls: [{ index, function: { arguments: serializedInput } }]
|
||||
});
|
||||
// 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;
|
||||
if (state.stopReason === "tool_use" && !state.hasToolCalls) {
|
||||
// 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) {
|
||||
throw new Error("Kiro tool_use stop reason did not include a complete tool call");
|
||||
}
|
||||
};
|
||||
|
|
@ -796,7 +825,6 @@ 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) {
|
||||
|
|
@ -924,9 +952,10 @@ 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;
|
||||
state.tools.clear();
|
||||
state.bufferedToolBytes = 0;
|
||||
console.error(`[Kiro] tool fragment rejected, keeping ${state.tools.size} buffered tool(s): ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
fail(
|
||||
|
|
@ -958,7 +987,16 @@ export class KiroExecutor extends BaseExecutor {
|
|||
}
|
||||
state.transportState = "clean_eof";
|
||||
const declaredDisposition = stopDisposition(state.stopReason, state.sawToolUse);
|
||||
if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) {
|
||||
// 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)) {
|
||||
const code = declaredDisposition === "retryable_protocol_failure"
|
||||
? "kiro_retryable_protocol_failure"
|
||||
: declaredDisposition === "terminal_refusal"
|
||||
|
|
@ -975,16 +1013,6 @@ 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) {
|
||||
|
|
@ -997,6 +1025,22 @@ 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) {
|
||||
|
|
@ -1011,7 +1055,13 @@ export class KiroExecutor extends BaseExecutor {
|
|||
}
|
||||
|
||||
const disposition = stopDisposition(state.stopReason, state.hasToolCalls);
|
||||
if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) {
|
||||
// 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)) {
|
||||
const code = disposition === "retryable_protocol_failure"
|
||||
? "kiro_retryable_protocol_failure"
|
||||
: disposition === "terminal_refusal"
|
||||
|
|
@ -1041,18 +1091,24 @@ export class KiroExecutor extends BaseExecutor {
|
|||
total_tokens: prompt + completion
|
||||
};
|
||||
}
|
||||
const finishReason = state.hasToolCalls
|
||||
? "tool_calls"
|
||||
: disposition === "length"
|
||||
? "length"
|
||||
: "stop";
|
||||
const finishReason = truncatedAfterOutput
|
||||
? "length"
|
||||
: 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,
|
||||
stop_disposition: disposition
|
||||
// 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
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,43 @@
|
|||
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";
|
||||
|
||||
// Models that use /zen/v1/messages (claude format)
|
||||
const OPENCODE_UA = "opencode";
|
||||
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) {
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
this._currentSessionId = resolveOpencodeSession(body, credentials);
|
||||
return injectReasoningContent({ provider: this.provider, model, body });
|
||||
}
|
||||
|
||||
|
|
@ -21,12 +48,23 @@ export class OpenCodeExecutor extends BaseExecutor {
|
|||
: `${base}/zen/v1/chat/completions`;
|
||||
}
|
||||
|
||||
buildHeaders() {
|
||||
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");
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer public",
|
||||
"x-opencode-client": "desktop",
|
||||
"Accept": "text/event-stream"
|
||||
"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" : "*/*",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -215,6 +215,52 @@ 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.
|
||||
|
|
@ -229,16 +275,34 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
|
|||
* [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.
|
||||
*/
|
||||
function wrapQoderSSE(response, model) {
|
||||
async function wrapQoderSSE(response, model) {
|
||||
if (!response.ok || !response.body) return response;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let buffer = "";
|
||||
let doneEmitted = false;
|
||||
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 doneEmitted = false;
|
||||
|
||||
// Process one already-extracted SSE line (no trailing newline).
|
||||
const processLine = (line, controller) => {
|
||||
const trimmed = line.replace(/\r$/, "").trim();
|
||||
|
|
@ -287,7 +351,28 @@ function wrapQoderSSE(response, model) {
|
|||
// enqueueing would never be re-invoked, hanging consumers like .text().
|
||||
async start(controller) {
|
||||
try {
|
||||
while (!doneEmitted) {
|
||||
// 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) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
buffer += decoder.decode();
|
||||
|
|
@ -472,7 +557,7 @@ export class QoderExecutor extends BaseExecutor {
|
|||
return { response, url, headers, transformedBody: payload };
|
||||
}
|
||||
|
||||
const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`);
|
||||
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`);
|
||||
return { response: wrapped, url, headers, transformedBody: payload };
|
||||
}
|
||||
|
||||
|
|
@ -496,4 +581,5 @@ export const __test__ = {
|
|||
normalizeMessages,
|
||||
wrapQoderSSE,
|
||||
buildQoderRequestBody,
|
||||
isBillingBlock,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 } from "../translator/formats/claude.js";
|
||||
import { normalizeClaudePassthrough, anchorClaudeCache } 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, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||
import { getModelTargetFormat, getModelSupportedFormats, 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,10 +78,20 @@ 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
|
||||
// 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);
|
||||
const runtimeTransport = resolveTransport(provider, sourceFormat);
|
||||
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider, credentials);
|
||||
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
|
||||
// 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 stripList = getModelStrip(alias, model);
|
||||
const upstreamModel = getModelUpstreamId(alias, model);
|
||||
|
||||
|
|
@ -275,6 +285,10 @@ 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(() => { });
|
||||
|
|
|
|||
|
|
@ -44,13 +44,14 @@ export function extractUsageFromResponse(responseBody) {
|
|||
};
|
||||
}
|
||||
|
||||
// Gemini format
|
||||
if (responseBody.usageMetadata) {
|
||||
// Gemini format. Antigravity / gemini-cli wrap the payload in { response: {...} }.
|
||||
const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata;
|
||||
if (usageMetadata) {
|
||||
return {
|
||||
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
|
||||
cached_tokens: responseBody.usageMetadata.cachedContentTokenCount || 0,
|
||||
reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount || 0
|
||||
prompt_tokens: usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: usageMetadata.candidatesTokenCount || 0,
|
||||
cached_tokens: usageMetadata.cachedContentTokenCount || 0,
|
||||
reasoning_tokens: usageMetadata.thoughtsTokenCount || 0
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@
|
|||
* @property {Record<string,unknown>} [providerSpecificData]
|
||||
*/
|
||||
|
||||
import { assertPublicUrl } from "../../../src/shared/utils/ssrfGuard.js";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -63,12 +65,31 @@ 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(/\/+$/, "");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,25 @@ 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, {
|
||||
|
|
@ -166,4 +185,5 @@ export const FORMAT_HANDLERS = {
|
|||
tortoise,
|
||||
openai: openaiCompat,
|
||||
"minimax-tts": minimaxTts,
|
||||
"fish-audio": fishAudio,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ 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 } },
|
||||
|
|
|
|||
|
|
@ -38,3 +38,11 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ 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 },
|
||||
|
|
|
|||
35
open-sse/providers/registry/alitp-intl.js
Normal file
35
open-sse/providers/registry/alitp-intl.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// 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" },
|
||||
],
|
||||
};
|
||||
|
|
@ -45,6 +45,9 @@ 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)" },
|
||||
|
|
@ -72,7 +75,7 @@ export default {
|
|||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
],
|
||||
apiEndpoint: "https://daily-cloudcode-pa.googleapis.com",
|
||||
apiEndpoint: "https://cloudcode-pa.googleapis.com",
|
||||
apiVersion: "v1internal",
|
||||
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
|
||||
|
|
|
|||
31
open-sse/providers/registry/fish-audio.js
Normal file
31
open-sse/providers/registry/fish-audio.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// 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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -36,6 +36,7 @@ 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" },
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ 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" },
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ 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" },
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@ 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,
|
||||
|
|
@ -239,4 +241,6 @@ export default [
|
|||
p117,
|
||||
p118,
|
||||
p119,
|
||||
p120,
|
||||
p121,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default {
|
|||
},
|
||||
},
|
||||
category: "freeTier",
|
||||
authModes: ["oauth"],
|
||||
authModes: ["oauth", "apikey"],
|
||||
hasOAuth: true,
|
||||
transport: {
|
||||
baseUrl: "https://llm.kimchi.dev/openai/v1/chat/completions",
|
||||
|
|
|
|||
|
|
@ -22,20 +22,28 @@ 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" },
|
||||
{ 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" },
|
||||
{ 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"] },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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://daily-cloudcode-pa.googleapis.com";
|
||||
export const ANTIGRAVITY_IDE_BASE_URL = "https://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)
|
||||
|
|
|
|||
|
|
@ -185,13 +185,9 @@ 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"
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,8 +138,42 @@ 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)) scanContent(m.content); // openai / claude
|
||||
for (const m of trailingUserItems(body.messages)) scanMessage(m); // openai / claude / hermes / ollama
|
||||
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);
|
||||
|
|
@ -530,7 +564,10 @@ 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, ...rest } = body;
|
||||
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 panelBody = { ...rest, stream: false };
|
||||
|
||||
// Flatten tool turns to prose so panel models keep context without emitting tool_calls.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }),
|
||||
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) {
|
||||
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
|
||||
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
|
||||
const providerDataWithProjectId = {
|
||||
...(providerSpecificData || {}),
|
||||
|
|
@ -65,5 +65,13 @@ export async function getUsageForProvider(connection, proxyOptions = null) {
|
|||
|
||||
const handler = USAGE_HANDLERS[provider];
|
||||
if (!handler) return { message: `Usage API not implemented for ${provider}` };
|
||||
return await handler({ provider, accessToken, apiKey, providerSpecificData, providerDataWithProjectId, proxyOptions });
|
||||
return await handler({
|
||||
provider,
|
||||
accessToken,
|
||||
apiKey,
|
||||
providerSpecificData,
|
||||
providerDataWithProjectId,
|
||||
proxyOptions,
|
||||
force: options.force === true,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,43 @@ const CLAUDE_CONFIG = {
|
|||
const OAUTH_429_COOLDOWN_MS = 180000;
|
||||
const oauthCooldown = new Map();
|
||||
|
||||
export async function getClaudeUsage(accessToken, proxyOptions = null) {
|
||||
// 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) {
|
||||
try {
|
||||
// Skip OAuth usage call while this token is cooling down from a recent 429
|
||||
const cooldownUntil = oauthCooldown.get(accessToken);
|
||||
|
|
|
|||
|
|
@ -161,6 +161,9 @@ 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',
|
||||
|
|
|
|||
|
|
@ -62,6 +62,19 @@ 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);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ 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;
|
||||
|
|
@ -124,32 +127,38 @@ export function normalizeClaudePassthrough(body, model = "") {
|
|||
if (Object.keys(body.output_config).length === 0) delete body.output_config;
|
||||
}
|
||||
|
||||
// 2. Hoist mid-conversation system messages into the top-level system field
|
||||
// 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.
|
||||
if (Array.isArray(body.messages)) {
|
||||
const systemBlocks = [];
|
||||
const messages = [];
|
||||
for (const msg of body.messages) {
|
||||
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 });
|
||||
if (msg.role !== ROLE.SYSTEM) {
|
||||
messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
messages.push(msg);
|
||||
}
|
||||
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;
|
||||
|
||||
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;
|
||||
// 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] });
|
||||
}
|
||||
body.messages = messages;
|
||||
}
|
||||
|
||||
// 3. Drop thinking blocks whose signature is not Claude's (combo mixes models,
|
||||
|
|
@ -182,6 +191,70 @@ 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
|
||||
|
|
|
|||
|
|
@ -311,6 +311,26 @@ 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;
|
||||
|
|
|
|||
|
|
@ -287,6 +287,18 @@ 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 || "",
|
||||
|
|
|
|||
|
|
@ -421,6 +421,7 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,40 +96,6 @@ 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];
|
||||
|
|
|
|||
|
|
@ -379,6 +379,18 @@ 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 = {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,15 @@ 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.
|
||||
|
|
@ -254,6 +263,13 @@ 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 }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,8 +99,8 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
|
|||
}
|
||||
}
|
||||
|
||||
// Handle tool_calls
|
||||
if (delta.tool_calls) {
|
||||
// Handle tool_calls (empty array is truthy; require a real call)
|
||||
if (delta.tool_calls && delta.tool_calls.length) {
|
||||
closeMessage(state, emit, idx);
|
||||
for (const tc of delta.tool_calls) {
|
||||
emitToolCall(state, emit, tc);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.5.50",
|
||||
"version": "0.5.55",
|
||||
"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": "next start --port 20127",
|
||||
"start": "node custom-server.js --port 20127",
|
||||
"dev:bun": "bun --bun next dev --webpack --port 20127",
|
||||
"build:bun": "bun --bun next build --webpack",
|
||||
"start:bun": "bun ./.next/standalone/server.js",
|
||||
"start:bun": "bun ./.next/standalone/custom-server.js",
|
||||
"cli:pack": "npm --prefix cli run pack:cli",
|
||||
"cli:publish": "npm --prefix cli run publish:cli"
|
||||
},
|
||||
|
|
@ -23,8 +23,10 @@
|
|||
"@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",
|
||||
|
|
@ -37,6 +39,7 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ 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")) {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n api_key: \${OPENAI_API_KEY}\n`;
|
||||
const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
|
||||
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ function getLocaleFromCookie() {
|
|||
|
||||
export default function ProfilePage() {
|
||||
const { theme, setTheme, isDark } = useTheme();
|
||||
const [locale, setLocale] = useState("en");
|
||||
const [locale, setLocale] = useState(() => getLocaleFromCookie());
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||
const [isShuttingDown, setIsShuttingDown] = useState(false);
|
||||
|
|
@ -46,8 +46,31 @@ 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,
|
||||
|
|
@ -58,10 +81,6 @@ 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())
|
||||
|
|
@ -75,7 +94,23 @@ export default function ProfilePage() {
|
|||
oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC",
|
||||
});
|
||||
setOidcClientSecret("");
|
||||
if (data?.authMode === "oidc" || data?.authMode === "both") setOidcExpanded(true);
|
||||
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);
|
||||
}
|
||||
setProxyForm({
|
||||
outboundProxyEnabled: data?.outboundProxyEnabled === true,
|
||||
outboundProxyUrl: data?.outboundProxyUrl || "",
|
||||
|
|
@ -89,12 +124,6 @@ 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;
|
||||
|
|
@ -331,6 +360,7 @@ export default function ProfilePage() {
|
|||
try {
|
||||
const payload = {
|
||||
authMode,
|
||||
ssoType: "oidc",
|
||||
oidcIssuerUrl: issuerUrl,
|
||||
oidcClientId: clientId,
|
||||
oidcScopes: scopes || "openid profile email",
|
||||
|
|
@ -445,6 +475,159 @@ 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", {
|
||||
|
|
@ -752,7 +935,7 @@ export default function ProfilePage() {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
{/* OIDC */}
|
||||
{/* Single Sign-On (SSO) */}
|
||||
<Card>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -763,9 +946,13 @@ 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">OIDC Dashboard Login</h3>
|
||||
<h3 className="text-base sm:text-lg font-semibold">Single Sign-On (SSO)</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
{settings.authMode === "oidc" ? "OIDC active" : settings.authMode === "both" ? "Password + OIDC active" : "Optional SSO via Authentik/Keycloak/Google"}
|
||||
{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"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-text-muted shrink-0">
|
||||
|
|
@ -773,145 +960,472 @@ 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">
|
||||
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>
|
||||
<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-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 (
|
||||
{/* 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">
|
||||
<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}
|
||||
onClick={() => setShowSamlGuide((prev) => !prev)}
|
||||
className="w-full p-3 flex items-center justify-between gap-2 text-left hover:bg-surface/50 transition-colors"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{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----- MIIC... -----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 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -529,8 +529,7 @@ export default function TokenSaverClient() {
|
|||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={headroomEnabled && headroomRunning}
|
||||
disabled={!headroomRunning}
|
||||
checked={headroomEnabled}
|
||||
onChange={() => handleHeadroomEnabled(!headroomEnabled)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ export default function ProviderLimits() {
|
|||
);
|
||||
|
||||
// Fetch quota for a specific connection
|
||||
const fetchQuota = useCallback(async (connectionId, provider) => {
|
||||
const fetchQuota = useCallback(async (connectionId, provider, { force = false } = {}) => {
|
||||
setLoading((prev) => ({ ...prev, [connectionId]: true }));
|
||||
setErrors((prev) => ({ ...prev, [connectionId]: null }));
|
||||
|
||||
|
|
@ -224,7 +224,8 @@ export default function ProviderLimits() {
|
|||
console.log(
|
||||
`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`,
|
||||
);
|
||||
const response = await fetch(`/api/usage/${connectionId}`);
|
||||
const url = `/api/usage/${connectionId}${force ? "?force=1" : ""}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
|
@ -295,7 +296,7 @@ export default function ProviderLimits() {
|
|||
// Refresh quota for a specific provider
|
||||
const refreshProvider = useCallback(
|
||||
async (connectionId, provider) => {
|
||||
await fetchQuota(connectionId, provider);
|
||||
await fetchQuota(connectionId, provider, { force: true });
|
||||
setLastUpdated(new Date());
|
||||
},
|
||||
[fetchQuota],
|
||||
|
|
|
|||
|
|
@ -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 = 180000;
|
||||
export const CLAUDE_REFRESH_INTERVAL_MS = 600000;
|
||||
export const DEPLETED_QUOTA_THRESHOLD = 5;
|
||||
export const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
|
||||
export const CONNECTIONS_PAGE_SIZE = 20;
|
||||
|
|
@ -36,6 +36,17 @@ 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,
|
||||
|
|
@ -58,7 +69,7 @@ export function sortVisibleConnections(
|
|||
});
|
||||
}
|
||||
|
||||
if (!expiringFirst) return connections;
|
||||
if (!expiringFirst) return groupByProviderStable(connections);
|
||||
|
||||
const getEarliestResetTime = (connection) => {
|
||||
const resetTimes = (quotaData[connection.id]?.quotas || [])
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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";
|
||||
|
||||
|
|
@ -39,8 +40,14 @@ export async function POST(request) {
|
|||
// Default password is '123456' if not set
|
||||
const storedHash = settings.password;
|
||||
|
||||
if (settings.authMode === "oidc" && isOidcConfigured(settings)) {
|
||||
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
|
|
@ -54,15 +61,36 @@ 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);
|
||||
|
||||
return NextResponse.json({ success: true, mustChangePassword }, { headers: NO_STORE_HEADERS });
|
||||
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 });
|
||||
}
|
||||
|
||||
const { remainingBeforeLock } = recordFail(ip);
|
||||
|
|
|
|||
69
src/app/api/auth/saml/acs/route.js
Normal file
69
src/app/api/auth/saml/acs/route.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/app/api/auth/saml/metadata/route.js
Normal file
25
src/app/api/auth/saml/metadata/route.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
32
src/app/api/auth/saml/start/route.js
Normal file
32
src/app/api/auth/saml/start/route.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
72
src/app/api/auth/saml/test/route.js
Normal file
72
src/app/api/auth/saml/test/route.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ 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() {
|
||||
|
|
@ -11,16 +12,29 @@ 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 displayName = oidcName || oidcEmail || (session?.oidc ? "OIDC user" : "Password user");
|
||||
const loginMethod = session?.oidc ? "OIDC" : "Password";
|
||||
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";
|
||||
|
||||
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,
|
||||
|
|
@ -28,13 +42,19 @@ 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",
|
||||
|
|
@ -42,6 +62,9 @@ export async function GET() {
|
|||
oidcName: null,
|
||||
oidcEmail: null,
|
||||
oidcLogin: false,
|
||||
samlName: null,
|
||||
samlEmail: null,
|
||||
samlLogin: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
`model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`;
|
||||
|
||||
// Parse current model block back to fields (best-effort, simple key:value)
|
||||
const parseModelBlock = (yaml) => {
|
||||
|
|
@ -35,6 +35,7 @@ const parseModelBlock = (yaml) => {
|
|||
default: get("default"),
|
||||
provider: get("provider"),
|
||||
base_url: get("base_url"),
|
||||
api_key: get("api_key"),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -135,9 +135,11 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
|
|||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
// 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,
|
||||
// 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,
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}),
|
||||
|
|
@ -180,6 +182,21 @@ 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,
|
||||
|
|
|
|||
|
|
@ -788,6 +788,27 @@ 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" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ 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
|
||||
|
|
@ -168,7 +169,7 @@ export async function GET(request, { params }) {
|
|||
}
|
||||
|
||||
// Fetch usage from provider API
|
||||
let usage = await getUsageForProvider(connection, proxyOptions);
|
||||
let usage = await getUsageForProvider(connection, proxyOptions, { force });
|
||||
|
||||
// If provider returned an auth-expired message instead of throwing,
|
||||
// force-refresh token and retry once (OAuth only)
|
||||
|
|
@ -176,7 +177,7 @@ export async function GET(request, { params }) {
|
|||
try {
|
||||
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
|
||||
connection = retryResult.connection;
|
||||
usage = await getUsageForProvider(connection, proxyOptions);
|
||||
usage = await getUsageForProvider(connection, proxyOptions, { force });
|
||||
} catch (retryError) {
|
||||
console.warn(`[Usage] ${connection.provider}: force refresh failed: ${retryError.message}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,23 @@ export async function GET(request) {
|
|||
if (endDate) filter.endDate = endDate;
|
||||
|
||||
const result = await getRequestDetails(filter);
|
||||
|
||||
return NextResponse.json(result);
|
||||
|
||||
// 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 });
|
||||
} catch (error) {
|
||||
console.error("[API] Failed to get request details:", error);
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -485,6 +485,27 @@ 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@ 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("");
|
||||
|
||||
|
|
@ -43,8 +46,11 @@ 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);
|
||||
|
|
@ -118,8 +124,18 @@ export default function LoginPage() {
|
|||
window.location.href = "/api/auth/oidc/start";
|
||||
};
|
||||
|
||||
const oidcAvailable = oidcConfigured && ["oidc", "both"].includes(authMode);
|
||||
const passwordAvailable = authMode !== "oidc" || !oidcConfigured;
|
||||
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;
|
||||
|
||||
// Show loading state while checking password
|
||||
if (hasPassword === null) {
|
||||
|
|
@ -141,7 +157,9 @@ 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">
|
||||
{authMode === "oidc" && oidcConfigured
|
||||
{samlAvailable
|
||||
? "Sign in with SAML 2.0 Single Sign-On"
|
||||
: oidcAvailable
|
||||
? "Sign in with your OIDC provider to access the dashboard"
|
||||
: "Enter your password to access the dashboard"}
|
||||
</p>
|
||||
|
|
@ -171,25 +189,31 @@ 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>
|
||||
)}
|
||||
|
||||
{oidcAvailable && passwordAvailable && <div className="h-px bg-border/60" />}
|
||||
{ssoAvailable && passwordAvailable && <div className="h-px bg-border/60" />}
|
||||
|
||||
{passwordAvailable ? (
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||
{((authMode === "oidc" && !oidcConfigured) || (authMode === "both" && !oidcConfigured)) && (
|
||||
{isSsoEnabled && !ssoAvailable && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 text-center">
|
||||
OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.
|
||||
{activeSsoType === "saml" ? "SAML SSO" : "OIDC"} login is enabled, but configuration is incomplete. Password login is still available for recovery.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{authMode === "both" && oidcConfigured && (
|
||||
{authMode === "both" && ssoAvailable && (
|
||||
<p className="text-xs text-text-muted text-center">
|
||||
Password and OIDC login are both enabled.
|
||||
Password and {activeSsoType === "saml" ? "SAML SSO" : "OIDC"} login are both enabled.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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";
|
||||
|
|
@ -27,6 +28,7 @@ const PUBLIC_API_PATHS = [
|
|||
"/api/auth/logout",
|
||||
"/api/auth/status",
|
||||
"/api/auth/oidc",
|
||||
"/api/auth/saml",
|
||||
"/api/version",
|
||||
"/api/settings/require-login",
|
||||
];
|
||||
|
|
@ -86,24 +88,40 @@ 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;
|
||||
const name = h.split(":")[0].replace(/^\[|\]$/g, "").toLowerCase();
|
||||
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);
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
if (!isLoopbackPeer(request)) return false;
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// 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
|
||||
|
|
@ -46,9 +47,12 @@ export function recordSuccess(ip) {
|
|||
}
|
||||
|
||||
export function getClientIp(request) {
|
||||
// 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;
|
||||
// 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;
|
||||
}
|
||||
// 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");
|
||||
|
|
|
|||
268
src/lib/auth/saml.js
Normal file
268
src/lib/auth/saml.js
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
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);
|
||||
}
|
||||
7
src/lib/auth/trustedPeer.js
Normal file
7
src/lib/auth/trustedPeer.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -189,14 +189,13 @@ export async function createProviderConnection(data) {
|
|||
}
|
||||
|
||||
// Critical: OAuth refresh token race — atomic merge inside transaction
|
||||
export async function updateProviderConnection(id, update) {
|
||||
export async function updateProviderConnection(id, data) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ 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);
|
||||
|
|
|
|||
|
|
@ -27,11 +27,18 @@ 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,
|
||||
|
|
@ -63,7 +70,7 @@ async function readRaw() {
|
|||
}
|
||||
|
||||
// Merge raw settings with defaults; backward-compat for missing keys
|
||||
function mergeWithDefaults(raw) {
|
||||
export function mergeWithDefaults(raw) {
|
||||
const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
|
||||
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
|
||||
if (merged[key] === undefined) {
|
||||
|
|
|
|||
|
|
@ -26,10 +26,28 @@ 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: {
|
||||
|
|
@ -37,6 +55,9 @@ 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",
|
||||
|
|
@ -113,13 +134,15 @@ function extractModel(url, body) {
|
|||
return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null;
|
||||
}
|
||||
const model = urlModel || parsed.model || null;
|
||||
if (String(model).replace(/^models\//, "") === "gemini-3.6-flash-tiered") {
|
||||
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";
|
||||
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-3.6-flash-${level}`;
|
||||
return `gemini-${ver}-flash-${level}`;
|
||||
}
|
||||
return model;
|
||||
} catch {
|
||||
|
|
@ -127,4 +150,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, extractModel };
|
||||
module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, isChatRequest, extractModel };
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ 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
|
||||
thinkBuf: "", // Buffer for partial thinking content
|
||||
initialSent: false, // Whether initial-response frame was emitted
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -130,9 +131,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"
|
||||
* :content-type = "application/json" (initial-response uses x-amz-json-1.0)
|
||||
*/
|
||||
function buildEventStreamFrame(eventType, payload) {
|
||||
function buildEventStreamFrame(eventType, payload, contentType = "application/json") {
|
||||
const payloadBuf = Buffer.from(
|
||||
typeof payload === "string" ? payload : JSON.stringify(payload),
|
||||
"utf8"
|
||||
|
|
@ -142,7 +143,7 @@ function buildEventStreamFrame(eventType, payload) {
|
|||
const headersBuf = Buffer.concat([
|
||||
encodeHeader(":message-type", "event"),
|
||||
encodeHeader(":event-type", eventType),
|
||||
encodeHeader(":content-type", "application/json"),
|
||||
encodeHeader(":content-type", contentType),
|
||||
]);
|
||||
const headersLen = headersBuf.length;
|
||||
|
||||
|
|
@ -159,6 +160,24 @@ function buildEventStreamFrame(eventType, payload) {
|
|||
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 ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -321,12 +340,12 @@ function convertOpenAIToKiro(chunk, state) {
|
|||
state.inThink = false;
|
||||
const thinking = state.thinkBuf;
|
||||
state.thinkBuf = "";
|
||||
return buildEventStreamFrame("reasoningContentEvent", {
|
||||
return withInitialFrame(state, buildEventStreamFrame("reasoningContentEvent", {
|
||||
content: thinking,
|
||||
modelId: state.modelId || "kiro-unknown"
|
||||
});
|
||||
}));
|
||||
}
|
||||
return buildEventStreamFrame("messageStopEvent", {});
|
||||
return withInitialFrame(state, buildEventStreamFrame("messageStopEvent", {}));
|
||||
}
|
||||
|
||||
const frames = [];
|
||||
|
|
@ -408,8 +427,12 @@ function convertOpenAIToKiro(chunk, state) {
|
|||
}
|
||||
}
|
||||
|
||||
if (frames.length === 0) return null;
|
||||
return frames.length === 1 ? frames[0] : frames;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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, extractModel } = require("./config");
|
||||
const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, isChatRequest, extractModel } = require("./config");
|
||||
const { DATA_DIR, MITM_DIR } = require("./paths");
|
||||
const { generateCert, getCertForDomain } = require("./cert/generate");
|
||||
const { getMitmAlias } = require("./dbReader");
|
||||
|
|
@ -311,9 +311,8 @@ const server = https.createServer(sslOptions, async (req, res) => {
|
|||
const tool = getToolForHost(req.headers.host);
|
||||
if (!tool) 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);
|
||||
// Kiro IDE posts chat to `/` with x-amz-target (not path /generateAssistantResponse)
|
||||
if (!isChatRequest(tool, req)) return passthrough(req, res, bodyBuffer);
|
||||
|
||||
// Cursor uses binary proto — model extraction not possible at this layer.
|
||||
// Delegate directly to handler which decodes proto internally.
|
||||
|
|
|
|||
|
|
@ -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?.oidcName || data?.oidcEmail || "");
|
||||
setDisplayName(data?.displayName || data?.samlName || data?.samlEmail || data?.oidcName || data?.oidcEmail || "");
|
||||
setLoginMethod(data?.loginMethod || "");
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -303,12 +303,15 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
|||
|
||||
{/* Right actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{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">
|
||||
{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}
|
||||
>
|
||||
<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">
|
||||
OIDC
|
||||
{loginMethod}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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.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.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"],
|
||||
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,8 +57,13 @@ export const MITM_TOOLS = {
|
|||
color: "#FF6B00",
|
||||
description: "Kiro IDE with MITM",
|
||||
configType: "mitm",
|
||||
mitmDomain: "q.us-east-1.amazonaws.com",
|
||||
mitmDomain: "runtime.us-east-1.kiro.dev",
|
||||
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" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {
|
||||
extractApiKey, isValidApiKey,
|
||||
getProviderCredentials, markAccountUnavailable, clearAccountError,
|
||||
getProviderCredentials, markAccountUnavailable,
|
||||
} from "../services/auth.js";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { getModelInfo } from "../services/model.js";
|
||||
|
|
@ -74,10 +74,7 @@ export async function handleStt(request) {
|
|||
|
||||
const result = await handleSttCore({ provider, model, formData, credentials, sttConfig: AI_PROVIDERS[provider]?.sttConfig });
|
||||
|
||||
if (result.success) {
|
||||
await clearAccountError(credentials.connectionId, credentials, model);
|
||||
return result.response;
|
||||
}
|
||||
if (result.success) return result.response;
|
||||
|
||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
||||
if (shouldFallback) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {
|
||||
extractApiKey, isValidApiKey,
|
||||
getProviderCredentials, markAccountUnavailable, clearAccountError,
|
||||
getProviderCredentials, markAccountUnavailable,
|
||||
} from "../services/auth.js";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { getModelInfo, getComboModels } from "../services/model.js";
|
||||
|
|
@ -101,10 +101,7 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language, st
|
|||
|
||||
const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat, language, style });
|
||||
|
||||
if (result.success) {
|
||||
await clearAccountError(credentials.connectionId, credentials, model);
|
||||
return result.response;
|
||||
}
|
||||
if (result.success) return result.response;
|
||||
|
||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
||||
if (shouldFallback) {
|
||||
|
|
|
|||
|
|
@ -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, resetAccountState } from "open-sse/services/accountFallback.js";
|
||||
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } 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,8 +274,43 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
|
|||
*/
|
||||
export async function clearAccountError(connectionId, currentConnection, model = null) {
|
||||
if (!connectionId || connectionId === "noauth") return;
|
||||
// Reset inside transaction so concurrent 429 writes cannot leave stale locks.
|
||||
await updateProviderConnection(connectionId, resetAccountState);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"alicode": "alicode",
|
||||
"alicode-intl": "alicode-intl",
|
||||
"alims-intl": "alims-intl",
|
||||
"alitp-intl": "alitp-intl",
|
||||
"anthropic": "anthropic",
|
||||
"antigravity": "ag",
|
||||
"api-airforce": "af",
|
||||
|
|
@ -206,6 +207,7 @@
|
|||
"alicode",
|
||||
"alicode-intl",
|
||||
"alims-intl",
|
||||
"alitp-intl",
|
||||
"anthropic",
|
||||
"assemblyai",
|
||||
"black-forest-labs",
|
||||
|
|
|
|||
|
|
@ -708,7 +708,37 @@
|
|||
"opencode-go": {
|
||||
"baseUrl": "https://opencode.ai/zen/go/v1/chat/completions",
|
||||
"headers": {},
|
||||
"format": "openai"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"opencode": {
|
||||
"baseUrl": "https://opencode.ai",
|
||||
|
|
@ -968,7 +998,15 @@
|
|||
"tokenrouter": {
|
||||
"baseUrl": "https://api.tokenrouter.com/v1/chat/completions",
|
||||
"validateUrl": "https://api.tokenrouter.com/v1/models",
|
||||
"thinkingFormat": "openai",
|
||||
"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
|
||||
},
|
||||
"format": "openai"
|
||||
}
|
||||
}
|
||||
40
tests/auth/saml.test.js
Normal file
40
tests/auth/saml.test.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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");
|
||||
});
|
||||
|
|
@ -38,6 +38,25 @@ 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": {
|
||||
|
|
@ -66,6 +85,31 @@ 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": {
|
||||
|
|
@ -85,6 +129,44 @@ 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": {
|
||||
|
|
@ -104,6 +186,25 @@ 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": {
|
||||
|
|
@ -268,6 +369,52 @@ 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": {
|
||||
|
|
@ -324,6 +471,43 @@ 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": {
|
||||
|
|
@ -381,6 +565,25 @@ 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": {
|
||||
|
|
@ -482,6 +685,34 @@ 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": {
|
||||
|
|
@ -520,6 +751,25 @@ 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": {
|
||||
|
|
@ -539,6 +789,28 @@ 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": {
|
||||
|
|
@ -601,6 +873,25 @@ 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": {
|
||||
|
|
@ -689,6 +980,25 @@ 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": {
|
||||
|
|
@ -828,6 +1138,63 @@ 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": {
|
||||
|
|
@ -847,6 +1214,25 @@ 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": {
|
||||
|
|
@ -866,6 +1252,44 @@ 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": {
|
||||
|
|
@ -942,6 +1366,28 @@ 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",
|
||||
|
|
@ -956,6 +1402,13 @@ 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",
|
||||
|
|
@ -963,6 +1416,13 @@ 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",
|
||||
|
|
@ -970,6 +1430,20 @@ 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",
|
||||
|
|
@ -977,6 +1451,13 @@ 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",
|
||||
|
|
@ -1012,6 +1493,13 @@ 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",
|
||||
|
|
@ -1026,6 +1514,13 @@ 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",
|
||||
|
|
@ -1047,6 +1542,13 @@ 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",
|
||||
|
|
@ -1082,6 +1584,13 @@ 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",
|
||||
|
|
@ -1096,6 +1605,13 @@ 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",
|
||||
|
|
@ -1103,6 +1619,13 @@ 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",
|
||||
|
|
@ -1117,6 +1640,13 @@ 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",
|
||||
|
|
@ -1145,6 +1675,13 @@ 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",
|
||||
|
|
@ -1194,6 +1731,27 @@ 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",
|
||||
|
|
@ -1201,6 +1759,13 @@ 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",
|
||||
|
|
@ -1208,6 +1773,20 @@ 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",
|
||||
|
|
@ -1235,3 +1814,10 @@ 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",
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
45
tests/unit/alibaba-token-plan-provider.test.js
Normal file
45
tests/unit/alibaba-token-plan-provider.test.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
54
tests/unit/antigravity-nonstream-usage-3260.test.js
Normal file
54
tests/unit/antigravity-nonstream-usage-3260.test.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
61
tests/unit/antigravity-quota-gemini-3.7.test.js
Normal file
61
tests/unit/antigravity-quota-gemini-3.7.test.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
86
tests/unit/custom-server-peer-headers.test.js
Normal file
86
tests/unit/custom-server-peer-headers.test.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -35,6 +35,8 @@ 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 {
|
||||
|
|
@ -45,9 +47,16 @@ 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");
|
||||
|
|
@ -55,14 +64,14 @@ describe("dashboard guard public LLM API access", () => {
|
|||
});
|
||||
|
||||
it("allows loopback public LLM API without API key", async () => {
|
||||
const response = await proxy(request("/v1/chat/completions", { host: "localhost:20128" }));
|
||||
const response = await proxy(localRequest("/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(request("/v1/chat/completions", {
|
||||
const response = await proxy(localRequest("/v1/chat/completions", {
|
||||
host: "localhost",
|
||||
"x-9r-real-ip": "10.204.111.34",
|
||||
}));
|
||||
|
|
@ -72,7 +81,7 @@ describe("dashboard guard public LLM API access", () => {
|
|||
});
|
||||
|
||||
it("allows loopback peer IP regardless of Host", async () => {
|
||||
const response = await proxy(request("/v1/chat/completions", {
|
||||
const response = await proxy(localRequest("/v1/chat/completions", {
|
||||
host: "localhost:20128",
|
||||
"x-9r-real-ip": "127.0.0.1",
|
||||
}));
|
||||
|
|
@ -89,7 +98,7 @@ describe("dashboard guard public LLM API access", () => {
|
|||
});
|
||||
|
||||
it("allows loopback rewritten public LLM API without API key", async () => {
|
||||
const response = await proxy(request("/api/v1/chat/completions", { host: "localhost:20128" }));
|
||||
const response = await proxy(localRequest("/api/v1/chat/completions", { host: "localhost:20128" }));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
||||
|
|
@ -191,6 +200,7 @@ 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");
|
||||
|
|
@ -207,7 +217,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(request("/api/mcp/filesystem/sse", {
|
||||
const response = await proxy(localRequest("/api/mcp/filesystem/sse", {
|
||||
host: "localhost:20128",
|
||||
origin: "http://localhost:20128",
|
||||
}));
|
||||
|
|
@ -219,7 +229,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(request("/api/cli-tools/antigravity-mitm", {
|
||||
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
|
||||
host: "localhost:20128",
|
||||
origin: "http://localhost:20128",
|
||||
}));
|
||||
|
|
@ -240,7 +250,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(request("/api/cli-tools/antigravity-mitm", {
|
||||
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
|
||||
host: "localhost:20128",
|
||||
origin: "http://evil.example.com",
|
||||
}));
|
||||
|
|
|
|||
109
tests/unit/fish-audio-tts.test.js
Normal file
109
tests/unit/fish-audio-tts.test.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
83
tests/unit/fusion-strip-stream-options-3024.test.js
Normal file
83
tests/unit/fusion-strip-stream-options-3024.test.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// 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);
|
||||
});
|
||||
});
|
||||
36
tests/unit/gemini-3.7-antigravity.test.js
Normal file
36
tests/unit/gemini-3.7-antigravity.test.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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"]);
|
||||
});
|
||||
});
|
||||
115
tests/unit/hermes-vision-detection.test.js
Normal file
115
tests/unit/hermes-vision-detection.test.js
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -14,6 +14,13 @@ 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
Loading…
Reference in a new issue