Compare commits
2 commits
master
...
preserve/o
| Author | SHA1 | Date | |
|---|---|---|---|
| e99e91aa09 | |||
| 0f6a449dbc |
112 changed files with 506 additions and 9244 deletions
86
CHANGELOG.md
86
CHANGELOG.md
|
|
@ -1,89 +1,3 @@
|
||||||
# v0.5.55 (2026-08-14)
|
|
||||||
|
|
||||||
## Features
|
|
||||||
- **Auth**: native SAML 2.0 SSO alongside OIDC — AuthnRequest generation, ACS
|
|
||||||
assertion handling, SP metadata export, admin config test, replay-protected
|
|
||||||
via a `saml_state` cookie matched against `InResponseTo`
|
|
||||||
- **Providers**: add Alibaba Token Plan (`token-plan.ap-southeast-1`) — the
|
|
||||||
fourth Alibaba key type, Singapore-only and OpenAI-compatible transport only
|
|
||||||
- **Providers**: add `glm-5.3` to GLM Coding and GLM (China)
|
|
||||||
- **Providers**: Kimchi accepts API keys as well as OAuth (dual auth), with a
|
|
||||||
working Test Connection for both modes
|
|
||||||
- **Antigravity**: add Gemini 3.7 Flash and its tiered high/medium/low variants
|
|
||||||
(also in the Gemini registry) with pricing and quota tracking
|
|
||||||
- **TTS**: add Fish Audio — model id travels in an HTTP `model` header, voice
|
|
||||||
is a `reference_id` (preset or cloned voice model)
|
|
||||||
- **OpenCode-Go**: route by request format via declared transports instead of
|
|
||||||
forcing every client into `/messages` — Codex/OpenAI clients no longer pay a
|
|
||||||
lossy Responses→OpenAI→Claude double translation. Per-model `supportedFormats`
|
|
||||||
guard; the bespoke executor is gone (its shared `_lastModel` cache could cross
|
|
||||||
auth headers between concurrent requests)
|
|
||||||
- **Usage**: dedup + cache Claude quota calls (120s TTL keyed by access token,
|
|
||||||
in-flight promise dedup, last-good read on soft failure) to stop multiple
|
|
||||||
tabs tripping 429; manual refresh (↻) sends `force=1` to bypass the cache
|
|
||||||
|
|
||||||
## Fixes
|
|
||||||
- **Docker**: ship `sql.js` in the image so the pure-JS DB fallback can start —
|
|
||||||
file tracing carried the package's JS without `dist/sql-wasm.wasm`, so a
|
|
||||||
container with no native driver aborted with ENOENT and never got a database
|
|
||||||
(#3248)
|
|
||||||
- **Usage**: read Gemini `usageMetadata` out of the antigravity `{ response }`
|
|
||||||
envelope — every non-streaming antigravity request logged `IN 0 | OUT 0`
|
|
||||||
(#3260)
|
|
||||||
- **Claude**: re-anchor passthrough cache breakpoints — the client's own
|
|
||||||
`cache_control` markers point at pre-normalization offsets, so the tail was
|
|
||||||
re-cached every request. Last system block and last tool pinned at 1h TTL,
|
|
||||||
last assistant turn at 5m, mid-conversation system messages folded into the
|
|
||||||
neighbouring user turn instead of hoisted into `body.system`
|
|
||||||
- **Combos**: detect images from Hermes and attachment payloads (`images[]`,
|
|
||||||
`experimental_attachments`, message-level `image_url`/`audio_url`, inline
|
|
||||||
`data:` URIs) so the Vision Adapter auto-switch fires for Hermes/Ollama/
|
|
||||||
Vercel AI SDK shapes
|
|
||||||
- **Kiro**: intercept chat via `x-amz-target` — Kiro IDE 1.0.228+ moved
|
|
||||||
`GenerateAssistantResponse` to `POST /` + header, bypassing MITM. Also emit
|
|
||||||
the now-mandatory initial-response frame and map the `auto` model slot
|
|
||||||
- **Kiro**: report real output tokens and stop discarding usable turns
|
|
||||||
- **Qoder**: detect billing blocks at stream start and return a synthetic 403
|
|
||||||
so combo/account fallback triggers instead of leaking the error into chat
|
|
||||||
- **Antigravity**: strip competitive system prompts (Zed IDE's Claude-agent
|
|
||||||
prompt) that Antigravity flags with a 429 Quota Exhausted
|
|
||||||
- **OpenCode**: send the official client fingerprint on free-tier requests so
|
|
||||||
the Console stops classifying traffic as unidentified and rate-limiting it;
|
|
||||||
session id resolves conversation-stable to preserve prompt caching
|
|
||||||
- **Responses**: don't close the message on an empty `tool_calls` array — some
|
|
||||||
providers attach one to every chunk, and the truthy check ended the message
|
|
||||||
on the first content token (#3234)
|
|
||||||
- **Translator**: preserve `prompt_cache_key` when converting chat to responses
|
|
||||||
- **Models**: expose snake_case token limits on `/v1/models`
|
|
||||||
- **Combos**: strip `stream_options` from the Fusion panel fan-out to avoid a
|
|
||||||
DeepSeek 400 (#3024); raise the dashboard model-test probe budget to 1024 and
|
|
||||||
soft-pass reasoning-only responses (#3010)
|
|
||||||
- **Headroom**: the toggle reflects the `headroomEnabled` setting even when the
|
|
||||||
proxy is down — it previously showed OFF while the engine kept calling
|
|
||||||
`/v1/compress`; proxy status stays visible via the status chip
|
|
||||||
- **Hermes**: add the `api_key` parameter to the model block in YAML config
|
|
||||||
- **Providers**: add llm7 to provider test support
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
- **i18n**: add Spanish, French, and Brazilian Portuguese README translations
|
|
||||||
|
|
||||||
## Security
|
|
||||||
- **Real IP**: `x-9r-real-ip` and the Host fallback were trusted from
|
|
||||||
client-controlled headers whenever `custom-server.js` was not in the request
|
|
||||||
path (`npm run start`, `start:bun`), letting a remote caller pose as local to
|
|
||||||
skip API key auth and reach `LOCAL_ONLY_PATHS` (`/api/mcp/*`,
|
|
||||||
`/api/tunnel/enable`, `/api/auth/reset-password`). The server now stamps a
|
|
||||||
per-process `x-9r-peer-token` on every request it sanitizes and only trusts
|
|
||||||
`x-9r-real-ip` behind it — falling back to Host in development and failing
|
|
||||||
closed in production (GHSA-pjm4-8fpg-f9p6). Also fixes IPv6 loopback
|
|
||||||
detection (`::1`, `::ffff:127.0.0.1`) and routes `npm run start` /
|
|
||||||
`start:bun` through `custom-server.js`
|
|
||||||
- **Search**: `resolveBaseUrl()` rejects client-supplied non-public baseUrls
|
|
||||||
(SSRF guard on `/v1/search`)
|
|
||||||
- **Login**: fresh-install remote login with the default password returns 403
|
|
||||||
without issuing a JWT
|
|
||||||
- **Usage**: `/api/usage/request-details` redacts request/response payloads
|
|
||||||
|
|
||||||
# v0.5.50 (2026-08-05)
|
# v0.5.50 (2026-08-05)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,6 @@ COPY --from=builder /app/src/mitm ./src/mitm
|
||||||
COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge
|
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.
|
# Ensure `next` is available at runtime in case tracing did not include it.
|
||||||
COPY --from=builder /app/node_modules/next ./node_modules/next
|
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 && \
|
RUN mkdir -p /app/data && chown -R node:node /app && \
|
||||||
mkdir -p /app/data-home && chown node:node /app/data-home && \
|
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)
|
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
|
||||||
|
|
||||||
[🇧🇷 Português (Brasil)](./i18n/README.pt-BR.md) • [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md) • [🇪🇸 Español](./i18n/README.es.md) • [🇫🇷 Français](./i18n/README.fr.md)
|
[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "9router",
|
"name": "9router",
|
||||||
"version": "0.5.55",
|
"version": "0.5.50",
|
||||||
"description": "9Router CLI - Start and manage 9Router server",
|
"description": "9Router CLI - Start and manage 9Router server",
|
||||||
"bin": {
|
"bin": {
|
||||||
"9router": "./cli.js"
|
"9router": "./cli.js"
|
||||||
|
|
|
||||||
|
|
@ -216,9 +216,7 @@ function buildCliPackage() {
|
||||||
fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
|
fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
|
||||||
console.log("✅ Copied custom-server.js\n");
|
console.log("✅ Copied custom-server.js\n");
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ custom-server.js not found — without it no request can be proven local,");
|
console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n");
|
||||||
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.
|
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,6 @@ const PROVIDER_MODELS = {
|
||||||
{ id: "glm-4.7" },
|
{ id: "glm-4.7" },
|
||||||
],
|
],
|
||||||
ag: [
|
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-high" },
|
||||||
{ id: "gemini-3.6-flash-medium" },
|
{ id: "gemini-3.6-flash-medium" },
|
||||||
{ id: "gemini-3.6-flash-low" },
|
{ id: "gemini-3.6-flash-low" },
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,9 @@
|
||||||
const http = require("http");
|
const http = require("http");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
|
||||||
const crypto = require("crypto");
|
|
||||||
const { pathToFileURL } = require("url");
|
const { pathToFileURL } = require("url");
|
||||||
|
|
||||||
const origCreate = http.createServer.bind(http);
|
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;
|
let backgroundRefreshStarted = false;
|
||||||
|
|
||||||
function startBackgroundTokenRefreshFromCustomServer() {
|
function startBackgroundTokenRefreshFromCustomServer() {
|
||||||
|
|
@ -66,9 +57,7 @@ http.createServer = (...args) => {
|
||||||
delete req.headers["x-9r-real-ip"];
|
delete req.headers["x-9r-real-ip"];
|
||||||
delete req.headers["x-forwarded-for"];
|
delete req.headers["x-forwarded-for"];
|
||||||
delete req.headers["x-9r-via-proxy"];
|
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-real-ip"] = ip;
|
||||||
req.headers["x-9r-peer-token"] = PEER_TOKEN;
|
|
||||||
if (viaProxy) req.headers["x-9r-via-proxy"] = "1";
|
if (viaProxy) req.headers["x-9r-via-proxy"] = "1";
|
||||||
return handler(req, res);
|
return handler(req, res);
|
||||||
};
|
};
|
||||||
|
|
@ -125,15 +114,4 @@ http.createServer = (...args) => {
|
||||||
return server;
|
return server;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) require("./server.js");
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
1445
i18n/README.es.md
1445
i18n/README.es.md
File diff suppressed because it is too large
Load diff
1445
i18n/README.fr.md
1445
i18n/README.fr.md
File diff suppressed because it is too large
Load diff
1526
i18n/README.pt-BR.md
1526
i18n/README.pt-BR.md
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";
|
import REGISTRY from "../providers/registry/index.js";
|
||||||
// PROVIDER_MODELS now built from providers/registry (transport + models co-located)
|
// PROVIDER_MODELS now built from providers/registry (transport + models co-located)
|
||||||
import { PROVIDER_MODELS } from "../providers/index.js";
|
import { PROVIDER_MODELS } from "../providers/index.js";
|
||||||
import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js";
|
import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js";
|
||||||
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
|
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
|
||||||
export { PROVIDER_MODELS };
|
export { PROVIDER_MODELS };
|
||||||
|
|
||||||
|
|
@ -54,14 +54,6 @@ export function getModelTargetFormat(aliasOrId, modelId) {
|
||||||
return modelTargetFormat(findModel(models, modelId, aliasOrId));
|
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) {
|
export function getModelType(aliasOrId, modelId) {
|
||||||
const models = PROVIDER_MODELS[aliasOrId];
|
const models = PROVIDER_MODELS[aliasOrId];
|
||||||
if (!models) return null;
|
if (!models) return null;
|
||||||
|
|
|
||||||
|
|
@ -245,18 +245,6 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||||
// Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects
|
// Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects
|
||||||
const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {};
|
const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {};
|
||||||
stripBlacklisted(requestWithoutTools);
|
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 || {}) };
|
const generationConfig = { ...(requestWithoutTools.generationConfig || {}) };
|
||||||
if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) {
|
if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) {
|
||||||
generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS;
|
generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { CodexExecutor } from "./codex.js";
|
||||||
import { CursorExecutor } from "./cursor.js";
|
import { CursorExecutor } from "./cursor.js";
|
||||||
import { VertexExecutor } from "./vertex.js";
|
import { VertexExecutor } from "./vertex.js";
|
||||||
import { OpenCodeExecutor } from "./opencode.js";
|
import { OpenCodeExecutor } from "./opencode.js";
|
||||||
|
import { OpenCodeGoExecutor } from "./opencode-go.js";
|
||||||
import { GrokWebExecutor } from "./grok-web.js";
|
import { GrokWebExecutor } from "./grok-web.js";
|
||||||
import { GrokCliExecutor } from "./grok-cli.js";
|
import { GrokCliExecutor } from "./grok-cli.js";
|
||||||
import { PerplexityWebExecutor } from "./perplexity-web.js";
|
import { PerplexityWebExecutor } from "./perplexity-web.js";
|
||||||
|
|
@ -40,6 +41,7 @@ const executors = {
|
||||||
vertex: new VertexExecutor("vertex"),
|
vertex: new VertexExecutor("vertex"),
|
||||||
"vertex-partner": new VertexExecutor("vertex-partner"),
|
"vertex-partner": new VertexExecutor("vertex-partner"),
|
||||||
opencode: new OpenCodeExecutor(),
|
opencode: new OpenCodeExecutor(),
|
||||||
|
"opencode-go": new OpenCodeGoExecutor(),
|
||||||
"grok-web": new GrokWebExecutor(),
|
"grok-web": new GrokWebExecutor(),
|
||||||
"grok-cli": new GrokCliExecutor(),
|
"grok-cli": new GrokCliExecutor(),
|
||||||
gcli: new GrokCliExecutor(), // Alias
|
gcli: new GrokCliExecutor(), // Alias
|
||||||
|
|
@ -84,6 +86,7 @@ export { CursorExecutor } from "./cursor.js";
|
||||||
export { VertexExecutor } from "./vertex.js";
|
export { VertexExecutor } from "./vertex.js";
|
||||||
export { DefaultExecutor } from "./default.js";
|
export { DefaultExecutor } from "./default.js";
|
||||||
export { OpenCodeExecutor } from "./opencode.js";
|
export { OpenCodeExecutor } from "./opencode.js";
|
||||||
|
export { OpenCodeGoExecutor } from "./opencode-go.js";
|
||||||
export { GrokWebExecutor } from "./grok-web.js";
|
export { GrokWebExecutor } from "./grok-web.js";
|
||||||
export { GrokCliExecutor } from "./grok-cli.js";
|
export { GrokCliExecutor } from "./grok-cli.js";
|
||||||
export { PerplexityWebExecutor } from "./perplexity-web.js";
|
export { PerplexityWebExecutor } from "./perplexity-web.js";
|
||||||
|
|
|
||||||
|
|
@ -144,12 +144,6 @@ function normalizeStopReason(value) {
|
||||||
return reason || null;
|
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) {
|
function stopDisposition(stopReason, hasToolCalls) {
|
||||||
if (["malformed_model_output", "invalid_model_output"].includes(stopReason)) return "retryable_protocol_failure";
|
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";
|
if (["cancelled", "pause_turn", "model_context_window_exceeded"].includes(stopReason)) return "terminal_incomplete";
|
||||||
|
|
@ -717,25 +711,14 @@ export class KiroExecutor extends BaseExecutor {
|
||||||
};
|
};
|
||||||
const emitTools = (controller) => {
|
const emitTools = (controller) => {
|
||||||
for (const tool of state.tools.values()) {
|
for (const tool of state.tools.values()) {
|
||||||
// Validate per tool, not per turn: one unusable fragment used to throw out
|
const input = parsedToolInput(tool);
|
||||||
// of emitTools and take every other complete tool call in the same turn
|
if (tool.name === "tool_call") {
|
||||||
// with it, which the client saw as a turn that answered nothing.
|
if (typeof input.name !== "string" || !input.name.trim()) {
|
||||||
let input;
|
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name");
|
||||||
try {
|
}
|
||||||
input = parsedToolInput(tool);
|
if (!Object.prototype.hasOwnProperty.call(input, "arguments")) {
|
||||||
if (tool.name === "tool_call") {
|
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments");
|
||||||
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++;
|
const index = state.toolCounter++;
|
||||||
emitDelta(controller, {
|
emitDelta(controller, {
|
||||||
|
|
@ -746,26 +729,14 @@ export class KiroExecutor extends BaseExecutor {
|
||||||
function: { name: tool.name, arguments: "" }
|
function: { name: tool.name, arguments: "" }
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
const serializedInput = JSON.stringify(input);
|
|
||||||
emitDelta(controller, {
|
emitDelta(controller, {
|
||||||
tool_calls: [{ index, function: { arguments: serializedInput } }]
|
tool_calls: [{ index, function: { arguments: JSON.stringify(input) } }]
|
||||||
});
|
});
|
||||||
// Tool arguments are billed output like any other completion bytes. They
|
|
||||||
// were never added to totalContentLength, so the /4 estimator in finish()
|
|
||||||
// reported OUT 0 -- or the Math.max floor of 1 -- for every turn whose
|
|
||||||
// entire answer was a tool call.
|
|
||||||
state.totalContentLength += tool.name.length + serializedInput.length;
|
|
||||||
state.hasToolCalls = true;
|
state.hasToolCalls = true;
|
||||||
}
|
}
|
||||||
state.tools.clear();
|
state.tools.clear();
|
||||||
state.bufferedToolBytes = 0;
|
state.bufferedToolBytes = 0;
|
||||||
// A declared tool turn that emitted no usable call is only fatal when the
|
if (state.stopReason === "tool_use" && !state.hasToolCalls) {
|
||||||
// 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");
|
throw new Error("Kiro tool_use stop reason did not include a complete tool call");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -825,6 +796,7 @@ export class KiroExecutor extends BaseExecutor {
|
||||||
emitDelta(controller, { content: event.payload.content });
|
emitDelta(controller, { content: event.payload.content });
|
||||||
} else if (eventType === "toolUseEvent") {
|
} else if (eventType === "toolUseEvent") {
|
||||||
state.sawToolUse = true;
|
state.sawToolUse = true;
|
||||||
|
if (state.toolValidationError) return true;
|
||||||
const values = Array.isArray(event.payload) ? event.payload : [event.payload];
|
const values = Array.isArray(event.payload) ? event.payload : [event.payload];
|
||||||
if (!values[0]) throw new Error("Kiro toolUseEvent is empty");
|
if (!values[0]) throw new Error("Kiro toolUseEvent is empty");
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
|
|
@ -952,10 +924,9 @@ export class KiroExecutor extends BaseExecutor {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const bufferExceeded = error.code === "KIRO_BUFFER_EXCEEDED";
|
const bufferExceeded = error.code === "KIRO_BUFFER_EXCEEDED";
|
||||||
if (!bufferExceeded) {
|
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.toolValidationError ||= error.message;
|
||||||
console.error(`[Kiro] tool fragment rejected, keeping ${state.tools.size} buffered tool(s): ${error.message}`);
|
state.tools.clear();
|
||||||
|
state.bufferedToolBytes = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
fail(
|
fail(
|
||||||
|
|
@ -987,16 +958,7 @@ export class KiroExecutor extends BaseExecutor {
|
||||||
}
|
}
|
||||||
state.transportState = "clean_eof";
|
state.transportState = "clean_eof";
|
||||||
const declaredDisposition = stopDisposition(state.stopReason, state.sawToolUse);
|
const declaredDisposition = stopDisposition(state.stopReason, state.sawToolUse);
|
||||||
// model_context_window_exceeded / max_tokens map to terminal_incomplete. When
|
if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) {
|
||||||
// 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"
|
const code = declaredDisposition === "retryable_protocol_failure"
|
||||||
? "kiro_retryable_protocol_failure"
|
? "kiro_retryable_protocol_failure"
|
||||||
: declaredDisposition === "terminal_refusal"
|
: declaredDisposition === "terminal_refusal"
|
||||||
|
|
@ -1013,6 +975,16 @@ export class KiroExecutor extends BaseExecutor {
|
||||||
);
|
);
|
||||||
return;
|
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 {
|
try {
|
||||||
emitTools(controller);
|
emitTools(controller);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -1025,22 +997,6 @@ export class KiroExecutor extends BaseExecutor {
|
||||||
);
|
);
|
||||||
return;
|
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;
|
const hasOutput = state.hasText || state.hasReasoning || state.hasCode || state.hasToolCalls;
|
||||||
if (!hasOutput && !state.explicitStop) {
|
if (!hasOutput && !state.explicitStop) {
|
||||||
|
|
@ -1055,13 +1011,7 @@ export class KiroExecutor extends BaseExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
const disposition = stopDisposition(state.stopReason, state.hasToolCalls);
|
const disposition = stopDisposition(state.stopReason, state.hasToolCalls);
|
||||||
// Same reasoning as declaredTruncatedAfterOutput above.
|
if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) {
|
||||||
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"
|
const code = disposition === "retryable_protocol_failure"
|
||||||
? "kiro_retryable_protocol_failure"
|
? "kiro_retryable_protocol_failure"
|
||||||
: disposition === "terminal_refusal"
|
: disposition === "terminal_refusal"
|
||||||
|
|
@ -1091,24 +1041,18 @@ export class KiroExecutor extends BaseExecutor {
|
||||||
total_tokens: prompt + completion
|
total_tokens: prompt + completion
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const finishReason = truncatedAfterOutput
|
const finishReason = state.hasToolCalls
|
||||||
? "length"
|
? "tool_calls"
|
||||||
: state.hasToolCalls
|
: disposition === "length"
|
||||||
? "tool_calls"
|
? "length"
|
||||||
: disposition === "length"
|
: "stop";
|
||||||
? "length"
|
|
||||||
: "stop";
|
|
||||||
controller.enqueue(sseChunk({}, finishReason, state.usage));
|
controller.enqueue(sseChunk({}, finishReason, state.usage));
|
||||||
controller.enqueue(encoder.encode(SSE_DONE));
|
controller.enqueue(encoder.encode(SSE_DONE));
|
||||||
state.finished = true;
|
state.finished = true;
|
||||||
options.onTerminalState?.(diagnostics({
|
options.onTerminalState?.(diagnostics({
|
||||||
terminal_provenance: state.terminalProvenance || "clean_eventstream_eof",
|
terminal_provenance: state.terminalProvenance || "clean_eventstream_eof",
|
||||||
transport_state: state.transportState,
|
transport_state: state.transportState,
|
||||||
// Report what this exit actually did, not the raw disposition. The
|
stop_disposition: disposition
|
||||||
// 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
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
49
open-sse/executors/opencode-go.js
Normal file
49
open-sse/executors/opencode-go.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { BaseExecutor } from "./base.js";
|
||||||
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
|
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
||||||
|
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
|
||||||
|
|
||||||
|
// Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth)
|
||||||
|
const MESSAGES_FORMAT_MODELS = new Set([
|
||||||
|
"minimax-m3",
|
||||||
|
"minimax-m2.7",
|
||||||
|
"minimax-m2.5",
|
||||||
|
"qwen3.7-max",
|
||||||
|
"qwen3.7-plus",
|
||||||
|
"qwen3.6-plus",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BASE = "https://opencode.ai/zen/go/v1";
|
||||||
|
|
||||||
|
export class OpenCodeGoExecutor extends BaseExecutor {
|
||||||
|
constructor() {
|
||||||
|
super("opencode-go", PROVIDERS["opencode-go"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here
|
||||||
|
buildUrl(model) {
|
||||||
|
this._lastModel = model;
|
||||||
|
return MESSAGES_FORMAT_MODELS.has(model)
|
||||||
|
? `${BASE}/messages`
|
||||||
|
: `${BASE}/chat/completions`;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildHeaders(credentials, stream = true) {
|
||||||
|
const key = credentials?.apiKey || credentials?.accessToken;
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
if (MESSAGES_FORMAT_MODELS.has(this._lastModel)) {
|
||||||
|
headers["x-api-key"] = key;
|
||||||
|
headers["anthropic-version"] = ANTHROPIC_API_VERSION;
|
||||||
|
} else {
|
||||||
|
headers["Authorization"] = `Bearer ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream) headers["Accept"] = "text/event-stream";
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
transformRequest(model, body) {
|
||||||
|
return injectReasoningContent({ provider: this.provider, model, body });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,43 +1,16 @@
|
||||||
import crypto from "crypto";
|
|
||||||
import { BaseExecutor } from "./base.js";
|
import { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS } from "../config/providers.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
||||||
import { resolveSessionId } from "../utils/sessionManager.js";
|
|
||||||
|
|
||||||
const OPENCODE_UA = "opencode";
|
// Models that use /zen/v1/messages (claude format)
|
||||||
const MESSAGES_MODELS = new Set();
|
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 {
|
export class OpenCodeExecutor extends BaseExecutor {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("opencode", PROVIDERS.opencode);
|
super("opencode", PROVIDERS.opencode);
|
||||||
this._currentSessionId = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
transformRequest(model, body, stream, credentials) {
|
transformRequest(model, body) {
|
||||||
this._currentSessionId = resolveOpencodeSession(body, credentials);
|
|
||||||
return injectReasoningContent({ provider: this.provider, model, body });
|
return injectReasoningContent({ provider: this.provider, model, body });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,23 +21,12 @@ export class OpenCodeExecutor extends BaseExecutor {
|
||||||
: `${base}/zen/v1/chat/completions`;
|
: `${base}/zen/v1/chat/completions`;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildHeaders(credentials, stream = true) {
|
buildHeaders() {
|
||||||
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 {
|
return {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": "Bearer public",
|
"Authorization": "Bearer public",
|
||||||
"User-Agent": isOpencodeDownstream ? downstreamUa : OPENCODE_UA,
|
"x-opencode-client": "desktop",
|
||||||
"x-opencode-client": lower["x-opencode-client"] || "desktop",
|
"Accept": "text/event-stream"
|
||||||
"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,52 +215,6 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a qoder error message indicates a billing/quota block.
|
|
||||||
* Signatures: code 112 (quota exhausted), code 10605 (queue throttle), pricingUrl field.
|
|
||||||
*/
|
|
||||||
function isBillingBlock(inner) {
|
|
||||||
if (!inner || typeof inner !== "string") return false;
|
|
||||||
const lowerMsg = inner.toLowerCase();
|
|
||||||
// Match: {"code":"112",...}, {"code":"10605",...}, or pricingUrl field
|
|
||||||
return /\"code\"\s*:\s*\"(112|10605)\"/.test(inner) || lowerMsg.includes("pricingurl");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Peek the first SSE frame to detect billing errors before piping.
|
|
||||||
* Returns { isBilling, statusVal, message, consumed } — `consumed` is every
|
|
||||||
* byte read so far (including the peeked line) so the caller can re-process
|
|
||||||
* it and nothing is dropped from the stream.
|
|
||||||
*/
|
|
||||||
async function peekFirstQoderFrame(reader, decoder) {
|
|
||||||
let consumed = "";
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) return { isBilling: false, consumed, upstreamDone: true };
|
|
||||||
|
|
||||||
consumed += decoder.decode(value, { stream: true });
|
|
||||||
const nl = consumed.indexOf("\n");
|
|
||||||
if (nl === -1) continue; // need a full line first
|
|
||||||
|
|
||||||
const line = consumed.slice(0, nl).replace(/\r$/, "").trim();
|
|
||||||
if (!line.startsWith("data:")) continue;
|
|
||||||
|
|
||||||
const data = line.slice(5).trimStart();
|
|
||||||
if (data === "[DONE]") return { isBilling: false, consumed };
|
|
||||||
|
|
||||||
let envelope;
|
|
||||||
try { envelope = JSON.parse(data); } catch { return { isBilling: false, consumed }; }
|
|
||||||
|
|
||||||
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
|
|
||||||
const inner = typeof envelope.body === "string" ? envelope.body : "";
|
|
||||||
|
|
||||||
if (statusVal !== 200 && isBillingBlock(inner)) {
|
|
||||||
return { isBilling: true, statusVal, message: inner || `qoder billing block (${statusVal})` };
|
|
||||||
}
|
|
||||||
return { isBilling: false, consumed };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap the upstream's `{statusCodeValue, body}` SSE envelope into plain
|
* Wrap the upstream's `{statusCodeValue, body}` SSE envelope into plain
|
||||||
* OpenAI SSE chunks the rest of the chatCore pipeline understands.
|
* OpenAI SSE chunks the rest of the chatCore pipeline understands.
|
||||||
|
|
@ -275,33 +229,15 @@ async function peekFirstQoderFrame(reader, decoder) {
|
||||||
* [DONE]/error frame (agent keepalive). Non-streaming clients drain via
|
* [DONE]/error frame (agent keepalive). Non-streaming clients drain via
|
||||||
* response.text() which hangs until the socket closes — so on terminal
|
* response.text() which hangs until the socket closes — so on terminal
|
||||||
* events we cancel the upstream reader and close our stream immediately.
|
* events we cancel the upstream reader and close our stream immediately.
|
||||||
*
|
|
||||||
* NEW: Peek first frame to detect billing blocks (code 112/10605/pricingUrl).
|
|
||||||
* If detected, return 403 response so chatCore marks connection unavailable
|
|
||||||
* and triggers combo fallback instead of leaking error text into chat.
|
|
||||||
*/
|
*/
|
||||||
async function wrapQoderSSE(response, model) {
|
function wrapQoderSSE(response, model) {
|
||||||
if (!response.ok || !response.body) return response;
|
if (!response.ok || !response.body) return response;
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const reader = response.body.getReader();
|
|
||||||
|
|
||||||
// Peek first frame to detect billing block
|
|
||||||
const peek = await peekFirstQoderFrame(reader, decoder);
|
|
||||||
if (peek?.isBilling) {
|
|
||||||
// Billing block detected — return 403 so chatCore fails this connection
|
|
||||||
await reader.cancel().catch(() => {});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: { message: peek.message, code: peek.statusVal } }),
|
|
||||||
{ status: 403, headers: { "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal flow: re-process every byte the peek consumed, then continue.
|
|
||||||
let buffer = peek.consumed || "";
|
|
||||||
const upstreamDrained = peek.upstreamDone === true;
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
let buffer = "";
|
||||||
let doneEmitted = false;
|
let doneEmitted = false;
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
|
||||||
// Process one already-extracted SSE line (no trailing newline).
|
// Process one already-extracted SSE line (no trailing newline).
|
||||||
const processLine = (line, controller) => {
|
const processLine = (line, controller) => {
|
||||||
|
|
@ -351,28 +287,7 @@ async function wrapQoderSSE(response, model) {
|
||||||
// enqueueing would never be re-invoked, hanging consumers like .text().
|
// enqueueing would never be re-invoked, hanging consumers like .text().
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
try {
|
try {
|
||||||
// Drain whatever the peek already pulled off the socket first.
|
while (!doneEmitted) {
|
||||||
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();
|
const { done, value } = await reader.read();
|
||||||
if (done) {
|
if (done) {
|
||||||
buffer += decoder.decode();
|
buffer += decoder.decode();
|
||||||
|
|
@ -557,7 +472,7 @@ export class QoderExecutor extends BaseExecutor {
|
||||||
return { response, url, headers, transformedBody: payload };
|
return { response, url, headers, transformedBody: payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`);
|
const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`);
|
||||||
return { response: wrapped, url, headers, transformedBody: payload };
|
return { response: wrapped, url, headers, transformedBody: payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -581,5 +496,4 @@ export const __test__ = {
|
||||||
normalizeMessages,
|
normalizeMessages,
|
||||||
wrapQoderSSE,
|
wrapQoderSSE,
|
||||||
buildQoderRequestBody,
|
buildQoderRequestBody,
|
||||||
isBillingBlock,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import { detectFormat, getTargetFormat, resolveTransport } from "../services/pro
|
||||||
import { translateRequest } from "../translator/index.js";
|
import { translateRequest } from "../translator/index.js";
|
||||||
import { applyThinking, extractThinking, stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js";
|
import { applyThinking, extractThinking, stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js";
|
||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
import { normalizeClaudePassthrough, anchorClaudeCache } from "../translator/formats/claude.js";
|
import { normalizeClaudePassthrough } from "../translator/formats/claude.js";
|
||||||
import { createStreamController } from "../utils/streamHandler.js";
|
import { createStreamController } from "../utils/streamHandler.js";
|
||||||
import { refreshWithRetry } from "../services/tokenRefresh.js";
|
import { refreshWithRetry } from "../services/tokenRefresh.js";
|
||||||
import { createRequestLogger } from "../utils/requestLogger.js";
|
import { createRequestLogger } from "../utils/requestLogger.js";
|
||||||
import { getModelTargetFormat, getModelSupportedFormats, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||||
import { PROVIDERS } from "../config/providers.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
||||||
import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js";
|
import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js";
|
||||||
|
|
@ -78,20 +78,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||||
|
|
||||||
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||||
const modelTargetFormat = getModelTargetFormat(alias, model);
|
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 runtimeTransport = resolveTransport(provider, sourceFormat);
|
||||||
// Per-model guard: when a model declares supportedFormats, only use the
|
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider, credentials);
|
||||||
// sourceFormat-matched transport if that format is declared (opencode-go models
|
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
|
||||||
// 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 stripList = getModelStrip(alias, model);
|
||||||
const upstreamModel = getModelUpstreamId(alias, model);
|
const upstreamModel = getModelUpstreamId(alias, model);
|
||||||
|
|
||||||
|
|
@ -285,10 +275,6 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||||
|
|
||||||
if (xf.length && log?.line) log.line(reqTag, "⚙", xf.join(" · "));
|
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);
|
const executor = getExecutor(provider);
|
||||||
trackPendingRequest(model, provider, connectionId, true);
|
trackPendingRequest(model, provider, connectionId, true);
|
||||||
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { });
|
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { });
|
||||||
|
|
|
||||||
|
|
@ -44,14 +44,13 @@ export function extractUsageFromResponse(responseBody) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gemini format. Antigravity / gemini-cli wrap the payload in { response: {...} }.
|
// Gemini format
|
||||||
const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata;
|
if (responseBody.usageMetadata) {
|
||||||
if (usageMetadata) {
|
|
||||||
return {
|
return {
|
||||||
prompt_tokens: usageMetadata.promptTokenCount || 0,
|
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
|
||||||
completion_tokens: usageMetadata.candidatesTokenCount || 0,
|
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
|
||||||
cached_tokens: usageMetadata.cachedContentTokenCount || 0,
|
cached_tokens: responseBody.usageMetadata.cachedContentTokenCount || 0,
|
||||||
reasoning_tokens: usageMetadata.thoughtsTokenCount || 0
|
reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@
|
||||||
* @property {Record<string,unknown>} [providerSpecificData]
|
* @property {Record<string,unknown>} [providerSpecificData]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertPublicUrl } from "../../../src/shared/utils/ssrfGuard.js";
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -65,31 +63,12 @@ export function getProviderSetting(params, key) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve base URL with optional override from providerOptions.baseUrl.
|
* 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 {SearchProviderConfig} config
|
||||||
* @param {SearchRequestParams} params
|
* @param {SearchRequestParams} params
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function resolveBaseUrl(config, params) {
|
export function resolveBaseUrl(config, params) {
|
||||||
const override = getProviderSetting(params, "baseUrl");
|
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(/\/+$/, "");
|
return (override || config.baseUrl).replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,25 +51,6 @@ async function huggingface({ baseUrl, apiKey, text, modelId }) {
|
||||||
return responseToBase64(res, "wav");
|
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 }
|
// Inworld: Basic auth, JSON { audioContent }
|
||||||
async function inworld({ baseUrl, apiKey, text, modelId, voiceId }) {
|
async function inworld({ baseUrl, apiKey, text, modelId, voiceId }) {
|
||||||
const res = await fetch(baseUrl, {
|
const res = await fetch(baseUrl, {
|
||||||
|
|
@ -185,5 +166,4 @@ export const FORMAT_HANDLERS = {
|
||||||
tortoise,
|
tortoise,
|
||||||
openai: openaiCompat,
|
openai: openaiCompat,
|
||||||
"minimax-tts": minimaxTts,
|
"minimax-tts": minimaxTts,
|
||||||
"fish-audio": fishAudio,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,6 @@ export const PATTERN_CAPABILITIES = [
|
||||||
|
|
||||||
// ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─
|
// ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─
|
||||||
{ pattern: "*gemini*image*", caps: { vision: true, imageOutput: true, contextWindow: 1048576 } },
|
{ 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*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-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 } },
|
{ 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,11 +38,3 @@ export function modelStrip(model) {
|
||||||
export function modelTargetFormat(model) {
|
export function modelTargetFormat(model) {
|
||||||
return model?.targetFormat || MODEL_DEFAULTS.targetFormat;
|
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,10 +57,6 @@ export const MODEL_PRICING = {
|
||||||
"o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
|
"o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
|
||||||
|
|
||||||
// === Gemini ===
|
// === 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": { 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-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 },
|
"gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
// Token Plan — credit subscription keys on token-plan.<region>.maas.aliyuncs.com.
|
|
||||||
// Fourth Alibaba key type: Coding Plan (alicode/alicode-intl) and Model Studio
|
|
||||||
// (alims-intl) both reject these keys, and they reject Model Studio keys back.
|
|
||||||
// Singapore is the only region that serves the plan; eu-central-1 answers
|
|
||||||
// IllegalEndpoint. The Anthropic surface (/apps/anthropic/v1/messages) is not
|
|
||||||
// authorized for this plan, so OpenAI-compatible mode is the only transport.
|
|
||||||
export default {
|
|
||||||
id: "alitp-intl",
|
|
||||||
priority: 11,
|
|
||||||
alias: "alitp-intl",
|
|
||||||
display: {
|
|
||||||
name: "Alibaba Token Plan",
|
|
||||||
icon: "cloud",
|
|
||||||
color: "#FF6A00",
|
|
||||||
textIcon: "ATP",
|
|
||||||
website: "https://www.alibabacloud.com/campaign/ai-landing-page-token",
|
|
||||||
notice: {
|
|
||||||
apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
category: "apikey",
|
|
||||||
transport: {
|
|
||||||
baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
|
|
||||||
headers: {},
|
|
||||||
quirks: { preserveCacheControl: true },
|
|
||||||
},
|
|
||||||
models: [
|
|
||||||
{ id: "qwen3.8-max-preview", name: "Qwen3.8 Max Preview" },
|
|
||||||
{ id: "qwen3.7-max", name: "Qwen3.7 Max" },
|
|
||||||
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus" },
|
|
||||||
{ id: "qwen3.6-flash", name: "Qwen3.6 Flash" },
|
|
||||||
{ id: "glm-5.2", name: "GLM 5.2" },
|
|
||||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -45,9 +45,6 @@ export default {
|
||||||
clientSecret: "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",
|
clientSecret: "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",
|
||||||
},
|
},
|
||||||
models: [
|
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-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-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)" },
|
{ id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" },
|
||||||
|
|
@ -75,7 +72,7 @@ export default {
|
||||||
"https://www.googleapis.com/auth/cclog",
|
"https://www.googleapis.com/auth/cclog",
|
||||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||||
],
|
],
|
||||||
apiEndpoint: "https://cloudcode-pa.googleapis.com",
|
apiEndpoint: "https://daily-cloudcode-pa.googleapis.com",
|
||||||
apiVersion: "v1internal",
|
apiVersion: "v1internal",
|
||||||
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||||
onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
|
onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
// Fish Audio TTS — the model id travels in an HTTP `model` header rather than the
|
|
||||||
// JSON body, and the voice is a reference_id (a cloned or preset voice model).
|
|
||||||
export default {
|
|
||||||
id: "fish-audio",
|
|
||||||
alias: "fish",
|
|
||||||
display: {
|
|
||||||
name: "Fish Audio",
|
|
||||||
icon: "record_voice_over",
|
|
||||||
color: "#1E9BF0",
|
|
||||||
textIcon: "FA",
|
|
||||||
website: "https://fish.audio",
|
|
||||||
notice: {
|
|
||||||
apiKeyUrl: "https://fish.audio/app/api-keys/",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
category: "apikey",
|
|
||||||
authType: "apikey",
|
|
||||||
serviceKinds: ["tts"],
|
|
||||||
ttsConfig: {
|
|
||||||
baseUrl: "https://api.fish.audio/v1/tts",
|
|
||||||
authType: "apikey",
|
|
||||||
authHeader: "bearer",
|
|
||||||
format: "fish-audio",
|
|
||||||
models: [
|
|
||||||
{ id: "s2.1-pro-free", name: "S2.1 Pro Free" },
|
|
||||||
{ id: "s2.1-pro", name: "S2.1 Pro" },
|
|
||||||
{ id: "s2-pro", name: "S2 Pro" },
|
|
||||||
{ id: "s1", name: "S1" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -36,7 +36,6 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
{ id: "gemini-3.7-flash", name: "Gemini 3.7 Flash" },
|
|
||||||
{ id: "gemini-3.6-flash", name: "Gemini 3.6 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.5-flash-lite", name: "Gemini 3.5 Flash Lite" },
|
||||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
{ id: "glm-5.3", name: "GLM 5.3" },
|
|
||||||
{ id: "glm-5.2", name: "GLM 5.2" },
|
{ id: "glm-5.2", name: "GLM 5.2" },
|
||||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||||
{ id: "glm-5", name: "GLM 5" },
|
{ id: "glm-5", name: "GLM 5" },
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ export default {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
models: [
|
models: [
|
||||||
{ id: "glm-5.3", name: "GLM 5.3" },
|
|
||||||
{ id: "glm-5.2", name: "GLM 5.2" },
|
{ id: "glm-5.2", name: "GLM 5.2" },
|
||||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||||
{ id: "glm-5", name: "GLM 5" },
|
{ id: "glm-5", name: "GLM 5" },
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,6 @@ import p116 from "./tokenrouter.js";
|
||||||
import p117 from "./selfhosted-stt.js";
|
import p117 from "./selfhosted-stt.js";
|
||||||
import p118 from "./selfhosted-tts.js";
|
import p118 from "./selfhosted-tts.js";
|
||||||
import p119 from "./selfhosted-embedding.js";
|
import p119 from "./selfhosted-embedding.js";
|
||||||
import p120 from "./fish-audio.js";
|
|
||||||
import p121 from "./alitp-intl.js";
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
p0,
|
p0,
|
||||||
|
|
@ -241,6 +239,4 @@ export default [
|
||||||
p117,
|
p117,
|
||||||
p118,
|
p118,
|
||||||
p119,
|
p119,
|
||||||
p120,
|
|
||||||
p121,
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "freeTier",
|
category: "freeTier",
|
||||||
authModes: ["oauth", "apikey"],
|
authModes: ["oauth"],
|
||||||
hasOAuth: true,
|
hasOAuth: true,
|
||||||
transport: {
|
transport: {
|
||||||
baseUrl: "https://llm.kimchi.dev/openai/v1/chat/completions",
|
baseUrl: "https://llm.kimchi.dev/openai/v1/chat/completions",
|
||||||
|
|
|
||||||
|
|
@ -22,28 +22,20 @@ export default {
|
||||||
baseUrl: "https://opencode.ai/zen/go/v1/chat/completions",
|
baseUrl: "https://opencode.ai/zen/go/v1/chat/completions",
|
||||||
headers: {},
|
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: [
|
models: [
|
||||||
{ id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] },
|
{ id: "glm-5.2", name: "GLM 5.2" },
|
||||||
{ id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] },
|
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||||
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] },
|
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
|
||||||
{ id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] },
|
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
||||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] },
|
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
||||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] },
|
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
|
||||||
{ id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] },
|
{ id: "mimo-v2.5", name: "MiMo V2.5" },
|
||||||
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] },
|
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
|
||||||
{ id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] },
|
{ id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude" },
|
||||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] },
|
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
|
||||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] },
|
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
|
||||||
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] },
|
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", targetFormat: "claude" },
|
||||||
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] },
|
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", targetFormat: "claude" },
|
||||||
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] },
|
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", targetFormat: "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
|
// Keep this static even when 9router runs on Linux: the provider profile is
|
||||||
// intentionally matching the IDE client, not the server host.
|
// intentionally matching the IDE client, not the server host.
|
||||||
export const ANTIGRAVITY_IDE_VERSION = "2.1.1";
|
export const ANTIGRAVITY_IDE_VERSION = "2.1.1";
|
||||||
export const ANTIGRAVITY_IDE_BASE_URL = "https://cloudcode-pa.googleapis.com";
|
export const ANTIGRAVITY_IDE_BASE_URL = "https://daily-cloudcode-pa.googleapis.com";
|
||||||
export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`;
|
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)
|
// Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth)
|
||||||
|
|
|
||||||
|
|
@ -185,9 +185,13 @@ export function resetAccountState(account) {
|
||||||
if (!account) return account;
|
if (!account) return account;
|
||||||
return {
|
return {
|
||||||
...account,
|
...account,
|
||||||
|
...buildClearModelLocksUpdate(account),
|
||||||
rateLimitedUntil: null,
|
rateLimitedUntil: null,
|
||||||
backoffLevel: 0,
|
backoffLevel: 0,
|
||||||
|
testStatus: "active",
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
errorCode: null,
|
||||||
|
lastErrorAt: null,
|
||||||
status: "active"
|
status: "active"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,42 +138,8 @@ export function detectRequiredCapabilities(body) {
|
||||||
if (Array.isArray(content)) for (const b of content) scanBlock(b);
|
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).
|
// Modalities: current user turn only (trailing user run across each known shape).
|
||||||
for (const m of trailingUserItems(body.messages)) scanMessage(m); // openai / claude / hermes / ollama
|
for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude
|
||||||
for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses
|
for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses
|
||||||
const contents = body.contents || body.request?.contents; // gemini / antigravity
|
const contents = body.contents || body.request?.contents; // gemini / antigravity
|
||||||
for (const c of trailingUserItems(contents)) scanContent(c.parts);
|
for (const c of trailingUserItems(contents)) scanContent(c.parts);
|
||||||
|
|
@ -564,10 +530,7 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
|
||||||
log.info("FUSION", `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`);
|
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).
|
// 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose).
|
||||||
const { tools, tool_choice, stream_options, ...rest } = body;
|
const { tools, tool_choice, ...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 };
|
const panelBody = { ...rest, stream: false };
|
||||||
|
|
||||||
// Flatten tool turns to prose so panel models keep context without emitting tool_calls.
|
// 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),
|
github: (c) => getGitHubUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||||
"gemini-cli": (c) => getGeminiUsage(c.accessToken, c.providerDataWithProjectId, c.proxyOptions),
|
"gemini-cli": (c) => getGeminiUsage(c.accessToken, c.providerDataWithProjectId, c.proxyOptions),
|
||||||
antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||||
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }),
|
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions),
|
||||||
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
|
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
|
||||||
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||||
qoder: async (c) => {
|
qoder: async (c) => {
|
||||||
|
|
@ -56,7 +56,7 @@ const USAGE_HANDLERS = {
|
||||||
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
|
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
|
export async function getUsageForProvider(connection, proxyOptions = null) {
|
||||||
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
|
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
|
||||||
const providerDataWithProjectId = {
|
const providerDataWithProjectId = {
|
||||||
...(providerSpecificData || {}),
|
...(providerSpecificData || {}),
|
||||||
|
|
@ -65,13 +65,5 @@ export async function getUsageForProvider(connection, proxyOptions = null, optio
|
||||||
|
|
||||||
const handler = USAGE_HANDLERS[provider];
|
const handler = USAGE_HANDLERS[provider];
|
||||||
if (!handler) return { message: `Usage API not implemented for ${provider}` };
|
if (!handler) return { message: `Usage API not implemented for ${provider}` };
|
||||||
return await handler({
|
return await handler({ provider, accessToken, apiKey, providerSpecificData, providerDataWithProjectId, proxyOptions });
|
||||||
provider,
|
|
||||||
accessToken,
|
|
||||||
apiKey,
|
|
||||||
providerSpecificData,
|
|
||||||
providerDataWithProjectId,
|
|
||||||
proxyOptions,
|
|
||||||
force: options.force === true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,43 +19,7 @@ const CLAUDE_CONFIG = {
|
||||||
const OAUTH_429_COOLDOWN_MS = 180000;
|
const OAUTH_429_COOLDOWN_MS = 180000;
|
||||||
const oauthCooldown = new Map();
|
const oauthCooldown = new Map();
|
||||||
|
|
||||||
// Dedup + short TTL cache per access token. Many tabs / many accounts / auto-refresh
|
export async function getClaudeUsage(accessToken, proxyOptions = null) {
|
||||||
// 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 {
|
try {
|
||||||
// Skip OAuth usage call while this token is cooling down from a recent 429
|
// Skip OAuth usage call while this token is cooling down from a recent 429
|
||||||
const cooldownUntil = oauthCooldown.get(accessToken);
|
const cooldownUntil = oauthCooldown.get(accessToken);
|
||||||
|
|
|
||||||
|
|
@ -161,9 +161,6 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
||||||
if (data.models) {
|
if (data.models) {
|
||||||
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
|
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
|
||||||
const importantModels = [
|
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-high',
|
||||||
'gemini-3.6-flash-medium',
|
'gemini-3.6-flash-medium',
|
||||||
'gemini-3.6-flash-low',
|
'gemini-3.6-flash-low',
|
||||||
|
|
|
||||||
|
|
@ -62,19 +62,6 @@ function stripOpenAI(body, caps) {
|
||||||
if (!Array.isArray(body.messages)) return;
|
if (!Array.isArray(body.messages)) return;
|
||||||
const last = body.messages.length - 1;
|
const last = body.messages.length - 1;
|
||||||
body.messages.forEach((msg, i) => {
|
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;
|
if (!Array.isArray(msg.content)) return;
|
||||||
const removed = new Set();
|
const removed = new Set();
|
||||||
msg.content = filterBlocks(msg.content, capForOpenAIBlock, caps, removed, i === last);
|
msg.content = filterBlocks(msg.content, capForOpenAIBlock, caps, removed, i === last);
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ import { PROVIDERS } from "../../providers/index.js";
|
||||||
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
|
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
|
||||||
import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.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
|
// Check if message has valid non-empty content
|
||||||
export function hasValidContent(msg) {
|
export function hasValidContent(msg) {
|
||||||
if (typeof msg.content === "string" && msg.content.trim()) return true;
|
if (typeof msg.content === "string" && msg.content.trim()) return true;
|
||||||
|
|
@ -127,38 +124,32 @@ export function normalizeClaudePassthrough(body, model = "") {
|
||||||
if (Object.keys(body.output_config).length === 0) delete body.output_config;
|
if (Object.keys(body.output_config).length === 0) delete body.output_config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fold mid-conversation system messages into the neighbouring turn.
|
// 2. Hoist mid-conversation system messages into the top-level system field
|
||||||
// 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)) {
|
if (Array.isArray(body.messages)) {
|
||||||
|
const systemBlocks = [];
|
||||||
const messages = [];
|
const messages = [];
|
||||||
for (const msg of body.messages) {
|
for (const msg of body.messages) {
|
||||||
if (msg.role !== ROLE.SYSTEM) {
|
if (msg.role === ROLE.SYSTEM) {
|
||||||
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()) systemBlocks.push({ type: CLAUDE_BLOCK.TEXT, text });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const text = typeof msg.content === "string"
|
messages.push(msg);
|
||||||
? msg.content
|
}
|
||||||
: Array.isArray(msg.content)
|
|
||||||
? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n")
|
if (systemBlocks.length > 0) {
|
||||||
: "";
|
const existing = Array.isArray(body.system)
|
||||||
if (!text.trim()) continue;
|
? body.system
|
||||||
|
: typeof body.system === "string" && body.system.trim()
|
||||||
// Copy-on-write: the caller's body is reused across account-fallback
|
? [{ type: "text", text: body.system }]
|
||||||
// attempts, so folding must never mutate the original message.
|
: [];
|
||||||
const block = { type: CLAUDE_BLOCK.TEXT, text };
|
body.system = [...existing, ...systemBlocks];
|
||||||
const prev = messages[messages.length - 1];
|
body.messages = messages;
|
||||||
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,
|
// 3. Drop thinking blocks whose signature is not Claude's (combo mixes models,
|
||||||
|
|
@ -191,70 +182,6 @@ export function normalizeClaudePassthrough(body, model = "") {
|
||||||
return body;
|
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
|
// Prepare request for Claude format endpoints
|
||||||
// - Cleanup cache_control
|
// - Cleanup cache_control
|
||||||
// - Filter empty messages
|
// - Filter empty messages
|
||||||
|
|
|
||||||
|
|
@ -311,26 +311,6 @@ function ensureObjectType(obj) {
|
||||||
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
|
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
|
||||||
export function cleanJSONSchemaForAntigravity(schema) {
|
export function cleanJSONSchemaForAntigravity(schema) {
|
||||||
if (!schema || typeof schema !== "object") return 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)
|
// Mutate directly (schema is only used once per request)
|
||||||
let cleaned = schema;
|
let cleaned = schema;
|
||||||
|
|
|
||||||
|
|
@ -287,18 +287,6 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||||
toolSpecs,
|
toolSpecs,
|
||||||
nameMap,
|
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 replayCurrent = canonical.currentMessage.userInputMessage;
|
||||||
const userInputMessage = {
|
const userInputMessage = {
|
||||||
content: replayCurrent.content || "",
|
content: replayCurrent.content || "",
|
||||||
|
|
|
||||||
|
|
@ -421,7 +421,6 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
|
||||||
if (body.reasoning !== undefined) result.reasoning = body.reasoning;
|
if (body.reasoning !== undefined) result.reasoning = body.reasoning;
|
||||||
if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" };
|
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.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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,40 @@ export function openaiToClaudeRequest(model, body, stream) {
|
||||||
|
|
||||||
flushCurrentMessage();
|
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
|
// Add cache_control to last assistant message
|
||||||
for (let i = result.messages.length - 1; i >= 0; i--) {
|
for (let i = result.messages.length - 1; i >= 0; i--) {
|
||||||
const message = result.messages[i];
|
const message = result.messages[i];
|
||||||
|
|
|
||||||
|
|
@ -379,18 +379,6 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||||
toolSpecs,
|
toolSpecs,
|
||||||
nameMap,
|
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 replayCurrent = canonical.currentMessage.userInputMessage;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
|
||||||
|
|
@ -75,15 +75,6 @@ export function kiroToClaudeResponse(chunk, state) {
|
||||||
? data.usage.completion_tokens
|
? data.usage.completion_tokens
|
||||||
: 0;
|
: 0;
|
||||||
state.usage = { input_tokens: promptTokens, output_tokens: outputTokens };
|
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.
|
// First chunk → emit message_start.
|
||||||
|
|
@ -263,13 +254,6 @@ export function kiroToClaudeNonStreaming(data) {
|
||||||
usage: {
|
usage: {
|
||||||
input_tokens: usage.prompt_tokens || 0,
|
input_tokens: usage.prompt_tokens || 0,
|
||||||
output_tokens: usage.completion_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 (empty array is truthy; require a real call)
|
// Handle tool_calls
|
||||||
if (delta.tool_calls && delta.tool_calls.length) {
|
if (delta.tool_calls) {
|
||||||
closeMessage(state, emit, idx);
|
closeMessage(state, emit, idx);
|
||||||
for (const tc of delta.tool_calls) {
|
for (const tc of delta.tool_calls) {
|
||||||
emitToolCall(state, emit, tc);
|
emitToolCall(state, emit, tc);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "0.5.55",
|
"version": "0.5.50",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -9,10 +9,10 @@
|
||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"postbuild": "node scripts/copy-standalone-assets.mjs",
|
"postbuild": "node scripts/copy-standalone-assets.mjs",
|
||||||
"postbuild:bun": "node scripts/copy-standalone-assets.mjs",
|
"postbuild:bun": "node scripts/copy-standalone-assets.mjs",
|
||||||
"start": "node custom-server.js --port 20127",
|
"start": "next start --port 20127",
|
||||||
"dev:bun": "bun --bun next dev --webpack --port 20127",
|
"dev:bun": "bun --bun next dev --webpack --port 20127",
|
||||||
"build:bun": "bun --bun next build --webpack",
|
"build:bun": "bun --bun next build --webpack",
|
||||||
"start:bun": "bun ./.next/standalone/custom-server.js",
|
"start:bun": "bun ./.next/standalone/server.js",
|
||||||
"cli:pack": "npm --prefix cli run pack:cli",
|
"cli:pack": "npm --prefix cli run pack:cli",
|
||||||
"cli:publish": "npm --prefix cli run publish:cli"
|
"cli:publish": "npm --prefix cli run publish:cli"
|
||||||
},
|
},
|
||||||
|
|
@ -23,10 +23,8 @@
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@next/third-parties": "^16.2.9",
|
"@next/third-parties": "^16.2.9",
|
||||||
"@node-saml/node-saml": "^5.1.0",
|
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"chalk": "^5.6.2",
|
|
||||||
"confbox": "^0.2.4",
|
"confbox": "^0.2.4",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
|
|
@ -39,7 +37,6 @@
|
||||||
"node-machine-id": "^1.1.12",
|
"node-machine-id": "^1.1.12",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
"ora": "^9.1.0",
|
"ora": "^9.1.0",
|
||||||
"prop-types": "^15.8.1",
|
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-is": "^16.13.1",
|
"react-is": "^16.13.1",
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,6 @@ export function copyStandaloneAssets({ projectRoot = process.cwd(), distDir = pr
|
||||||
cpSync(publicSource, publicDestination, { recursive: true, force: true });
|
cpSync(publicSource, publicDestination, { recursive: true, force: true });
|
||||||
console.log(`[standalone-assets] Copied public assets to ${publicDestination}`);
|
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")) {
|
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
|
? selectedApiKey
|
||||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||||
|
|
||||||
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n api_key: \${OPENAI_API_KEY}\n`;
|
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n`;
|
||||||
const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
|
const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ function getLocaleFromCookie() {
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { theme, setTheme, isDark } = useTheme();
|
const { theme, setTheme, isDark } = useTheme();
|
||||||
const [locale, setLocale] = useState(() => getLocaleFromCookie());
|
const [locale, setLocale] = useState("en");
|
||||||
const [langOpen, setLangOpen] = useState(false);
|
const [langOpen, setLangOpen] = useState(false);
|
||||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||||
const [isShuttingDown, setIsShuttingDown] = useState(false);
|
const [isShuttingDown, setIsShuttingDown] = useState(false);
|
||||||
|
|
@ -46,31 +46,8 @@ export default function ProfilePage() {
|
||||||
const [oidcLoading, setOidcLoading] = useState(false);
|
const [oidcLoading, setOidcLoading] = useState(false);
|
||||||
const [oidcTestLoading, setOidcTestLoading] = useState(false);
|
const [oidcTestLoading, setOidcTestLoading] = useState(false);
|
||||||
const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" });
|
const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" });
|
||||||
|
const [oidcRedirectUri, setOidcRedirectUri] = useState("/api/auth/oidc/callback");
|
||||||
const [oidcExpanded, setOidcExpanded] = useState(false);
|
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 importFileRef = useRef(null);
|
||||||
const [proxyForm, setProxyForm] = useState({
|
const [proxyForm, setProxyForm] = useState({
|
||||||
outboundProxyEnabled: false,
|
outboundProxyEnabled: false,
|
||||||
|
|
@ -81,6 +58,10 @@ export default function ProfilePage() {
|
||||||
const [proxyLoading, setProxyLoading] = useState(false);
|
const [proxyLoading, setProxyLoading] = useState(false);
|
||||||
const [proxyTestLoading, setProxyTestLoading] = useState(false);
|
const [proxyTestLoading, setProxyTestLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocale(getLocaleFromCookie());
|
||||||
|
}, [langOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/settings")
|
fetch("/api/settings")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
|
|
@ -94,23 +75,7 @@ export default function ProfilePage() {
|
||||||
oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC",
|
oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC",
|
||||||
});
|
});
|
||||||
setOidcClientSecret("");
|
setOidcClientSecret("");
|
||||||
setSsoTypeTab(data?.ssoType || "saml");
|
if (data?.authMode === "oidc" || data?.authMode === "both") setOidcExpanded(true);
|
||||||
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({
|
setProxyForm({
|
||||||
outboundProxyEnabled: data?.outboundProxyEnabled === true,
|
outboundProxyEnabled: data?.outboundProxyEnabled === true,
|
||||||
outboundProxyUrl: data?.outboundProxyUrl || "",
|
outboundProxyUrl: data?.outboundProxyUrl || "",
|
||||||
|
|
@ -124,6 +89,12 @@ export default function ProfilePage() {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
setOidcRedirectUri(`${window.location.origin}/api/auth/oidc/callback`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateOutboundProxy = async (e) => {
|
const updateOutboundProxy = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (settings.outboundProxyEnabled !== true) return;
|
if (settings.outboundProxyEnabled !== true) return;
|
||||||
|
|
@ -360,7 +331,6 @@ export default function ProfilePage() {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
authMode,
|
authMode,
|
||||||
ssoType: "oidc",
|
|
||||||
oidcIssuerUrl: issuerUrl,
|
oidcIssuerUrl: issuerUrl,
|
||||||
oidcClientId: clientId,
|
oidcClientId: clientId,
|
||||||
oidcScopes: scopes || "openid profile email",
|
oidcScopes: scopes || "openid profile email",
|
||||||
|
|
@ -475,159 +445,6 @@ export default function ProfilePage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSamlForm = (field, value) => {
|
|
||||||
setSamlForm((prev) => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleIdpMetadataUpload = (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (idpMetadataFileRef.current) idpMetadataFileRef.current.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
try {
|
|
||||||
const xmlText = e.target?.result || "";
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(xmlText, "text/xml");
|
|
||||||
const parserError = doc.querySelector("parsererror");
|
|
||||||
if (parserError) {
|
|
||||||
setSamlStatus({ type: "error", message: "Unable to parse valid SAML IdP metadata from XML file" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entityID = doc.documentElement.getAttribute("entityID") || "";
|
|
||||||
const ssoNodes = Array.from(doc.querySelectorAll("SingleSignOnService, *|SingleSignOnService"));
|
|
||||||
let ssoUrl = "";
|
|
||||||
for (const node of ssoNodes) {
|
|
||||||
const binding = node.getAttribute("Binding") || "";
|
|
||||||
const location = node.getAttribute("Location") || "";
|
|
||||||
if (location) {
|
|
||||||
ssoUrl = location;
|
|
||||||
if (binding.includes("HTTP-Redirect")) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const certNodes = Array.from(doc.querySelectorAll("X509Certificate, *|X509Certificate"));
|
|
||||||
let certStr = "";
|
|
||||||
if (certNodes.length > 0) {
|
|
||||||
certStr = certNodes[0].textContent.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
setSamlForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
samlEntryPoint: ssoUrl || prev.samlEntryPoint,
|
|
||||||
samlIssuer: prev.samlIssuer || "urn:9router:sp",
|
|
||||||
samlCert: certStr || prev.samlCert,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setSamlStatus({
|
|
||||||
type: "success",
|
|
||||||
message: `IdP Metadata imported! (SSO URL: ${ssoUrl ? "found" : "not found"}, EntityID: ${entityID ? "found" : "not found"}, Cert: ${certStr ? "found" : "not found"})`,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
setSamlStatus({ type: "error", message: "Error reading IdP Metadata XML file" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCertFileUpload = (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (certFileRef.current) certFileRef.current.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const text = e.target?.result || "";
|
|
||||||
setSamlForm((prev) => ({ ...prev, samlCert: text.trim() }));
|
|
||||||
setSamlStatus({ type: "success", message: "Certificate file loaded into configuration." });
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveSamlSettings = async (targetAuthMode = oidcForm.authMode || "password") => {
|
|
||||||
setSamlLoading(true);
|
|
||||||
setSamlStatus({ type: "", message: "" });
|
|
||||||
setSamlTestStatus({ type: "", message: "" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
authMode: targetAuthMode,
|
|
||||||
ssoType: "saml",
|
|
||||||
samlEntryPoint: samlForm.samlEntryPoint.trim(),
|
|
||||||
samlIssuer: samlForm.samlIssuer.trim() || "urn:9router:sp",
|
|
||||||
samlCert: samlForm.samlCert.trim(),
|
|
||||||
samlLoginLabel: samlForm.samlLoginLabel.trim() || "Sign in with SAML SSO",
|
|
||||||
samlAttributeEmail: samlForm.samlAttributeEmail.trim() || "email",
|
|
||||||
samlAttributeName: samlForm.samlAttributeName.trim() || "name",
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch("/api/settings", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok) {
|
|
||||||
setSettings((prev) => ({ ...prev, ...data }));
|
|
||||||
setSamlForm({
|
|
||||||
samlEntryPoint: data?.samlEntryPoint || payload.samlEntryPoint,
|
|
||||||
samlIssuer: data?.samlIssuer || payload.samlIssuer,
|
|
||||||
samlCert: data?.samlCert || payload.samlCert,
|
|
||||||
samlLoginLabel: data?.samlLoginLabel || payload.samlLoginLabel,
|
|
||||||
samlAttributeEmail: data?.samlAttributeEmail || payload.samlAttributeEmail,
|
|
||||||
samlAttributeName: data?.samlAttributeName || payload.samlAttributeName,
|
|
||||||
});
|
|
||||||
setSamlStatus({
|
|
||||||
type: "success",
|
|
||||||
message:
|
|
||||||
targetAuthMode === "sso" || targetAuthMode === "saml"
|
|
||||||
? "SAML SSO login enabled"
|
|
||||||
: targetAuthMode === "both"
|
|
||||||
? "Password and SAML SSO login enabled"
|
|
||||||
: "SAML 2.0 settings saved",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setSamlStatus({ type: "error", message: data.error || "Failed to save SAML settings" });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setSamlStatus({ type: "error", message: "An error occurred while saving SAML settings" });
|
|
||||||
} finally {
|
|
||||||
setSamlLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const testSamlConnection = async () => {
|
|
||||||
setSamlTestLoading(true);
|
|
||||||
setSamlStatus({ type: "", message: "" });
|
|
||||||
setSamlTestStatus({ type: "", message: "" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/auth/saml/test", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
samlEntryPoint: samlForm.samlEntryPoint.trim(),
|
|
||||||
samlIssuer: samlForm.samlIssuer.trim(),
|
|
||||||
samlCert: samlForm.samlCert.trim(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok && data.ok) {
|
|
||||||
setSamlTestStatus({ type: "success", message: data.message || "SAML configuration verified!" });
|
|
||||||
} else {
|
|
||||||
setSamlTestStatus({ type: "error", message: data.error || "SAML configuration test failed" });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setSamlTestStatus({ type: "error", message: "An error occurred while testing SAML configuration" });
|
|
||||||
} finally {
|
|
||||||
setSamlTestLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateObservabilityEnabled = async (enabled) => {
|
const updateObservabilityEnabled = async (enabled) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings", {
|
const res = await fetch("/api/settings", {
|
||||||
|
|
@ -935,7 +752,7 @@ export default function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Single Sign-On (SSO) */}
|
{/* OIDC */}
|
||||||
<Card>
|
<Card>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -946,13 +763,9 @@ export default function ProfilePage() {
|
||||||
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-base sm:text-lg font-semibold">Single Sign-On (SSO)</h3>
|
<h3 className="text-base sm:text-lg font-semibold">OIDC Dashboard Login</h3>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
{settings.authMode === "sso" || settings.authMode === "oidc" || settings.authMode === "saml"
|
{settings.authMode === "oidc" ? "OIDC active" : settings.authMode === "both" ? "Password + OIDC active" : "Optional SSO via Authentik/Keycloak/Google"}
|
||||||
? `${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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="material-symbols-outlined text-text-muted shrink-0">
|
<span className="material-symbols-outlined text-text-muted shrink-0">
|
||||||
|
|
@ -960,472 +773,145 @@ export default function ProfilePage() {
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{oidcExpanded && (
|
{oidcExpanded && (
|
||||||
<div className="flex flex-col gap-4 mt-4">
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
<p className="text-xs sm:text-sm text-text-muted">
|
<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.
|
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>
|
</p>
|
||||||
|
|
||||||
{/* SSO Protocol Switcher Tabs */}
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
|
||||||
<label className="font-medium text-sm sm:text-base">SSO Protocol</label>
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<div className="flex p-1 rounded-lg bg-black/5 dark:bg-white/5 border border-border">
|
{[
|
||||||
<button
|
{
|
||||||
type="button"
|
value: "password",
|
||||||
onClick={() => setSsoTypeTab("saml")}
|
title: "Password only",
|
||||||
className={cn(
|
desc: "Keep the legacy password login.",
|
||||||
"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"
|
value: "oidc",
|
||||||
: "text-text-muted hover:text-text-main"
|
title: "OIDC only",
|
||||||
)}
|
desc: "Require OIDC for dashboard access.",
|
||||||
>
|
},
|
||||||
SAML 2.0
|
{
|
||||||
</button>
|
value: "both",
|
||||||
<button
|
title: "Both",
|
||||||
type="button"
|
desc: "Allow either password or OIDC.",
|
||||||
onClick={() => setSsoTypeTab("oidc")}
|
},
|
||||||
className={cn(
|
].map((option) => {
|
||||||
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
|
const active = oidcForm.authMode === option.value;
|
||||||
ssoTypeTab === "oidc"
|
return (
|
||||||
? "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
|
<button
|
||||||
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowSamlGuide((prev) => !prev)}
|
onClick={() => updateOidcForm("authMode", option.value)}
|
||||||
className="w-full p-3 flex items-center justify-between gap-2 text-left hover:bg-surface/50 transition-colors"
|
className={cn(
|
||||||
|
"text-left rounded-lg border p-3 transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
)}
|
||||||
|
disabled={loading || oidcLoading}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<p className="font-medium text-sm sm:text-base">{option.title}</p>
|
||||||
<span className="material-symbols-outlined text-primary text-lg">menu_book</span>
|
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
|
||||||
<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>
|
</button>
|
||||||
|
);
|
||||||
{showSamlGuide && (
|
})}
|
||||||
<div className="p-4 border-t border-border bg-surface/30 text-xs text-text-main flex flex-col gap-3">
|
</div>
|
||||||
<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>
|
||||||
|
|
||||||
|
<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>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -529,7 +529,8 @@ export default function TokenSaverClient() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={headroomEnabled}
|
checked={headroomEnabled && headroomRunning}
|
||||||
|
disabled={!headroomRunning}
|
||||||
onChange={() => handleHeadroomEnabled(!headroomEnabled)}
|
onChange={() => handleHeadroomEnabled(!headroomEnabled)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ export default function ProviderLimits() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch quota for a specific connection
|
// Fetch quota for a specific connection
|
||||||
const fetchQuota = useCallback(async (connectionId, provider, { force = false } = {}) => {
|
const fetchQuota = useCallback(async (connectionId, provider) => {
|
||||||
setLoading((prev) => ({ ...prev, [connectionId]: true }));
|
setLoading((prev) => ({ ...prev, [connectionId]: true }));
|
||||||
setErrors((prev) => ({ ...prev, [connectionId]: null }));
|
setErrors((prev) => ({ ...prev, [connectionId]: null }));
|
||||||
|
|
||||||
|
|
@ -224,8 +224,7 @@ export default function ProviderLimits() {
|
||||||
console.log(
|
console.log(
|
||||||
`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`,
|
`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`,
|
||||||
);
|
);
|
||||||
const url = `/api/usage/${connectionId}${force ? "?force=1" : ""}`;
|
const response = await fetch(`/api/usage/${connectionId}`);
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
|
@ -296,7 +295,7 @@ export default function ProviderLimits() {
|
||||||
// Refresh quota for a specific provider
|
// Refresh quota for a specific provider
|
||||||
const refreshProvider = useCallback(
|
const refreshProvider = useCallback(
|
||||||
async (connectionId, provider) => {
|
async (connectionId, provider) => {
|
||||||
await fetchQuota(connectionId, provider, { force: true });
|
await fetchQuota(connectionId, provider);
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
},
|
},
|
||||||
[fetchQuota],
|
[fetchQuota],
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { getModelsByProviderId } from "open-sse/config/providerModels.js";
|
||||||
export const QUOTA_CACHE_KEY = "quotaCacheData";
|
export const QUOTA_CACHE_KEY = "quotaCacheData";
|
||||||
export const REFRESH_INTERVAL_MS = 60000;
|
export const REFRESH_INTERVAL_MS = 60000;
|
||||||
// Claude usage/quota endpoint rate-limits; poll it less often than other providers
|
// Claude usage/quota endpoint rate-limits; poll it less often than other providers
|
||||||
export const CLAUDE_REFRESH_INTERVAL_MS = 600000;
|
export const CLAUDE_REFRESH_INTERVAL_MS = 180000;
|
||||||
export const DEPLETED_QUOTA_THRESHOLD = 5;
|
export const DEPLETED_QUOTA_THRESHOLD = 5;
|
||||||
export const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
|
export const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
|
||||||
export const CONNECTIONS_PAGE_SIZE = 20;
|
export const CONNECTIONS_PAGE_SIZE = 20;
|
||||||
|
|
@ -36,17 +36,6 @@ export function getConnectionQuotaRemaining(connection, quotaData) {
|
||||||
return Number.POSITIVE_INFINITY;
|
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(
|
export function sortVisibleConnections(
|
||||||
connections,
|
connections,
|
||||||
quotaData,
|
quotaData,
|
||||||
|
|
@ -69,7 +58,7 @@ export function sortVisibleConnections(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!expiringFirst) return groupByProviderStable(connections);
|
if (!expiringFirst) return connections;
|
||||||
|
|
||||||
const getEarliestResetTime = (connection) => {
|
const getEarliestResetTime = (connection) => {
|
||||||
const resetTimes = (quotaData[connection.id]?.quotas || [])
|
const resetTimes = (quotaData[connection.id]?.quotas || [])
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import bcrypt from "bcryptjs";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
|
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
|
||||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||||
import { isSamlConfigured } from "@/lib/auth/saml.js";
|
|
||||||
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
|
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
|
||||||
import { isLocalRequest } from "@/dashboardGuard";
|
import { isLocalRequest } from "@/dashboardGuard";
|
||||||
|
|
||||||
|
|
@ -40,14 +39,8 @@ export async function POST(request) {
|
||||||
// Default password is '123456' if not set
|
// Default password is '123456' if not set
|
||||||
const storedHash = settings.password;
|
const storedHash = settings.password;
|
||||||
|
|
||||||
if (settings.authMode === "sso" || settings.authMode === "saml" || settings.authMode === "oidc") {
|
if (settings.authMode === "oidc" && isOidcConfigured(settings)) {
|
||||||
const ssoType = settings.ssoType || (settings.authMode === "saml" ? "saml" : "oidc");
|
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
|
||||||
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;
|
let isValid = false;
|
||||||
|
|
@ -61,36 +54,15 @@ export async function POST(request) {
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
recordSuccess(ip);
|
recordSuccess(ip);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
await setDashboardAuthCookie(cookieStore, request);
|
||||||
|
|
||||||
// Default password still in use on a remote client → force a password
|
// Default password still in use on a remote client → force a password
|
||||||
// change before the dashboard is exposed remotely (keeps local UX intact).
|
// change before the dashboard is exposed remotely (keeps local UX intact).
|
||||||
const mustChangePassword =
|
const mustChangePassword =
|
||||||
!storedHash && !process.env.INITIAL_PASSWORD && !isLocalRequest(request);
|
!storedHash && !process.env.INITIAL_PASSWORD && !isLocalRequest(request);
|
||||||
|
|
||||||
if (mustChangePassword) {
|
return NextResponse.json({ success: true, mustChangePassword }, { headers: NO_STORE_HEADERS });
|
||||||
// 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);
|
const { remainingBeforeLock } = recordFail(ip);
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { getSettings } from "@/lib/localDb";
|
|
||||||
import {
|
|
||||||
getSamlBaseUrl,
|
|
||||||
isSamlConfigured,
|
|
||||||
pickSamlDisplayName,
|
|
||||||
pickSamlEmail,
|
|
||||||
validateSamlResponse,
|
|
||||||
} from "@/lib/auth/saml.js";
|
|
||||||
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
|
|
||||||
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
|
|
||||||
|
|
||||||
export async function POST(request) {
|
|
||||||
const settings = await getSettings();
|
|
||||||
const origin = getSamlBaseUrl(request, settings);
|
|
||||||
const ip = getClientIp(request);
|
|
||||||
|
|
||||||
const lock = checkLock(ip);
|
|
||||||
if (lock.locked) {
|
|
||||||
return NextResponse.redirect(
|
|
||||||
new URL(
|
|
||||||
`/login?error=${encodeURIComponent(`Too many failed attempts. Try again in ${lock.retryAfter}s.`)}`,
|
|
||||||
origin
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const storedRequestId = cookieStore.get("saml_state")?.value || "";
|
|
||||||
|
|
||||||
// Always clear saml_state cookie after attempt
|
|
||||||
cookieStore.delete("saml_state");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const SAMLResponse = formData.get("SAMLResponse");
|
|
||||||
|
|
||||||
if (!SAMLResponse) {
|
|
||||||
recordFail(ip);
|
|
||||||
return NextResponse.redirect(new URL("/login?error=saml_missing_response", origin));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSamlConfigured(settings)) {
|
|
||||||
recordFail(ip);
|
|
||||||
return NextResponse.redirect(new URL("/login?error=saml_not_configured", origin));
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await validateSamlResponse(request, { SAMLResponse }, storedRequestId, settings);
|
|
||||||
|
|
||||||
const samlEmail = pickSamlEmail(profile, settings) || null;
|
|
||||||
const samlName = pickSamlDisplayName(profile, settings) || "SAML user";
|
|
||||||
|
|
||||||
recordSuccess(ip);
|
|
||||||
|
|
||||||
await setDashboardAuthCookie(cookieStore, request, {
|
|
||||||
saml: true,
|
|
||||||
samlEmail,
|
|
||||||
samlName,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.redirect(new URL("/dashboard", origin));
|
|
||||||
} catch (error) {
|
|
||||||
recordFail(ip);
|
|
||||||
return NextResponse.redirect(
|
|
||||||
new URL(`/login?error=${encodeURIComponent(error.message || "saml_acs_failed")}`, origin)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { getSettings } from "@/lib/localDb";
|
|
||||||
import { generateSamlMetadata } from "@/lib/auth/saml";
|
|
||||||
|
|
||||||
export async function GET(request) {
|
|
||||||
try {
|
|
||||||
const settings = await getSettings();
|
|
||||||
const origin = new URL(request.url).origin;
|
|
||||||
const metadataXml = generateSamlMetadata(origin, settings);
|
|
||||||
|
|
||||||
return new Response(metadataXml, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/xml",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return new Response(`<?xml version="1.0"?><Error>${error.message || "Failed to generate metadata"}</Error>`, {
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/xml",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { getSettings } from "@/lib/localDb";
|
|
||||||
import { buildSamlAuthorizeUrl, getSamlBaseUrl, isSamlConfigured } from "@/lib/auth/saml.js";
|
|
||||||
import { shouldUseSecureCookie } from "@/lib/auth/dashboardSession";
|
|
||||||
|
|
||||||
export async function GET(request) {
|
|
||||||
const settings = await getSettings();
|
|
||||||
const origin = getSamlBaseUrl(request, settings);
|
|
||||||
try {
|
|
||||||
if (!isSamlConfigured(settings)) {
|
|
||||||
return NextResponse.redirect(new URL("/login?error=saml_not_configured", origin));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { authorizeUrl, requestId } = await buildSamlAuthorizeUrl(request, settings);
|
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.set("saml_state", requestId, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: shouldUseSecureCookie(request),
|
|
||||||
sameSite: "lax",
|
|
||||||
path: "/",
|
|
||||||
maxAge: 10 * 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.redirect(authorizeUrl);
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.redirect(
|
|
||||||
new URL(`/login?error=${encodeURIComponent(error.message || "saml_start_failed")}`, origin)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { getSettings } from "@/lib/localDb";
|
|
||||||
import { formatX509Certificate } from "@/lib/auth/saml.js";
|
|
||||||
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
|
|
||||||
|
|
||||||
async function canAccessTestRoute() {
|
|
||||||
const settings = await getSettings();
|
|
||||||
if (settings.requireLogin === false) return true;
|
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const token = cookieStore.get("auth_token")?.value;
|
|
||||||
return await verifyDashboardAuthToken(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request) {
|
|
||||||
try {
|
|
||||||
if (!(await canAccessTestRoute())) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
|
||||||
const settings = await getSettings();
|
|
||||||
|
|
||||||
const samlEntryPoint = String(body.samlEntryPoint || settings.samlEntryPoint || "").trim();
|
|
||||||
const samlIssuer = String(body.samlIssuer || settings.samlIssuer || "urn:9router:sp").trim();
|
|
||||||
const samlCert = String(
|
|
||||||
Object.prototype.hasOwnProperty.call(body, "samlCert")
|
|
||||||
? body.samlCert
|
|
||||||
: settings.samlCert || ""
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
if (!samlEntryPoint) {
|
|
||||||
return NextResponse.json({ error: "Single Sign-On Service URL (samlEntryPoint) is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
new URL(samlEntryPoint);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Single Sign-On Service URL must be a valid URL" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!samlIssuer) {
|
|
||||||
return NextResponse.json({ error: "SP Entity ID / Issuer (samlIssuer) is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!samlCert) {
|
|
||||||
return NextResponse.json({ error: "IdP X.509 Certificate (samlCert) is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedCert = formatX509Certificate(samlCert);
|
|
||||||
if (!formattedCert) {
|
|
||||||
return NextResponse.json({ error: "Invalid IdP X.509 Certificate format" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const origin = new URL(request.url).origin;
|
|
||||||
const acsUrl = `${origin}/api/auth/saml/acs`;
|
|
||||||
const metadataUrl = `${origin}/api/auth/saml/metadata`;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
samlEntryPoint,
|
|
||||||
samlIssuer,
|
|
||||||
certValid: true,
|
|
||||||
acsUrl,
|
|
||||||
metadataUrl,
|
|
||||||
message: "SAML 2.0 configuration verified successfully.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json({ error: error.message || "SAML test failed" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getSettings } from "@/lib/localDb";
|
import { getSettings } from "@/lib/localDb";
|
||||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||||
import { isSamlConfigured } from "@/lib/auth/saml.js";
|
|
||||||
import { getDashboardAuthSession } from "@/lib/auth/dashboardSession";
|
import { getDashboardAuthSession } from "@/lib/auth/dashboardSession";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
|
@ -12,29 +11,16 @@ export async function GET() {
|
||||||
const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value);
|
const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value);
|
||||||
const requireLogin = settings.requireLogin !== false;
|
const requireLogin = settings.requireLogin !== false;
|
||||||
const authMode = settings.authMode || "password";
|
const authMode = settings.authMode || "password";
|
||||||
const ssoType = settings.ssoType || "oidc";
|
|
||||||
const oidcName = String(session?.oidcName || "").trim();
|
const oidcName = String(session?.oidcName || "").trim();
|
||||||
const oidcEmail = String(session?.oidcEmail || "").trim();
|
const oidcEmail = String(session?.oidcEmail || "").trim();
|
||||||
const samlName = String(session?.samlName || "").trim();
|
const displayName = oidcName || oidcEmail || (session?.oidc ? "OIDC user" : "Password user");
|
||||||
const samlEmail = String(session?.samlEmail || "").trim();
|
const loginMethod = session?.oidc ? "OIDC" : "Password";
|
||||||
|
|
||||||
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({
|
return NextResponse.json({
|
||||||
requireLogin,
|
requireLogin,
|
||||||
authMode,
|
authMode,
|
||||||
ssoType,
|
|
||||||
oidcConfigured: isOidcConfigured(settings),
|
oidcConfigured: isOidcConfigured(settings),
|
||||||
oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC",
|
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,
|
hasPassword: !!settings.password,
|
||||||
displayName,
|
displayName,
|
||||||
loginMethod,
|
loginMethod,
|
||||||
|
|
@ -42,19 +28,13 @@ export async function GET() {
|
||||||
oidcName: oidcName || null,
|
oidcName: oidcName || null,
|
||||||
oidcEmail: oidcEmail || null,
|
oidcEmail: oidcEmail || null,
|
||||||
oidcLogin: !!session?.oidc,
|
oidcLogin: !!session?.oidc,
|
||||||
samlName: samlName || null,
|
|
||||||
samlEmail: samlEmail || null,
|
|
||||||
samlLogin: !!session?.saml,
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
authMode: "password",
|
authMode: "password",
|
||||||
ssoType: "oidc",
|
|
||||||
oidcConfigured: false,
|
oidcConfigured: false,
|
||||||
oidcLoginLabel: "Sign in with OIDC",
|
oidcLoginLabel: "Sign in with OIDC",
|
||||||
samlConfigured: false,
|
|
||||||
samlLoginLabel: "Sign in with SAML SSO",
|
|
||||||
hasPassword: false,
|
hasPassword: false,
|
||||||
displayName: "Password user",
|
displayName: "Password user",
|
||||||
loginMethod: "Password",
|
loginMethod: "Password",
|
||||||
|
|
@ -62,9 +42,6 @@ export async function GET() {
|
||||||
oidcName: null,
|
oidcName: null,
|
||||||
oidcEmail: null,
|
oidcEmail: null,
|
||||||
oidcLogin: false,
|
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 MODEL_BLOCK_RE = /^model:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m;
|
||||||
|
|
||||||
const buildModelBlock = (model, baseUrl) =>
|
const buildModelBlock = (model, baseUrl) =>
|
||||||
`model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`;
|
`model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n`;
|
||||||
|
|
||||||
// Parse current model block back to fields (best-effort, simple key:value)
|
// Parse current model block back to fields (best-effort, simple key:value)
|
||||||
const parseModelBlock = (yaml) => {
|
const parseModelBlock = (yaml) => {
|
||||||
|
|
@ -35,7 +35,6 @@ const parseModelBlock = (yaml) => {
|
||||||
default: get("default"),
|
default: get("default"),
|
||||||
provider: get("provider"),
|
provider: get("provider"),
|
||||||
base_url: get("base_url"),
|
base_url: get("base_url"),
|
||||||
api_key: get("api_key"),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,11 +135,9 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
// 1024 tokens: reasoning models (ClinePass/kimi-k3, deepseek-v4-pro, etc.) spend
|
// Claude-on-Copilot returns empty choices at max_tokens:1 (budget is spent
|
||||||
// their budget on chain-of-thought before emitting an answer. A tiny probe like
|
// before a content token emits), so a 1-token probe yields a false negative.
|
||||||
// max_tokens:16 starves the answer and yields a false "no choices" failure.
|
max_tokens: 16,
|
||||||
// See issue #3010.
|
|
||||||
max_tokens: 1024,
|
|
||||||
stream: false,
|
stream: false,
|
||||||
messages: [{ role: "user", content: "hi" }],
|
messages: [{ role: "user", content: "hi" }],
|
||||||
}),
|
}),
|
||||||
|
|
@ -182,21 +180,6 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasChoices = Array.isArray(parsed?.choices) && parsed.choices.length > 0;
|
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) {
|
if (!hasChoices) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
|
||||||
|
|
@ -788,27 +788,6 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
|
||||||
);
|
);
|
||||||
return { valid: exRes.ok, error: exRes.ok ? null : "Invalid Personal Access Token" };
|
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:
|
default:
|
||||||
return { valid: false, error: "Provider test not supported" };
|
return { valid: false, error: "Provider test not supported" };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,6 @@ export async function GET(request, { params }) {
|
||||||
let connection;
|
let connection;
|
||||||
try {
|
try {
|
||||||
const { connectionId } = await params;
|
const { connectionId } = await params;
|
||||||
const force = new URL(request.url).searchParams.get("force") === "1";
|
|
||||||
|
|
||||||
|
|
||||||
// Get connection from database
|
// Get connection from database
|
||||||
|
|
@ -169,7 +168,7 @@ export async function GET(request, { params }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch usage from provider API
|
// Fetch usage from provider API
|
||||||
let usage = await getUsageForProvider(connection, proxyOptions, { force });
|
let usage = await getUsageForProvider(connection, proxyOptions);
|
||||||
|
|
||||||
// If provider returned an auth-expired message instead of throwing,
|
// If provider returned an auth-expired message instead of throwing,
|
||||||
// force-refresh token and retry once (OAuth only)
|
// force-refresh token and retry once (OAuth only)
|
||||||
|
|
@ -177,7 +176,7 @@ export async function GET(request, { params }) {
|
||||||
try {
|
try {
|
||||||
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
|
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
|
||||||
connection = retryResult.connection;
|
connection = retryResult.connection;
|
||||||
usage = await getUsageForProvider(connection, proxyOptions, { force });
|
usage = await getUsageForProvider(connection, proxyOptions);
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
console.warn(`[Usage] ${connection.provider}: force refresh failed: ${retryError.message}`);
|
console.warn(`[Usage] ${connection.provider}: force refresh failed: ${retryError.message}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,23 +47,8 @@ export async function GET(request) {
|
||||||
if (endDate) filter.endDate = endDate;
|
if (endDate) filter.endDate = endDate;
|
||||||
|
|
||||||
const result = await getRequestDetails(filter);
|
const result = await getRequestDetails(filter);
|
||||||
|
|
||||||
// Redact conversation payloads: the stored details include full request
|
return NextResponse.json(result);
|
||||||
// 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) {
|
} catch (error) {
|
||||||
console.error("[API] Failed to get request details:", error);
|
console.error("[API] Failed to get request details:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
|
||||||
|
|
@ -485,27 +485,6 @@ export async function buildModelsList(kindFilter, options = {}) {
|
||||||
|| capabilitiesFromServiceKind(customKind || liveKind)
|
|| capabilitiesFromServiceKind(customKind || liveKind)
|
||||||
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null);
|
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null);
|
||||||
if (caps) model.capabilities = caps;
|
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);
|
models.push(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,8 @@ export default function LoginPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasPassword, setHasPassword] = useState(null);
|
const [hasPassword, setHasPassword] = useState(null);
|
||||||
const [authMode, setAuthMode] = useState("password");
|
const [authMode, setAuthMode] = useState("password");
|
||||||
const [ssoType, setSsoType] = useState("oidc");
|
|
||||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||||
const [oidcLoginLabel, setOidcLoginLabel] = useState("Sign in with OIDC");
|
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 [mustChange, setMustChange] = useState(false);
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
|
||||||
|
|
@ -46,11 +43,8 @@ export default function LoginPage() {
|
||||||
}
|
}
|
||||||
setHasPassword(!!data.hasPassword);
|
setHasPassword(!!data.hasPassword);
|
||||||
setAuthMode(data.authMode || "password");
|
setAuthMode(data.authMode || "password");
|
||||||
setSsoType(data.ssoType || "oidc");
|
|
||||||
setOidcConfigured(data.oidcConfigured === true);
|
setOidcConfigured(data.oidcConfigured === true);
|
||||||
setOidcLoginLabel(data.oidcLoginLabel || "Sign in with OIDC");
|
setOidcLoginLabel(data.oidcLoginLabel || "Sign in with OIDC");
|
||||||
setSamlConfigured(data.samlConfigured === true);
|
|
||||||
setSamlLoginLabel(data.samlLoginLabel || "Sign in with SAML SSO");
|
|
||||||
} else {
|
} else {
|
||||||
// Safe fallback on non-OK response to avoid infinite loading state.
|
// Safe fallback on non-OK response to avoid infinite loading state.
|
||||||
setHasPassword(true);
|
setHasPassword(true);
|
||||||
|
|
@ -124,18 +118,8 @@ export default function LoginPage() {
|
||||||
window.location.href = "/api/auth/oidc/start";
|
window.location.href = "/api/auth/oidc/start";
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSamlLogin = () => {
|
const oidcAvailable = oidcConfigured && ["oidc", "both"].includes(authMode);
|
||||||
window.location.href = "/api/auth/saml/start";
|
const passwordAvailable = authMode !== "oidc" || !oidcConfigured;
|
||||||
};
|
|
||||||
|
|
||||||
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
|
// Show loading state while checking password
|
||||||
if (hasPassword === null) {
|
if (hasPassword === null) {
|
||||||
|
|
@ -157,9 +141,7 @@ export default function LoginPage() {
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold text-primary mb-2">9Router</h1>
|
<h1 className="text-3xl font-bold text-primary mb-2">9Router</h1>
|
||||||
<p className="text-text-muted">
|
<p className="text-text-muted">
|
||||||
{samlAvailable
|
{authMode === "oidc" && oidcConfigured
|
||||||
? "Sign in with SAML 2.0 Single Sign-On"
|
|
||||||
: oidcAvailable
|
|
||||||
? "Sign in with your OIDC provider to access the dashboard"
|
? "Sign in with your OIDC provider to access the dashboard"
|
||||||
: "Enter your password to access the dashboard"}
|
: "Enter your password to access the dashboard"}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -189,31 +171,25 @@ export default function LoginPage() {
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{samlAvailable && (
|
|
||||||
<Button type="button" variant="primary" className="w-full" onClick={handleSamlLogin}>
|
|
||||||
{samlLoginLabel}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{oidcAvailable && (
|
{oidcAvailable && (
|
||||||
<Button type="button" variant="primary" className="w-full" onClick={handleOidcLogin}>
|
<Button type="button" variant="primary" className="w-full" onClick={handleOidcLogin}>
|
||||||
{oidcLoginLabel}
|
{oidcLoginLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ssoAvailable && passwordAvailable && <div className="h-px bg-border/60" />}
|
{oidcAvailable && passwordAvailable && <div className="h-px bg-border/60" />}
|
||||||
|
|
||||||
{passwordAvailable ? (
|
{passwordAvailable ? (
|
||||||
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||||
{isSsoEnabled && !ssoAvailable && (
|
{((authMode === "oidc" && !oidcConfigured) || (authMode === "both" && !oidcConfigured)) && (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400 text-center">
|
<p className="text-xs text-amber-600 dark:text-amber-400 text-center">
|
||||||
{activeSsoType === "saml" ? "SAML SSO" : "OIDC"} login is enabled, but configuration is incomplete. Password login is still available for recovery.
|
OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authMode === "both" && ssoAvailable && (
|
{authMode === "both" && oidcConfigured && (
|
||||||
<p className="text-xs text-text-muted text-center">
|
<p className="text-xs text-text-muted text-center">
|
||||||
Password and {activeSsoType === "saml" ? "SAML SSO" : "OIDC"} login are both enabled.
|
Password and OIDC login are both enabled.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
|
||||||
import { getSettings, validateApiKey } from "@/lib/localDb";
|
import { getSettings, validateApiKey } from "@/lib/localDb";
|
||||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||||
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
|
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
|
||||||
import { hasTrustedPeerHeaders } from "@/lib/auth/trustedPeer";
|
|
||||||
|
|
||||||
const CLI_TOKEN_HEADER = "x-9r-cli-token";
|
const CLI_TOKEN_HEADER = "x-9r-cli-token";
|
||||||
const CLI_TOKEN_SALT = "9r-cli-auth";
|
const CLI_TOKEN_SALT = "9r-cli-auth";
|
||||||
|
|
@ -28,7 +27,6 @@ const PUBLIC_API_PATHS = [
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
"/api/auth/status",
|
"/api/auth/status",
|
||||||
"/api/auth/oidc",
|
"/api/auth/oidc",
|
||||||
"/api/auth/saml",
|
|
||||||
"/api/version",
|
"/api/version",
|
||||||
"/api/settings/require-login",
|
"/api/settings/require-login",
|
||||||
];
|
];
|
||||||
|
|
@ -88,40 +86,24 @@ const LOCAL_ONLY_PATHS = [
|
||||||
|
|
||||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
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) {
|
function isLoopbackHostname(h) {
|
||||||
if (!h) return false;
|
if (!h) return false;
|
||||||
let name = String(h).trim().toLowerCase();
|
const name = h.split(":")[0].replace(/^\[|\]$/g, "").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);
|
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) {
|
export function isLocalRequest(request) {
|
||||||
// Stamped by custom-server.js when forwarding headers exist: request came through
|
// 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.
|
// a reverse proxy, so the loopback socket is the proxy hop, not the end-user.
|
||||||
if (request.headers.get("x-9r-via-proxy")) return false;
|
if (request.headers.get("x-9r-via-proxy")) return false;
|
||||||
if (!isLoopbackPeer(request)) return false;
|
// Trusted peer IP from TCP socket (custom-server.js); unspoofable. Primary anchor for "local".
|
||||||
|
const realIp = request.headers.get("x-9r-real-ip");
|
||||||
|
if (realIp) {
|
||||||
|
if (!isLoopbackHostname(realIp)) return false;
|
||||||
|
} else if (!isLoopbackHostname(request.headers.get("host"))) {
|
||||||
|
// Fallback for bare server.js (dev) without custom-server: legacy Host-based check.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const origin = request.headers.get("origin");
|
const origin = request.headers.get("origin");
|
||||||
if (origin) {
|
if (origin) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
// In-memory progressive lockout for dashboard login. Resets on process restart.
|
// In-memory progressive lockout for dashboard login. Resets on process restart.
|
||||||
import { hasTrustedPeerHeaders } from "./trustedPeer.js";
|
|
||||||
|
|
||||||
const MAX_FAILS_BEFORE_LOCK = 5;
|
const MAX_FAILS_BEFORE_LOCK = 5;
|
||||||
const LOCK_STEPS_MS = [30_000, 120_000, 600_000, 1_800_000]; // 30s, 2m, 10m, 30m
|
const LOCK_STEPS_MS = [30_000, 120_000, 600_000, 1_800_000]; // 30s, 2m, 10m, 30m
|
||||||
|
|
@ -47,12 +46,9 @@ export function recordSuccess(ip) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClientIp(request) {
|
export function getClientIp(request) {
|
||||||
// Trusted only when custom-server.js proves it stamped the header from the TCP socket;
|
// Trusted: set from TCP socket by custom-server.js (client cannot spoof).
|
||||||
// otherwise a client could rotate the value to escape its own lockout bucket.
|
const realIp = request.headers.get("x-9r-real-ip");
|
||||||
if (hasTrustedPeerHeaders(request)) {
|
if (realIp) return realIp;
|
||||||
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.
|
// Behind a trusted reverse proxy that overwrites XFF with the real client IP.
|
||||||
if (process.env.TRUST_PROXY === "true") {
|
if (process.env.TRUST_PROXY === "true") {
|
||||||
const xff = request.headers.get("x-forwarded-for");
|
const xff = request.headers.get("x-forwarded-for");
|
||||||
|
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
import { SAML } from "@node-saml/node-saml";
|
|
||||||
import { getSettings } from "../db/repos/settingsRepo.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a raw Base64 string or unformatted X.509 certificate into standard PEM format.
|
|
||||||
* @param {string} certStr
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function formatX509Certificate(certStr) {
|
|
||||||
if (!certStr || typeof certStr !== "string") return "";
|
|
||||||
const clean = certStr
|
|
||||||
.replace(/-----BEGIN CERTIFICATE-----/gi, "")
|
|
||||||
.replace(/-----END CERTIFICATE-----/gi, "")
|
|
||||||
.replace(/[^A-Za-z0-9+/=]/g, "");
|
|
||||||
|
|
||||||
if (!clean) return "";
|
|
||||||
|
|
||||||
const lines = clean.match(/.{1,64}/g) || [];
|
|
||||||
return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether SAML configuration has essential parameters (entryPoint & cert).
|
|
||||||
* @param {object} settings
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function isSamlConfigured(settings) {
|
|
||||||
return Boolean(settings?.samlEntryPoint && settings?.samlCert);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches settings and returns runtime status + settings.
|
|
||||||
* @returns {Promise<{ configured: boolean, settings: object }>}
|
|
||||||
*/
|
|
||||||
export async function getSamlRuntimeConfig() {
|
|
||||||
const settings = await getSettings();
|
|
||||||
return {
|
|
||||||
configured: isSamlConfigured(settings),
|
|
||||||
settings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a configured `@node-saml/node-saml` SAML instance with security defaults.
|
|
||||||
* @param {object} settings
|
|
||||||
* @param {string} origin
|
|
||||||
* @returns {SAML}
|
|
||||||
*/
|
|
||||||
const DUMMY_FALLBACK_CERT =
|
|
||||||
"-----BEGIN CERTIFICATE-----\nMIIC...DUMMY...\n-----END CERTIFICATE-----";
|
|
||||||
|
|
||||||
function trimTrailingSlashes(str) {
|
|
||||||
return (str || "").replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the public Base URL / Origin for SAML requests.
|
|
||||||
* Respects settings.baseUrl, process.env.BASE_URL, x-forwarded-proto, and x-forwarded-host.
|
|
||||||
* @param {Request} request
|
|
||||||
* @param {object} settings
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function getSamlBaseUrl(request, settings) {
|
|
||||||
const configuredBaseUrl =
|
|
||||||
(settings?.baseUrl || "").trim() ||
|
|
||||||
process.env.BASE_URL ||
|
|
||||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
|
||||||
"";
|
|
||||||
|
|
||||||
if (configuredBaseUrl) {
|
|
||||||
return trimTrailingSlashes(configuredBaseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request) {
|
|
||||||
const forwardedProto = request?.headers?.get?.("x-forwarded-proto") || "";
|
|
||||||
const forwardedHost = request?.headers?.get?.("x-forwarded-host") || "";
|
|
||||||
const host = forwardedHost || request?.headers?.get?.("host") || "";
|
|
||||||
if (host) {
|
|
||||||
const protocol = (forwardedProto || new URL(request.url).protocol || "http:").replace(/:$/, "");
|
|
||||||
return `${protocol}://${host}`.replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
if (request.url) {
|
|
||||||
return trimTrailingSlashes(new URL(request.url).origin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "http://localhost:20128";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSamlInstance(settings, origin) {
|
|
||||||
const cert = formatX509Certificate(settings?.samlCert || "") || DUMMY_FALLBACK_CERT;
|
|
||||||
const callbackUrl = `${origin}/api/auth/saml/acs`;
|
|
||||||
return new SAML({
|
|
||||||
entryPoint: settings?.samlEntryPoint || "https://example.com/sso",
|
|
||||||
issuer: settings?.samlIssuer || "urn:9router:sp",
|
|
||||||
idpCert: cert,
|
|
||||||
cert: cert,
|
|
||||||
callbackUrl: callbackUrl,
|
|
||||||
acceptedClockSkewMs: 60000,
|
|
||||||
wantAssertionsSigned: true,
|
|
||||||
validateInResponseTo: "never",
|
|
||||||
requestIdExpirationMs: 28800000, // 8 hours
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds SAML AuthnRequest redirect URL and returns { authorizeUrl, requestId }.
|
|
||||||
* @param {Request} request
|
|
||||||
* @param {object} settings
|
|
||||||
* @returns {Promise<{ authorizeUrl: string, requestId: string }>}
|
|
||||||
*/
|
|
||||||
export async function buildSamlAuthorizeUrl(request, settings) {
|
|
||||||
const origin = getSamlBaseUrl(request, settings);
|
|
||||||
const samlInstance = createSamlInstance(settings, origin);
|
|
||||||
|
|
||||||
const xml = await samlInstance.generateAuthorizeRequestAsync(false, false);
|
|
||||||
const match = xml.match(/ID="([^"]+)"/);
|
|
||||||
const requestId = match ? match[1] : "";
|
|
||||||
|
|
||||||
const authorizeUrl = await samlInstance._requestToUrlAsync(xml, null, "authorize", {});
|
|
||||||
|
|
||||||
return { authorizeUrl, requestId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates SAML POST response from IdP ACS callback and returns user profile.
|
|
||||||
* @param {Request} request
|
|
||||||
* @param {object} body - Parsed form body or object containing SAMLResponse
|
|
||||||
* @param {string} expectedRequestId - Request ID stored in saml_state cookie
|
|
||||||
* @param {object} settings
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function validateSamlResponse(request, body, expectedRequestId, settings) {
|
|
||||||
if (!settings?.samlCert) {
|
|
||||||
throw new Error("IdP X.509 Certificate (samlCert) is missing or not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
const origin = getSamlBaseUrl(request, settings);
|
|
||||||
const samlInstance = createSamlInstance(settings, origin);
|
|
||||||
|
|
||||||
const container = typeof body === "object" && body !== null ? body : { SAMLResponse: body };
|
|
||||||
const rawSamlResponse = container.SAMLResponse;
|
|
||||||
|
|
||||||
if (!rawSamlResponse) {
|
|
||||||
throw new Error("Missing SAMLResponse parameter in assertion POST body");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse response XML to inspect InResponseTo for replay protection
|
|
||||||
if (expectedRequestId) {
|
|
||||||
const xml = Buffer.from(rawSamlResponse, "base64").toString("utf8");
|
|
||||||
const match = xml.match(/InResponseTo=["']([^"']+)["']/i);
|
|
||||||
const inResponseTo = match ? match[1] : null;
|
|
||||||
|
|
||||||
if (!inResponseTo || inResponseTo !== expectedRequestId) {
|
|
||||||
throw new Error(`InResponseTo mismatch: expected ${expectedRequestId}, received ${inResponseTo || "none"}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await samlInstance.validatePostResponseAsync({ SAMLResponse: rawSamlResponse });
|
|
||||||
const profile = result?.profile || result;
|
|
||||||
|
|
||||||
return profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates standard SP XML Metadata.
|
|
||||||
* @param {string} origin
|
|
||||||
* @param {object} settings
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function generateSamlMetadata(origin, settings) {
|
|
||||||
const samlInstance = createSamlInstance(settings, origin);
|
|
||||||
return samlInstance.generateServiceProviderMetadata();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts email claim from SAML profile assertion.
|
|
||||||
* @param {object} profile
|
|
||||||
* @param {object} settings
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function pickSamlEmail(profile = {}, settings = {}) {
|
|
||||||
if (!profile) return "";
|
|
||||||
|
|
||||||
// 1. Configured custom attribute
|
|
||||||
const customAttr = settings.samlAttributeEmail;
|
|
||||||
if (customAttr && profile[customAttr]) {
|
|
||||||
const val = profile[customAttr];
|
|
||||||
return Array.isArray(val) ? val[0] : String(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Common email claims
|
|
||||||
const emailKeys = [
|
|
||||||
"email",
|
|
||||||
"emailAddress",
|
|
||||||
"mail",
|
|
||||||
"nameID",
|
|
||||||
"nameId",
|
|
||||||
"upn",
|
|
||||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
|
||||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
|
|
||||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const key of emailKeys) {
|
|
||||||
if (profile[key]) {
|
|
||||||
const val = profile[key];
|
|
||||||
return Array.isArray(val) ? val[0] : String(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fallback: check attributes object if present
|
|
||||||
if (profile.attributes) {
|
|
||||||
for (const key of emailKeys) {
|
|
||||||
if (profile.attributes[key]) {
|
|
||||||
const val = profile.attributes[key];
|
|
||||||
return Array.isArray(val) ? val[0] : String(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts display name claim from SAML profile assertion.
|
|
||||||
* @param {object} profile
|
|
||||||
* @param {object} settings
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function pickSamlDisplayName(profile = {}, settings = {}) {
|
|
||||||
if (!profile) return "";
|
|
||||||
|
|
||||||
// 1. Configured custom attribute
|
|
||||||
const customAttr = settings.samlAttributeName;
|
|
||||||
if (customAttr && profile[customAttr]) {
|
|
||||||
const val = profile[customAttr];
|
|
||||||
return Array.isArray(val) ? val[0] : String(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Common name claims
|
|
||||||
const nameKeys = [
|
|
||||||
"displayName",
|
|
||||||
"name",
|
|
||||||
"cn",
|
|
||||||
"commonName",
|
|
||||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
|
||||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const key of nameKeys) {
|
|
||||||
if (profile[key]) {
|
|
||||||
const val = profile[key];
|
|
||||||
return Array.isArray(val) ? val[0] : String(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Combined givenName + surname
|
|
||||||
if (profile.givenName || profile.sn || profile.surname) {
|
|
||||||
const given = profile.givenName || "";
|
|
||||||
const surname = profile.sn || profile.surname || "";
|
|
||||||
const combined = `${given} ${surname}`.trim();
|
|
||||||
if (combined) return combined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Fallback to email
|
|
||||||
return pickSamlEmail(profile, settings);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// x-9r-real-ip is only trustworthy when custom-server.js stamped it from the TCP socket.
|
|
||||||
// It proves that by echoing the per-process secret it generated at boot, which a client
|
|
||||||
// cannot guess. Without the proof the header is just attacker-supplied input.
|
|
||||||
export function hasTrustedPeerHeaders(request) {
|
|
||||||
const token = process.env.NINEROUTER_PEER_TOKEN;
|
|
||||||
return Boolean(token) && request.headers.get("x-9r-peer-token") === token;
|
|
||||||
}
|
|
||||||
|
|
@ -189,13 +189,14 @@ export async function createProviderConnection(data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Critical: OAuth refresh token race — atomic merge inside transaction
|
// Critical: OAuth refresh token race — atomic merge inside transaction
|
||||||
export async function updateProviderConnection(id, data) {
|
export async function updateProviderConnection(id, update) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
let result;
|
let result;
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
const row = db.get(`SELECT * FROM providerConnections WHERE id = ?`, [id]);
|
const row = db.get(`SELECT * FROM providerConnections WHERE id = ?`, [id]);
|
||||||
if (!row) { result = null; return; }
|
if (!row) { result = null; return; }
|
||||||
const existing = rowToConn(row);
|
const existing = rowToConn(row);
|
||||||
|
const data = typeof update === "function" ? update(existing) : update;
|
||||||
const merged = { ...existing, ...data, updatedAt: new Date().toISOString() };
|
const merged = { ...existing, ...data, updatedAt: new Date().toISOString() };
|
||||||
upsert(db, merged);
|
upsert(db, merged);
|
||||||
if (data.priority !== undefined) reorderInTx(db, existing.provider);
|
if (data.priority !== undefined) reorderInTx(db, existing.provider);
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,6 @@ function sanitizeHeaders(headers) {
|
||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const __test__ = { sanitizeHeaders };
|
|
||||||
|
|
||||||
function generateDetailId(model) {
|
function generateDetailId(model) {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const random = Math.random().toString(36).substring(2, 8);
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,11 @@ const DEFAULT_SETTINGS = {
|
||||||
requireApiKey: true,
|
requireApiKey: true,
|
||||||
tunnelDashboardAccess: true,
|
tunnelDashboardAccess: true,
|
||||||
authMode: "password",
|
authMode: "password",
|
||||||
ssoType: "oidc",
|
|
||||||
oidcIssuerUrl: "",
|
oidcIssuerUrl: "",
|
||||||
oidcClientId: "",
|
oidcClientId: "",
|
||||||
oidcClientSecret: "",
|
oidcClientSecret: "",
|
||||||
oidcScopes: "openid profile email",
|
oidcScopes: "openid profile email",
|
||||||
oidcLoginLabel: "Sign in with OIDC",
|
oidcLoginLabel: "Sign in with OIDC",
|
||||||
samlEntryPoint: "",
|
|
||||||
samlIssuer: "urn:9router:sp",
|
|
||||||
samlCert: "",
|
|
||||||
samlLoginLabel: "Sign in with SAML SSO",
|
|
||||||
samlAttributeEmail: "email",
|
|
||||||
samlAttributeName: "name",
|
|
||||||
enableObservability: false,
|
enableObservability: false,
|
||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
observabilityBatchSize: 20,
|
observabilityBatchSize: 20,
|
||||||
|
|
@ -70,7 +63,7 @@ async function readRaw() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge raw settings with defaults; backward-compat for missing keys
|
// Merge raw settings with defaults; backward-compat for missing keys
|
||||||
export function mergeWithDefaults(raw) {
|
function mergeWithDefaults(raw) {
|
||||||
const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
|
const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
|
||||||
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
|
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
|
||||||
if (merged[key] === undefined) {
|
if (merged[key] === undefined) {
|
||||||
|
|
|
||||||
|
|
@ -26,28 +26,10 @@ const TARGET_HOSTS = [
|
||||||
const URL_PATTERNS = {
|
const URL_PATTERNS = {
|
||||||
antigravity: [":generateContent", ":streamGenerateContent"],
|
antigravity: [":generateContent", ":streamGenerateContent"],
|
||||||
copilot: ["/chat/completions", "/v1/messages", "/responses"],
|
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"],
|
kiro: ["/generateAssistantResponse"],
|
||||||
cursor: ["/BidiAppend", "/RunSSE", "/RunPoll", "/Run"],
|
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
|
// Synonym map: rawModel from request → canonical alias key in mitmAlias DB
|
||||||
const MODEL_SYNONYMS = {
|
const MODEL_SYNONYMS = {
|
||||||
antigravity: {
|
antigravity: {
|
||||||
|
|
@ -55,9 +37,6 @@ const MODEL_SYNONYMS = {
|
||||||
"gemini-3.5-flash-high": "gemini-3-flash-agent",
|
"gemini-3.5-flash-high": "gemini-3-flash-agent",
|
||||||
"gemini-3.5-flash-medium": "gemini-3.5-flash-low",
|
"gemini-3.5-flash-medium": "gemini-3.5-flash-low",
|
||||||
"gemini-3.5-flash-extra-low": "gemini-3.5-flash-extra-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.1-pro-high": "gemini-pro-agent",
|
||||||
"gemini-3-pro-high": "gemini-pro-agent",
|
"gemini-3-pro-high": "gemini-pro-agent",
|
||||||
"gemini-3-pro-low": "gemini-3.1-pro-low",
|
"gemini-3-pro-low": "gemini-3.1-pro-low",
|
||||||
|
|
@ -134,15 +113,13 @@ function extractModel(url, body) {
|
||||||
return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null;
|
return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null;
|
||||||
}
|
}
|
||||||
const model = urlModel || parsed.model || null;
|
const model = urlModel || parsed.model || null;
|
||||||
const cleanModelName = String(model).replace(/^models\//, "");
|
if (String(model).replace(/^models\//, "") === "gemini-3.6-flash-tiered") {
|
||||||
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
|
const rawLevel = parsed.request?.generationConfig?.thinkingConfig?.thinkingLevel
|
||||||
|| parsed.generationConfig?.thinkingConfig?.thinkingLevel;
|
|| parsed.generationConfig?.thinkingConfig?.thinkingLevel;
|
||||||
const level = ["high", "medium", "low"].includes(String(rawLevel).toLowerCase())
|
const level = ["high", "medium", "low"].includes(String(rawLevel).toLowerCase())
|
||||||
? String(rawLevel).toLowerCase()
|
? String(rawLevel).toLowerCase()
|
||||||
: "medium";
|
: "medium";
|
||||||
return `gemini-${ver}-flash-${level}`;
|
return `gemini-3.6-flash-${level}`;
|
||||||
}
|
}
|
||||||
return model;
|
return model;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -150,4 +127,4 @@ function extractModel(url, body) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, isChatRequest, extractModel };
|
module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, extractModel };
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,7 @@ function initKiroState(modelId) {
|
||||||
finishSent: false, // Whether termination has been emitted
|
finishSent: false, // Whether termination has been emitted
|
||||||
usage: null, // Accumulated usage from usage-only chunks
|
usage: null, // Accumulated usage from usage-only chunks
|
||||||
inThink: false, // Whether inside a <thinking> block
|
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
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,9 +130,9 @@ function encodeHeader(name, value) {
|
||||||
* The SmithyMessageDecoderStream layer requires three system headers on every frame:
|
* The SmithyMessageDecoderStream layer requires three system headers on every frame:
|
||||||
* :message-type = "event" (or "exception" / "error")
|
* :message-type = "event" (or "exception" / "error")
|
||||||
* :event-type = e.g. "assistantResponseEvent"
|
* :event-type = e.g. "assistantResponseEvent"
|
||||||
* :content-type = "application/json" (initial-response uses x-amz-json-1.0)
|
* :content-type = "application/json"
|
||||||
*/
|
*/
|
||||||
function buildEventStreamFrame(eventType, payload, contentType = "application/json") {
|
function buildEventStreamFrame(eventType, payload) {
|
||||||
const payloadBuf = Buffer.from(
|
const payloadBuf = Buffer.from(
|
||||||
typeof payload === "string" ? payload : JSON.stringify(payload),
|
typeof payload === "string" ? payload : JSON.stringify(payload),
|
||||||
"utf8"
|
"utf8"
|
||||||
|
|
@ -143,7 +142,7 @@ function buildEventStreamFrame(eventType, payload, contentType = "application/js
|
||||||
const headersBuf = Buffer.concat([
|
const headersBuf = Buffer.concat([
|
||||||
encodeHeader(":message-type", "event"),
|
encodeHeader(":message-type", "event"),
|
||||||
encodeHeader(":event-type", eventType),
|
encodeHeader(":event-type", eventType),
|
||||||
encodeHeader(":content-type", contentType),
|
encodeHeader(":content-type", "application/json"),
|
||||||
]);
|
]);
|
||||||
const headersLen = headersBuf.length;
|
const headersLen = headersBuf.length;
|
||||||
|
|
||||||
|
|
@ -160,24 +159,6 @@ function buildEventStreamFrame(eventType, payload, contentType = "application/js
|
||||||
return frame;
|
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 ───────────────────────────────────────
|
// ─── CodeWhisperer → OpenAI conversion ───────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -340,12 +321,12 @@ function convertOpenAIToKiro(chunk, state) {
|
||||||
state.inThink = false;
|
state.inThink = false;
|
||||||
const thinking = state.thinkBuf;
|
const thinking = state.thinkBuf;
|
||||||
state.thinkBuf = "";
|
state.thinkBuf = "";
|
||||||
return withInitialFrame(state, buildEventStreamFrame("reasoningContentEvent", {
|
return buildEventStreamFrame("reasoningContentEvent", {
|
||||||
content: thinking,
|
content: thinking,
|
||||||
modelId: state.modelId || "kiro-unknown"
|
modelId: state.modelId || "kiro-unknown"
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
return withInitialFrame(state, buildEventStreamFrame("messageStopEvent", {}));
|
return buildEventStreamFrame("messageStopEvent", {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const frames = [];
|
const frames = [];
|
||||||
|
|
@ -427,12 +408,8 @@ function convertOpenAIToKiro(chunk, state) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frames.length === 0) {
|
if (frames.length === 0) return null;
|
||||||
// اولین چانک ممکنه فقط role/empty باشه — initial رو همونجا بفرست
|
return frames.length === 1 ? frames[0] : frames;
|
||||||
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 { promisify } = require("util");
|
||||||
const { execSync } = require("child_process");
|
const { execSync } = require("child_process");
|
||||||
const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger");
|
const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger");
|
||||||
const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, isChatRequest, extractModel } = require("./config");
|
const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, extractModel } = require("./config");
|
||||||
const { DATA_DIR, MITM_DIR } = require("./paths");
|
const { DATA_DIR, MITM_DIR } = require("./paths");
|
||||||
const { generateCert, getCertForDomain } = require("./cert/generate");
|
const { generateCert, getCertForDomain } = require("./cert/generate");
|
||||||
const { getMitmAlias } = require("./dbReader");
|
const { getMitmAlias } = require("./dbReader");
|
||||||
|
|
@ -311,8 +311,9 @@ const server = https.createServer(sslOptions, async (req, res) => {
|
||||||
const tool = getToolForHost(req.headers.host);
|
const tool = getToolForHost(req.headers.host);
|
||||||
if (!tool) return passthrough(req, res, bodyBuffer);
|
if (!tool) return passthrough(req, res, bodyBuffer);
|
||||||
|
|
||||||
// Kiro IDE posts chat to `/` with x-amz-target (not path /generateAssistantResponse)
|
const patterns = URL_PATTERNS[tool] || [];
|
||||||
if (!isChatRequest(tool, req)) return passthrough(req, res, bodyBuffer);
|
const isChat = patterns.some(p => req.url.includes(p));
|
||||||
|
if (!isChat) return passthrough(req, res, bodyBuffer);
|
||||||
|
|
||||||
// Cursor uses binary proto — model extraction not possible at this layer.
|
// Cursor uses binary proto — model extraction not possible at this layer.
|
||||||
// Delegate directly to handler which decodes proto internally.
|
// Delegate directly to handler which decodes proto internally.
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setDisplayName(data?.displayName || data?.samlName || data?.samlEmail || data?.oidcName || data?.oidcEmail || "");
|
setDisplayName(data?.displayName || data?.oidcName || data?.oidcEmail || "");
|
||||||
setLoginMethod(data?.loginMethod || "");
|
setLoginMethod(data?.loginMethod || "");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -303,15 +303,12 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||||
|
|
||||||
{/* Right actions */}
|
{/* Right actions */}
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{displayName && (loginMethod === "OIDC" || loginMethod === "SAML") && (
|
{displayName && loginMethod === "OIDC" && (
|
||||||
<div
|
<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">
|
||||||
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="material-symbols-outlined text-[14px] mr-1.5 text-primary">person</span>
|
||||||
<span className="truncate">{displayName}</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">
|
<span className="ml-2 shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||||
{loginMethod}
|
OIDC
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export const MITM_TOOLS = {
|
||||||
description: "Google Antigravity IDE with MITM",
|
description: "Google Antigravity IDE with MITM",
|
||||||
configType: "mitm",
|
configType: "mitm",
|
||||||
mitmDomain: "daily-cloudcode-pa.googleapis.com",
|
mitmDomain: "daily-cloudcode-pa.googleapis.com",
|
||||||
modelAliases: ["gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", "gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
|
modelAliases: ["gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
|
||||||
defaultModels: [
|
defaultModels: [
|
||||||
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", alias: "gemini-3.6-flash-high" },
|
{ 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" },
|
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", alias: "gemini-3.6-flash-medium" },
|
||||||
|
|
@ -57,13 +57,8 @@ export const MITM_TOOLS = {
|
||||||
color: "#FF6B00",
|
color: "#FF6B00",
|
||||||
description: "Kiro IDE with MITM",
|
description: "Kiro IDE with MITM",
|
||||||
configType: "mitm",
|
configType: "mitm",
|
||||||
mitmDomain: "runtime.us-east-1.kiro.dev",
|
mitmDomain: "q.us-east-1.amazonaws.com",
|
||||||
defaultModels: [
|
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-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.5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4.5" },
|
||||||
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", alias: "claude-sonnet-4" },
|
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", alias: "claude-sonnet-4" },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import {
|
import {
|
||||||
extractApiKey, isValidApiKey,
|
extractApiKey, isValidApiKey,
|
||||||
getProviderCredentials, markAccountUnavailable,
|
getProviderCredentials, markAccountUnavailable, clearAccountError,
|
||||||
} from "../services/auth.js";
|
} from "../services/auth.js";
|
||||||
import { getSettings } from "@/lib/localDb";
|
import { getSettings } from "@/lib/localDb";
|
||||||
import { getModelInfo } from "../services/model.js";
|
import { getModelInfo } from "../services/model.js";
|
||||||
|
|
@ -74,7 +74,10 @@ export async function handleStt(request) {
|
||||||
|
|
||||||
const result = await handleSttCore({ provider, model, formData, credentials, sttConfig: AI_PROVIDERS[provider]?.sttConfig });
|
const result = await handleSttCore({ provider, model, formData, credentials, sttConfig: AI_PROVIDERS[provider]?.sttConfig });
|
||||||
|
|
||||||
if (result.success) return result.response;
|
if (result.success) {
|
||||||
|
await clearAccountError(credentials.connectionId, credentials, model);
|
||||||
|
return result.response;
|
||||||
|
}
|
||||||
|
|
||||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
||||||
if (shouldFallback) {
|
if (shouldFallback) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import {
|
import {
|
||||||
extractApiKey, isValidApiKey,
|
extractApiKey, isValidApiKey,
|
||||||
getProviderCredentials, markAccountUnavailable,
|
getProviderCredentials, markAccountUnavailable, clearAccountError,
|
||||||
} from "../services/auth.js";
|
} from "../services/auth.js";
|
||||||
import { getSettings } from "@/lib/localDb";
|
import { getSettings } from "@/lib/localDb";
|
||||||
import { getModelInfo, getComboModels } from "../services/model.js";
|
import { getModelInfo, getComboModels } from "../services/model.js";
|
||||||
|
|
@ -101,7 +101,10 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language, st
|
||||||
|
|
||||||
const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat, language, style });
|
const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat, language, style });
|
||||||
|
|
||||||
if (result.success) return result.response;
|
if (result.success) {
|
||||||
|
await clearAccountError(credentials.connectionId, credentials, model);
|
||||||
|
return result.response;
|
||||||
|
}
|
||||||
|
|
||||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
||||||
if (shouldFallback) {
|
if (shouldFallback) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb";
|
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb";
|
||||||
import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy";
|
import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy";
|
||||||
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js";
|
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil, resetAccountState } from "open-sse/services/accountFallback.js";
|
||||||
import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js";
|
import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js";
|
||||||
import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js";
|
import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js";
|
||||||
import * as log from "../utils/logger.js";
|
import * as log from "../utils/logger.js";
|
||||||
|
|
@ -274,43 +274,8 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
|
||||||
*/
|
*/
|
||||||
export async function clearAccountError(connectionId, currentConnection, model = null) {
|
export async function clearAccountError(connectionId, currentConnection, model = null) {
|
||||||
if (!connectionId || connectionId === "noauth") return;
|
if (!connectionId || connectionId === "noauth") return;
|
||||||
const conn = currentConnection._connection || currentConnection;
|
// Reset inside transaction so concurrent 429 writes cannot leave stale locks.
|
||||||
const now = Date.now();
|
await updateProviderConnection(connectionId, resetAccountState);
|
||||||
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,7 +122,6 @@
|
||||||
"alicode": "alicode",
|
"alicode": "alicode",
|
||||||
"alicode-intl": "alicode-intl",
|
"alicode-intl": "alicode-intl",
|
||||||
"alims-intl": "alims-intl",
|
"alims-intl": "alims-intl",
|
||||||
"alitp-intl": "alitp-intl",
|
|
||||||
"anthropic": "anthropic",
|
"anthropic": "anthropic",
|
||||||
"antigravity": "ag",
|
"antigravity": "ag",
|
||||||
"api-airforce": "af",
|
"api-airforce": "af",
|
||||||
|
|
@ -207,7 +206,6 @@
|
||||||
"alicode",
|
"alicode",
|
||||||
"alicode-intl",
|
"alicode-intl",
|
||||||
"alims-intl",
|
"alims-intl",
|
||||||
"alitp-intl",
|
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"assemblyai",
|
"assemblyai",
|
||||||
"black-forest-labs",
|
"black-forest-labs",
|
||||||
|
|
|
||||||
|
|
@ -708,37 +708,7 @@
|
||||||
"opencode-go": {
|
"opencode-go": {
|
||||||
"baseUrl": "https://opencode.ai/zen/go/v1/chat/completions",
|
"baseUrl": "https://opencode.ai/zen/go/v1/chat/completions",
|
||||||
"headers": {},
|
"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": {
|
"opencode": {
|
||||||
"baseUrl": "https://opencode.ai",
|
"baseUrl": "https://opencode.ai",
|
||||||
|
|
@ -998,15 +968,7 @@
|
||||||
"tokenrouter": {
|
"tokenrouter": {
|
||||||
"baseUrl": "https://api.tokenrouter.com/v1/chat/completions",
|
"baseUrl": "https://api.tokenrouter.com/v1/chat/completions",
|
||||||
"validateUrl": "https://api.tokenrouter.com/v1/models",
|
"validateUrl": "https://api.tokenrouter.com/v1/models",
|
||||||
"thinkingFormat": "tokenrouter",
|
"thinkingFormat": "openai",
|
||||||
"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"
|
"format": "openai"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import {
|
|
||||||
formatX509Certificate,
|
|
||||||
isSamlConfigured,
|
|
||||||
generateSamlMetadata,
|
|
||||||
pickSamlEmail,
|
|
||||||
pickSamlDisplayName,
|
|
||||||
} from "../../src/lib/auth/saml.js";
|
|
||||||
|
|
||||||
test("formatX509Certificate normalizes Base64 strings into PEM blocks", () => {
|
|
||||||
const rawBase64 = "MIIC1234567890123456789012345678901234567890123456789012345678901234567890";
|
|
||||||
const formatted = formatX509Certificate(rawBase64);
|
|
||||||
assert.match(formatted, /-----BEGIN CERTIFICATE-----/);
|
|
||||||
assert.match(formatted, /-----END CERTIFICATE-----/);
|
|
||||||
assert.equal(formatX509Certificate(""), "");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("isSamlConfigured checks required fields", () => {
|
|
||||||
assert.equal(isSamlConfigured({ samlEntryPoint: "https://idp.com/sso", samlCert: "cert" }), true);
|
|
||||||
assert.equal(isSamlConfigured({ samlEntryPoint: "https://idp.com/sso" }), false);
|
|
||||||
assert.equal(isSamlConfigured({}), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("generateSamlMetadata produces valid SP XML", () => {
|
|
||||||
const settings = {
|
|
||||||
samlEntryPoint: "https://idp.example.com/sso",
|
|
||||||
samlIssuer: "urn:9router:sp",
|
|
||||||
samlCert: "MIIC123456789012345678901234567890123456789012345678901234567890",
|
|
||||||
};
|
|
||||||
const xml = generateSamlMetadata("https://localhost:20127", settings);
|
|
||||||
assert.match(xml, /entityID="urn:9router:sp"/);
|
|
||||||
assert.match(xml, /Location="https:\/\/localhost:20127\/api\/auth\/saml\/acs"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Claims Extraction pickSamlEmail & pickSamlDisplayName", () => {
|
|
||||||
const profile = { email: "test@example.com", name: "Test User" };
|
|
||||||
assert.equal(pickSamlEmail(profile, {}), "test@example.com");
|
|
||||||
assert.equal(pickSamlDisplayName(profile, {}), "Test User");
|
|
||||||
});
|
|
||||||
|
|
@ -38,25 +38,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > alicode-intl → hea
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > alims-intl → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > anthropic → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > anthropic → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -85,31 +66,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > anthropic → header
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > api-airforce → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"HTTP-Referer": "https://endpoint-proxy.local",
|
|
||||||
"X-Title": "Endpoint Proxy",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"HTTP-Referer": "https://endpoint-proxy.local",
|
|
||||||
"X-Title": "Endpoint Proxy",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"HTTP-Referer": "https://endpoint-proxy.local",
|
|
||||||
"X-Title": "Endpoint Proxy",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -129,44 +85,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → heade
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > baidu → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > bazaarlink → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -186,25 +104,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > bluesminds → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > byteplus → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > byteplus → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -369,52 +268,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > cline → headers (a
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > clinepass → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"HTTP-Referer": "https://cline.bot",
|
|
||||||
"User-Agent": "9Router/0.5.50",
|
|
||||||
"X-CLIENT-TYPE": "9router",
|
|
||||||
"X-CLIENT-VERSION": "0.5.50",
|
|
||||||
"X-CORE-VERSION": "0.5.50",
|
|
||||||
"X-IS-MULTIROOT": "false",
|
|
||||||
"X-PLATFORM": "linux",
|
|
||||||
"X-PLATFORM-VERSION": "v24.15.0",
|
|
||||||
"X-Title": "Cline",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"HTTP-Referer": "https://cline.bot",
|
|
||||||
"User-Agent": "9Router/0.5.50",
|
|
||||||
"X-CLIENT-TYPE": "9router",
|
|
||||||
"X-CLIENT-VERSION": "0.5.50",
|
|
||||||
"X-CORE-VERSION": "0.5.50",
|
|
||||||
"X-IS-MULTIROOT": "false",
|
|
||||||
"X-PLATFORM": "linux",
|
|
||||||
"X-PLATFORM-VERSION": "v24.15.0",
|
|
||||||
"X-Title": "Cline",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"HTTP-Referer": "https://cline.bot",
|
|
||||||
"User-Agent": "9Router/0.5.50",
|
|
||||||
"X-CLIENT-TYPE": "9router",
|
|
||||||
"X-CLIENT-VERSION": "0.5.50",
|
|
||||||
"X-CORE-VERSION": "0.5.50",
|
|
||||||
"X-IS-MULTIROOT": "false",
|
|
||||||
"X-PLATFORM": "linux",
|
|
||||||
"X-PLATFORM-VERSION": "v24.15.0",
|
|
||||||
"X-Title": "Cline",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -471,43 +324,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-cn → hea
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-intl → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
|
|
||||||
"X-IDE-Name": "IDE",
|
|
||||||
"X-IDE-Type": "IDE",
|
|
||||||
"X-Product": "SaaS",
|
|
||||||
"x-codebuddy-request": "1",
|
|
||||||
"x-requested-with": "XMLHttpRequest",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
|
|
||||||
"X-IDE-Name": "IDE",
|
|
||||||
"X-IDE-Type": "IDE",
|
|
||||||
"X-Product": "SaaS",
|
|
||||||
"x-codebuddy-request": "1",
|
|
||||||
"x-requested-with": "XMLHttpRequest",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
|
|
||||||
"X-IDE-Name": "IDE",
|
|
||||||
"X-IDE-Type": "IDE",
|
|
||||||
"X-Product": "SaaS",
|
|
||||||
"x-codebuddy-request": "1",
|
|
||||||
"x-requested-with": "XMLHttpRequest",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > cohere → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > cohere → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -565,25 +381,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > deepseek → headers
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > featherless → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -685,34 +482,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers (
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > grok-cli → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
|
|
||||||
"x-grok-client-identifier": "grok-shell",
|
|
||||||
"x-grok-client-version": "0.2.99",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
|
|
||||||
"x-grok-client-identifier": "grok-shell",
|
|
||||||
"x-grok-client-version": "0.2.99",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
|
|
||||||
"x-grok-client-identifier": "grok-shell",
|
|
||||||
"x-grok-client-version": "0.2.99",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -751,25 +520,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > hyperbolic → heade
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > kilo-gateway → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -789,28 +539,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > kimchi → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": "kimchi/0.1.50",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": "kimchi/0.1.50",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": "kimchi/0.1.50",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -873,25 +601,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > kimi-coding → head
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > llm7 → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > minimax → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > minimax → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -980,25 +689,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > mmf → headers (api
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > morph → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > nanobanana → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > nanobanana → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -1138,63 +828,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > perplexity → heade
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > perplexity-agent → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > poolside → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > sambanova → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -1214,25 +847,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → head
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > tencent → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > together → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > together → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -1252,44 +866,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > together → headers
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > tokenrouter → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > venice → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
|
|
@ -1366,28 +942,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > xiaomi-mimo → head
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > zed → headers (apiKey / oauth) 1`] = `
|
|
||||||
{
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "<CRED>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "<CRED>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "<CRED>",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > alicode → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > alicode → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
|
"nonStream": "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
|
||||||
|
|
@ -1402,13 +956,6 @@ exports[`GOLDEN buildUrl (default executor providers) > alicode-intl → url (st
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > alims-intl → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
|
|
||||||
"stream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.anthropic.com/v1/messages",
|
"nonStream": "https://api.anthropic.com/v1/messages",
|
||||||
|
|
@ -1416,13 +963,6 @@ exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (strea
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > api-airforce → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://api.airforce/v1/chat/completions",
|
|
||||||
"stream": "https://api.airforce/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.assemblyai.com/v1/audio/transcriptions",
|
"nonStream": "https://api.assemblyai.com/v1/audio/transcriptions",
|
||||||
|
|
@ -1430,20 +970,6 @@ exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stre
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > baidu → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://qianfan.baidubce.com/v2/chat/completions",
|
|
||||||
"stream": "https://qianfan.baidubce.com/v2/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > bazaarlink → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://bazaarlink.ai/api/v1/chat/completions",
|
|
||||||
"stream": "https://bazaarlink.ai/api/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.blackbox.ai/chat/completions",
|
"nonStream": "https://api.blackbox.ai/chat/completions",
|
||||||
|
|
@ -1451,13 +977,6 @@ exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > bluesminds → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://api.bluesminds.com/v1/chat/completions",
|
|
||||||
"stream": "https://api.bluesminds.com/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > byteplus → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > byteplus → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions",
|
"nonStream": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions",
|
||||||
|
|
@ -1493,13 +1012,6 @@ exports[`GOLDEN buildUrl (default executor providers) > cline → url (stream +
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > clinepass → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://api.cline.bot/api/v1/chat/completions",
|
|
||||||
"stream": "https://api.cline.bot/api/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > cloudflare-ai → url (stream + non-stream) 1`] = `
|
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",
|
"nonStream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions",
|
||||||
|
|
@ -1514,13 +1026,6 @@ exports[`GOLDEN buildUrl (default executor providers) > codebuddy-cn → url (st
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > codebuddy-intl → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://www.codebuddy.ai/v2/chat/completions",
|
|
||||||
"stream": "https://www.codebuddy.ai/v2/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > cohere → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > cohere → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.cohere.ai/v1/chat/completions",
|
"nonStream": "https://api.cohere.ai/v1/chat/completions",
|
||||||
|
|
@ -1542,13 +1047,6 @@ exports[`GOLDEN buildUrl (default executor providers) > deepseek → url (stream
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > featherless → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://api.featherless.ai/v1/chat/completions",
|
|
||||||
"stream": "https://api.featherless.ai/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.fireworks.ai/inference/v1/chat/completions",
|
"nonStream": "https://api.fireworks.ai/inference/v1/chat/completions",
|
||||||
|
|
@ -1584,13 +1082,6 @@ exports[`GOLDEN buildUrl (default executor providers) > glm-cn → url (stream +
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > grok-cli → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://cli-chat-proxy.grok.com/v1/responses",
|
|
||||||
"stream": "https://cli-chat-proxy.grok.com/v1/responses",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.groq.com/openai/v1/chat/completions",
|
"nonStream": "https://api.groq.com/openai/v1/chat/completions",
|
||||||
|
|
@ -1605,13 +1096,6 @@ exports[`GOLDEN buildUrl (default executor providers) > hyperbolic → url (stre
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > kilo-gateway → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://api.kilo.ai/api/gateway/chat/completions",
|
|
||||||
"stream": "https://api.kilo.ai/api/gateway/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.kilo.ai/api/openrouter/chat/completions",
|
"nonStream": "https://api.kilo.ai/api/openrouter/chat/completions",
|
||||||
|
|
@ -1619,13 +1103,6 @@ exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > kimchi → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://llm.kimchi.dev/openai/v1/chat/completions",
|
|
||||||
"stream": "https://llm.kimchi.dev/openai/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.kimi.com/coding/v1/messages?beta=true",
|
"nonStream": "https://api.kimi.com/coding/v1/messages?beta=true",
|
||||||
|
|
@ -1640,13 +1117,6 @@ exports[`GOLDEN buildUrl (default executor providers) > kimi-coding → url (str
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > llm7 → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://api.llm7.io/v1/chat/completions",
|
|
||||||
"stream": "https://api.llm7.io/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > minimax → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > minimax → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.minimax.io/anthropic/v1/messages?beta=true",
|
"nonStream": "https://api.minimax.io/anthropic/v1/messages?beta=true",
|
||||||
|
|
@ -1675,13 +1145,6 @@ exports[`GOLDEN buildUrl (default executor providers) > mmf → url (stream + no
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > morph → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://api.morphllm.com/v1/chat/completions",
|
|
||||||
"stream": "https://api.morphllm.com/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > nanobanana → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > nanobanana → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.nanobananaapi.ai/v1/chat/completions",
|
"nonStream": "https://api.nanobananaapi.ai/v1/chat/completions",
|
||||||
|
|
@ -1731,27 +1194,6 @@ exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stre
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > perplexity-agent → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://api.perplexity.ai/v1/responses",
|
|
||||||
"stream": "https://api.perplexity.ai/v1/responses",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > poolside → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://inference.poolside.ai/v1/chat/completions",
|
|
||||||
"stream": "https://inference.poolside.ai/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > sambanova → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://api.sambanova.ai/v1/chat/completions",
|
|
||||||
"stream": "https://api.sambanova.ai/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.siliconflow.com/v1/chat/completions",
|
"nonStream": "https://api.siliconflow.com/v1/chat/completions",
|
||||||
|
|
@ -1759,13 +1201,6 @@ exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (str
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > tencent → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions",
|
|
||||||
"stream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > together → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > together → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.together.xyz/v1/chat/completions",
|
"nonStream": "https://api.together.xyz/v1/chat/completions",
|
||||||
|
|
@ -1773,20 +1208,6 @@ exports[`GOLDEN buildUrl (default executor providers) > together → url (stream
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > tokenrouter → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://api.tokenrouter.com/v1/chat/completions",
|
|
||||||
"stream": "https://api.tokenrouter.com/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > venice → url (stream + non-stream) 1`] = `
|
|
||||||
{
|
|
||||||
"nonStream": "https://api.venice.ai/api/v1/chat/completions",
|
|
||||||
"stream": "https://api.venice.ai/api/v1/chat/completions",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions",
|
"nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions",
|
||||||
|
|
@ -1814,10 +1235,3 @@ exports[`GOLDEN buildUrl (default executor providers) > xiaomi-mimo → url (str
|
||||||
"stream": "https://api.xiaomimimo.com/v1/chat/completions",
|
"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",
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
|
||||||
16
tests/unit/account-fallback-reset.test.js
Normal file
16
tests/unit/account-fallback-reset.test.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyErrorState, resetAccountState } from "../../open-sse/services/accountFallback.js";
|
||||||
|
|
||||||
|
describe("resetAccountState", () => {
|
||||||
|
it("clears every model lock and backoff after success", () => {
|
||||||
|
const reset = resetAccountState({ backoffLevel: 9, modelLock_alpha: "2099-01-01T00:00:00.000Z", modelLock___all: "2099-01-01T00:00:00.000Z", lastError: "429", errorCode: 429 });
|
||||||
|
expect(reset.backoffLevel).toBe(0);
|
||||||
|
expect(reset.modelLock_alpha).toBeNull();
|
||||||
|
expect(reset.modelLock___all).toBeNull();
|
||||||
|
expect(reset.lastError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps 429 on error path ratcheting upward", () => {
|
||||||
|
expect(applyErrorState({ backoffLevel: 3 }, 429, "rate limited").backoffLevel).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import REGISTRY from "../../open-sse/providers/registry/index.js";
|
|
||||||
import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js";
|
|
||||||
|
|
||||||
describe("Alibaba Token Plan provider", () => {
|
|
||||||
const entry = REGISTRY.find((e) => e.id === "alitp-intl");
|
|
||||||
|
|
||||||
it("is registered as an OpenAI-compatible apikey provider", () => {
|
|
||||||
expect(entry).toBeDefined();
|
|
||||||
expect(entry.category).toBe("apikey");
|
|
||||||
expect(PROVIDERS["alitp-intl"]).toBeDefined();
|
|
||||||
expect(PROVIDERS["alitp-intl"].format).toBe("openai");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("targets the Singapore Token Plan host in compatible mode", () => {
|
|
||||||
// eu-central-1 answers IllegalEndpoint; the plan is Singapore-only.
|
|
||||||
expect(PROVIDERS["alitp-intl"].baseUrl).toBe(
|
|
||||||
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not collide with the other three Alibaba key types", () => {
|
|
||||||
const hosts = ["alicode", "alicode-intl", "alims-intl", "alitp-intl"]
|
|
||||||
.map((id) => new URL(PROVIDERS[id].baseUrl).host);
|
|
||||||
expect(new Set(hosts).size).toBe(hosts.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes the models the plan actually serves", () => {
|
|
||||||
const ids = (PROVIDER_MODELS["alitp-intl"] || []).map((m) => m.id);
|
|
||||||
expect(ids).toEqual(expect.arrayContaining([
|
|
||||||
"qwen3.8-max-preview",
|
|
||||||
"qwen3.7-max",
|
|
||||||
"qwen3.7-plus",
|
|
||||||
"qwen3.6-flash",
|
|
||||||
"glm-5.2",
|
|
||||||
"deepseek-v4-pro",
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps every registry id unique after adding the provider", () => {
|
|
||||||
const ids = REGISTRY.map((e) => e.id);
|
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("@/lib/usageDb.js", () => ({
|
|
||||||
appendRequestLog: vi.fn(async () => {}),
|
|
||||||
saveRequestDetail: vi.fn(async () => {}),
|
|
||||||
saveRequestUsage: vi.fn(async () => {})
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { extractUsageFromResponse } = await import("../../open-sse/handlers/chatCore/requestDetail.js");
|
|
||||||
|
|
||||||
const USAGE_METADATA = {
|
|
||||||
promptTokenCount: 1234,
|
|
||||||
candidatesTokenCount: 56,
|
|
||||||
cachedContentTokenCount: 78,
|
|
||||||
thoughtsTokenCount: 90,
|
|
||||||
};
|
|
||||||
|
|
||||||
const EXPECTED = {
|
|
||||||
prompt_tokens: 1234,
|
|
||||||
completion_tokens: 56,
|
|
||||||
cached_tokens: 78,
|
|
||||||
reasoning_tokens: 90,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("#3260 non-streaming usage extraction for enveloped Gemini responses", () => {
|
|
||||||
it("reads usageMetadata out of the antigravity { response } envelope", () => {
|
|
||||||
expect(extractUsageFromResponse({ response: { usageMetadata: USAGE_METADATA } })).toEqual(EXPECTED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still reads a top-level usageMetadata", () => {
|
|
||||||
expect(extractUsageFromResponse({ usageMetadata: USAGE_METADATA })).toEqual(EXPECTED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers the top-level metadata when both are present", () => {
|
|
||||||
const enveloped = { ...USAGE_METADATA, promptTokenCount: 1 };
|
|
||||||
const out = extractUsageFromResponse({
|
|
||||||
usageMetadata: USAGE_METADATA,
|
|
||||||
response: { usageMetadata: enveloped },
|
|
||||||
});
|
|
||||||
expect(out.prompt_tokens).toBe(1234);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the OpenAI and Claude shapes alone", () => {
|
|
||||||
expect(extractUsageFromResponse({ usage: { prompt_tokens: 10, completion_tokens: 2 } }))
|
|
||||||
.toMatchObject({ prompt_tokens: 10, completion_tokens: 2 });
|
|
||||||
expect(extractUsageFromResponse({ usage: { input_tokens: 10, output_tokens: 2 } }))
|
|
||||||
.toMatchObject({ prompt_tokens: 10, completion_tokens: 2 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when there is no usage anywhere", () => {
|
|
||||||
expect(extractUsageFromResponse({ response: { candidates: [] } })).toBeNull();
|
|
||||||
expect(extractUsageFromResponse(null)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
const proxyAwareFetch = vi.fn(async (url) => ({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => url.includes(":loadCodeAssist")
|
|
||||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
|
|
||||||
: {
|
|
||||||
models: {
|
|
||||||
"gemini-3.7-flash-high": {
|
|
||||||
displayName: "Gemini 3.7 Flash (High)",
|
|
||||||
quotaInfo: { remainingFraction: 0.85, resetTime: "2026-08-25T12:00:00Z" },
|
|
||||||
},
|
|
||||||
"gemini-3.7-flash-medium": {
|
|
||||||
displayName: "Gemini 3.7 Flash (Medium)",
|
|
||||||
quotaInfo: { remainingFraction: 0.6, resetTime: "2026-08-25T12:00:00Z" },
|
|
||||||
},
|
|
||||||
"gemini-3.7-flash-low": {
|
|
||||||
displayName: "Gemini 3.7 Flash (Low)",
|
|
||||||
quotaInfo: { remainingFraction: 0.35, resetTime: "2026-08-25T12:00:00Z" },
|
|
||||||
},
|
|
||||||
"internal-model": {
|
|
||||||
displayName: "Internal",
|
|
||||||
isInternal: true,
|
|
||||||
quotaInfo: { remainingFraction: 0.5 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
text: async () => "{}",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
|
||||||
proxyAwareFetch,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Antigravity quota tracker: Gemini 3.7 Flash usage bars", () => {
|
|
||||||
beforeEach(() => proxyAwareFetch.mockClear());
|
|
||||||
|
|
||||||
it("returns Gemini 3.7 Flash tier quotas so the dashboard can render usage bars", async () => {
|
|
||||||
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
|
|
||||||
|
|
||||||
const usage = await getAntigravityUsage("access-token", {});
|
|
||||||
|
|
||||||
expect(usage.quotas["gemini-3.7-flash-high"]).toMatchObject({
|
|
||||||
used: 150,
|
|
||||||
total: 1000,
|
|
||||||
remainingPercentage: 85,
|
|
||||||
displayName: "Gemini 3.7 Flash (High)",
|
|
||||||
});
|
|
||||||
expect(usage.quotas["gemini-3.7-flash-medium"]).toMatchObject({
|
|
||||||
used: 400,
|
|
||||||
total: 1000,
|
|
||||||
remainingPercentage: 60,
|
|
||||||
});
|
|
||||||
expect(usage.quotas["gemini-3.7-flash-low"]).toMatchObject({
|
|
||||||
used: 650,
|
|
||||||
total: 1000,
|
|
||||||
remainingPercentage: 35,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
// custom-server.js is the only thing that makes x-9r-real-ip trustworthy. Boot a real
|
|
||||||
// HTTP server through it and confirm a client cannot smuggle its own peer headers in.
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import http from "node:http";
|
|
||||||
import { __test__ as requestDetails } from "@/lib/db/repos/requestDetailsRepo.js";
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
|
|
||||||
let server;
|
|
||||||
let baseUrl;
|
|
||||||
let seenHeaders;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
require("../../custom-server.js");
|
|
||||||
server = http.createServer((req, res) => {
|
|
||||||
seenHeaders = req.headers;
|
|
||||||
res.end("ok");
|
|
||||||
});
|
|
||||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
||||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await new Promise((resolve) => server.close(resolve));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function get(headers = {}) {
|
|
||||||
await fetch(baseUrl, { headers });
|
|
||||||
return seenHeaders;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("custom-server peer header sanitizing", () => {
|
|
||||||
it("generates a peer trust token at boot", () => {
|
|
||||||
expect(process.env.NINEROUTER_PEER_TOKEN).toMatch(/^[0-9a-f]{48}$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces a client-supplied x-9r-real-ip with the socket address", async () => {
|
|
||||||
const headers = await get({ "x-9r-real-ip": "203.0.113.55" });
|
|
||||||
|
|
||||||
expect(headers["x-9r-real-ip"]).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stamps the trust token so downstream can tell the wrapper ran", async () => {
|
|
||||||
const headers = await get();
|
|
||||||
|
|
||||||
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops a client-supplied peer trust token", async () => {
|
|
||||||
const headers = await get({ "x-9r-peer-token": "forged-token" });
|
|
||||||
|
|
||||||
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
|
|
||||||
expect(headers["x-9r-peer-token"]).not.toBe("forged-token");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops a client-supplied x-9r-via-proxy marker", async () => {
|
|
||||||
const headers = await get({ "x-9r-via-proxy": "1" });
|
|
||||||
|
|
||||||
expect(headers["x-9r-via-proxy"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks via-proxy and adopts the forwarded IP for a loopback proxy hop", async () => {
|
|
||||||
const headers = await get({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" });
|
|
||||||
|
|
||||||
expect(headers["x-9r-via-proxy"]).toBe("1");
|
|
||||||
expect(headers["x-9r-real-ip"]).toBe("203.0.113.9");
|
|
||||||
expect(headers["x-forwarded-for"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
// chat.js snapshots every client header into the request detail. Anything that grants
|
|
||||||
// access must not survive into a record the dashboard renders and cloud sync uploads.
|
|
||||||
it("keeps the peer token out of persisted request details", () => {
|
|
||||||
const sanitized = requestDetails.sanitizeHeaders({
|
|
||||||
"x-9r-peer-token": "secret",
|
|
||||||
"x-9r-cli-token": "secret",
|
|
||||||
"authorization": "Bearer sk-x",
|
|
||||||
"x-9r-real-ip": "127.0.0.1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sanitized["x-9r-peer-token"]).toBeUndefined();
|
|
||||||
expect(sanitized["x-9r-cli-token"]).toBeUndefined();
|
|
||||||
expect(sanitized["authorization"]).toBeUndefined();
|
|
||||||
expect(sanitized["x-9r-real-ip"]).toBe("127.0.0.1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -35,8 +35,6 @@ vi.mock("@/lib/auth/dashboardSession", () => ({
|
||||||
|
|
||||||
const { proxy, __test__ } = await import("../../src/dashboardGuard.js");
|
const { proxy, __test__ } = await import("../../src/dashboardGuard.js");
|
||||||
|
|
||||||
const PEER_TOKEN = "peer-token-fixture";
|
|
||||||
|
|
||||||
function request(pathname, headers = {}) {
|
function request(pathname, headers = {}) {
|
||||||
const normalizedHeaders = new Headers(headers);
|
const normalizedHeaders = new Headers(headers);
|
||||||
return {
|
return {
|
||||||
|
|
@ -47,16 +45,9 @@ function request(pathname, headers = {}) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// A request that actually came through custom-server.js: peer IP stamped from the TCP
|
|
||||||
// socket and proven by the per-process secret.
|
|
||||||
function localRequest(pathname, headers = {}) {
|
|
||||||
return request(pathname, { "x-9r-peer-token": PEER_TOKEN, "x-9r-real-ip": "127.0.0.1", ...headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("dashboard guard public LLM API access", () => {
|
describe("dashboard guard public LLM API access", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
|
|
||||||
mocks.getSettings.mockResolvedValue({ requireLogin: true });
|
mocks.getSettings.mockResolvedValue({ requireLogin: true });
|
||||||
mocks.validateApiKey.mockResolvedValue(false);
|
mocks.validateApiKey.mockResolvedValue(false);
|
||||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||||
|
|
@ -64,14 +55,14 @@ describe("dashboard guard public LLM API access", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows loopback public LLM API without API key", async () => {
|
it("allows loopback public LLM API without API key", async () => {
|
||||||
const response = await proxy(localRequest("/v1/chat/completions", { host: "localhost:20128" }));
|
const response = await proxy(request("/v1/chat/completions", { host: "localhost:20128" }));
|
||||||
|
|
||||||
expect(response).toBe(mocks.nextResponse);
|
expect(response).toBe(mocks.nextResponse);
|
||||||
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects remote Host-spoof when real peer IP is non-loopback", async () => {
|
it("rejects remote Host-spoof when real peer IP is non-loopback", async () => {
|
||||||
const response = await proxy(localRequest("/v1/chat/completions", {
|
const response = await proxy(request("/v1/chat/completions", {
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
"x-9r-real-ip": "10.204.111.34",
|
"x-9r-real-ip": "10.204.111.34",
|
||||||
}));
|
}));
|
||||||
|
|
@ -81,7 +72,7 @@ describe("dashboard guard public LLM API access", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows loopback peer IP regardless of Host", async () => {
|
it("allows loopback peer IP regardless of Host", async () => {
|
||||||
const response = await proxy(localRequest("/v1/chat/completions", {
|
const response = await proxy(request("/v1/chat/completions", {
|
||||||
host: "localhost:20128",
|
host: "localhost:20128",
|
||||||
"x-9r-real-ip": "127.0.0.1",
|
"x-9r-real-ip": "127.0.0.1",
|
||||||
}));
|
}));
|
||||||
|
|
@ -98,7 +89,7 @@ describe("dashboard guard public LLM API access", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows loopback rewritten public LLM API without API key", async () => {
|
it("allows loopback rewritten public LLM API without API key", async () => {
|
||||||
const response = await proxy(localRequest("/api/v1/chat/completions", { host: "localhost:20128" }));
|
const response = await proxy(request("/api/v1/chat/completions", { host: "localhost:20128" }));
|
||||||
|
|
||||||
expect(response).toBe(mocks.nextResponse);
|
expect(response).toBe(mocks.nextResponse);
|
||||||
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
||||||
|
|
@ -200,7 +191,6 @@ describe("dashboard guard public LLM API access", () => {
|
||||||
describe("dashboard guard local-only access", () => {
|
describe("dashboard guard local-only access", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
|
|
||||||
mocks.getSettings.mockResolvedValue({ requireLogin: true });
|
mocks.getSettings.mockResolvedValue({ requireLogin: true });
|
||||||
mocks.validateApiKey.mockResolvedValue(false);
|
mocks.validateApiKey.mockResolvedValue(false);
|
||||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||||
|
|
@ -217,7 +207,7 @@ describe("dashboard guard local-only access", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => {
|
it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => {
|
||||||
const response = await proxy(localRequest("/api/mcp/filesystem/sse", {
|
const response = await proxy(request("/api/mcp/filesystem/sse", {
|
||||||
host: "localhost:20128",
|
host: "localhost:20128",
|
||||||
origin: "http://localhost:20128",
|
origin: "http://localhost:20128",
|
||||||
}));
|
}));
|
||||||
|
|
@ -229,7 +219,7 @@ describe("dashboard guard local-only access", () => {
|
||||||
it("allows local-only route on loopback when requireLogin=false", async () => {
|
it("allows local-only route on loopback when requireLogin=false", async () => {
|
||||||
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
||||||
|
|
||||||
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
|
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
||||||
host: "localhost:20128",
|
host: "localhost:20128",
|
||||||
origin: "http://localhost:20128",
|
origin: "http://localhost:20128",
|
||||||
}));
|
}));
|
||||||
|
|
@ -250,7 +240,7 @@ describe("dashboard guard local-only access", () => {
|
||||||
it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => {
|
it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => {
|
||||||
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
||||||
|
|
||||||
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
|
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
||||||
host: "localhost:20128",
|
host: "localhost:20128",
|
||||||
origin: "http://evil.example.com",
|
origin: "http://evil.example.com",
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import REGISTRY from "../../open-sse/providers/registry/index.js";
|
|
||||||
import { PROVIDER_MEDIA } from "../../open-sse/providers/index.js";
|
|
||||||
import { FORMAT_HANDLERS } from "../../open-sse/handlers/ttsProviders/genericFormats.js";
|
|
||||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
|
||||||
|
|
||||||
const AUDIO = new Uint8Array(256).fill(7);
|
|
||||||
|
|
||||||
function okResponse() {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
headers: new Headers({ "content-type": "audio/mpeg" }),
|
|
||||||
arrayBuffer: async () => AUDIO.buffer,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Fish Audio TTS provider", () => {
|
|
||||||
const entry = REGISTRY.find((e) => e.id === "fish-audio");
|
|
||||||
|
|
||||||
it("is registered as a TTS-only apikey provider", () => {
|
|
||||||
expect(entry).toBeDefined();
|
|
||||||
expect(entry.category).toBe("apikey");
|
|
||||||
expect(entry.serviceKinds).toEqual(["tts"]);
|
|
||||||
expect(PROVIDER_MEDIA["fish-audio"]?.ttsConfig?.baseUrl).toBe("https://api.fish.audio/v1/tts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is visible to the generic dispatcher, which reads AI_PROVIDERS", () => {
|
|
||||||
// synthesizeViaConfig() looks the provider up here, not in PROVIDER_MEDIA.
|
|
||||||
expect(AI_PROVIDERS["fish-audio"]?.ttsConfig?.format).toBe("fish-audio");
|
|
||||||
expect(typeof FORMAT_HANDLERS["fish-audio"]).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes the four documented models", () => {
|
|
||||||
const ids = (entry.ttsConfig.models || []).map((m) => m.id);
|
|
||||||
expect(ids).toEqual(["s2.1-pro-free", "s2.1-pro", "s2-pro", "s1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps registry ids and aliases unique", () => {
|
|
||||||
const ids = REGISTRY.map((e) => e.id);
|
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
|
||||||
const aliases = REGISTRY.map((e) => e.alias).filter(Boolean);
|
|
||||||
expect(new Set(aliases).size).toBe(aliases.length);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Fish Audio TTS request shape", () => {
|
|
||||||
const handler = FORMAT_HANDLERS["fish-audio"];
|
|
||||||
let fetchMock;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fetchMock = vi.fn(async () => okResponse());
|
|
||||||
global.fetch = fetchMock;
|
|
||||||
});
|
|
||||||
|
|
||||||
const callArgs = () => {
|
|
||||||
const [url, init] = fetchMock.mock.calls.at(-1);
|
|
||||||
return { url, init, body: JSON.parse(init.body) };
|
|
||||||
};
|
|
||||||
|
|
||||||
it("sends the model as an HTTP header, not in the body", async () => {
|
|
||||||
await handler({
|
|
||||||
baseUrl: "https://api.fish.audio/v1/tts",
|
|
||||||
apiKey: "sk-test",
|
|
||||||
text: "xin chào",
|
|
||||||
modelId: "s1",
|
|
||||||
voiceId: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { url, init, body } = callArgs();
|
|
||||||
expect(url).toBe("https://api.fish.audio/v1/tts");
|
|
||||||
expect(init.headers.model).toBe("s1");
|
|
||||||
expect(init.headers.Authorization).toBe("Bearer sk-test");
|
|
||||||
expect(body).toEqual({ text: "xin chào", format: "mp3" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps the voice onto reference_id, and omits it when unset", async () => {
|
|
||||||
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "voice-abc" });
|
|
||||||
expect(callArgs().body.reference_id).toBe("voice-abc");
|
|
||||||
|
|
||||||
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" });
|
|
||||||
expect(callArgs().body).not.toHaveProperty("reference_id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to the free model when none is given", async () => {
|
|
||||||
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "", voiceId: "" });
|
|
||||||
expect(callArgs().init.headers.model).toBe("s2.1-pro-free");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns base64 audio with its format", async () => {
|
|
||||||
const out = await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" });
|
|
||||||
expect(out.format).toBe("mp3");
|
|
||||||
expect(typeof out.base64).toBe("string");
|
|
||||||
expect(out.base64.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("surfaces the upstream error message", async () => {
|
|
||||||
global.fetch = vi.fn(async () => ({
|
|
||||||
ok: false,
|
|
||||||
status: 402,
|
|
||||||
text: async () => JSON.stringify({ message: "Insufficient credit" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" }),
|
|
||||||
).rejects.toThrow("Insufficient credit");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
// Issue #3024 — Fusion combo must strip `stream_options` from panel requests
|
|
||||||
// when running non-streaming, or DeepSeek rejects with
|
|
||||||
// "stream_options should be set along with stream = true".
|
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { handleFusionChat } from "../../open-sse/services/combo.js";
|
|
||||||
|
|
||||||
// Minimal logger stub (combo.js calls log.info/warn).
|
|
||||||
const log = { info: () => {}, warn: () => {}, error: () => {} };
|
|
||||||
|
|
||||||
function makeBody(extra = {}) {
|
|
||||||
return {
|
|
||||||
model: "combo/gemseek",
|
|
||||||
stream: true,
|
|
||||||
stream_options: { include_usage: true },
|
|
||||||
messages: [{ role: "user", content: "hi" }],
|
|
||||||
...extra,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Fusion strips stream_options (#3024)", () => {
|
|
||||||
it("removes stream_options before fanning out to panel models", async () => {
|
|
||||||
let capturedPanelBody = null;
|
|
||||||
const handleSingleModel = vi.fn(async (panelBody, model, isPanel) => {
|
|
||||||
if (isPanel) capturedPanelBody = panelBody;
|
|
||||||
// Simulate a successful non-stream JSON answer for the panel.
|
|
||||||
if (isPanel) {
|
|
||||||
return new Response(JSON.stringify({ choices: [{ message: { content: `ans-${model}` } }] }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Judge leg: return a final answer.
|
|
||||||
return new Response(JSON.stringify({ choices: [{ message: { content: "final" } }] }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await handleFusionChat({
|
|
||||||
body: makeBody(),
|
|
||||||
models: ["ds/deepseek-v4-flash", "gemini/gemini-3.5-flash-lite"],
|
|
||||||
handleSingleModel,
|
|
||||||
log,
|
|
||||||
comboName: "GemSeek",
|
|
||||||
judgeModel: "gemini/gemini-3.5-flash-lite",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res).toBeInstanceOf(Response);
|
|
||||||
expect(capturedPanelBody).not.toBeNull();
|
|
||||||
// Critical assertion: stream_options must NOT leak into panel requests.
|
|
||||||
expect(capturedPanelBody.stream_options).toBeUndefined();
|
|
||||||
expect(capturedPanelBody.stream).toBe(false);
|
|
||||||
// Ensure the original client body still had it (proves we stripped deliberately).
|
|
||||||
expect(makeBody().stream_options).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not throw for a 2-model fusion with stream_options present", async () => {
|
|
||||||
const handleSingleModel = vi.fn(async (panelBody, model, isPanel) => {
|
|
||||||
if (isPanel) {
|
|
||||||
return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({ choices: [{ message: { content: "final" } }] }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await handleFusionChat({
|
|
||||||
body: makeBody({ stream_options: { include_usage: true } }),
|
|
||||||
models: ["ds/deepseek-v4-pro", "ds/deepseek-v4-flash"],
|
|
||||||
handleSingleModel,
|
|
||||||
log,
|
|
||||||
comboName: "GemSeek",
|
|
||||||
judgeModel: "ds/deepseek-v4-pro",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
|
||||||
import antigravityRegistry from "../../open-sse/providers/registry/antigravity.js";
|
|
||||||
import geminiRegistry from "../../open-sse/providers/registry/gemini.js";
|
|
||||||
import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
|
|
||||||
|
|
||||||
describe("Gemini 3.7 Flash Support & Config (#3286, #3281)", () => {
|
|
||||||
it("registers gemini-3.7-flash tiered models in antigravity provider registry", () => {
|
|
||||||
const agIds = antigravityRegistry.models.map(m => m.id);
|
|
||||||
expect(agIds).toContain("gemini-3.7-flash-high");
|
|
||||||
expect(agIds).toContain("gemini-3.7-flash-medium");
|
|
||||||
expect(agIds).toContain("gemini-3.7-flash-low");
|
|
||||||
expect(agIds).not.toContain("gemini-3.7-flash");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("registers gemini-3.7-flash in gemini provider registry", () => {
|
|
||||||
const geminiIds = geminiRegistry.models.map(m => m.id);
|
|
||||||
expect(geminiIds).toContain("gemini-3.7-flash");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves capabilities correctly for gemini-3.7 models with official limits", () => {
|
|
||||||
const caps = getCapabilitiesForModel("antigravity", "gemini-3.7-flash-high");
|
|
||||||
expect(caps.vision).toBe(true);
|
|
||||||
expect(caps.reasoning).toBe(true);
|
|
||||||
expect(caps.thinkingFormat).toBe("gemini-level");
|
|
||||||
expect(caps.contextWindow).toBe(1048576);
|
|
||||||
expect(caps.maxOutput).toBe(65536);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defines pricing matching gemini-3.6-flash baseline", () => {
|
|
||||||
expect(MODEL_PRICING["gemini-3.7-flash"]).toEqual(MODEL_PRICING["gemini-3.6-flash"]);
|
|
||||||
expect(MODEL_PRICING["gemini-3.7-flash-high"]).toEqual(MODEL_PRICING["gemini-3.6-flash-high"]);
|
|
||||||
expect(MODEL_PRICING["gemini-3.7-flash-medium"]).toEqual(MODEL_PRICING["gemini-3.6-flash-medium"]);
|
|
||||||
expect(MODEL_PRICING["gemini-3.7-flash-low"]).toEqual(MODEL_PRICING["gemini-3.6-flash-low"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { detectRequiredCapabilities } from "../../open-sse/services/combo.js";
|
|
||||||
import { augmentModelsWithCapacityAdapter } from "../../open-sse/services/capacityAdapter.js";
|
|
||||||
import { stripUnsupportedModalities } from "../../open-sse/translator/concerns/modality.js";
|
|
||||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
|
||||||
|
|
||||||
describe("Hermes Vision Image Detection", () => {
|
|
||||||
it("detects vision from Ollama / Hermes images array", () => {
|
|
||||||
const body = {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: "Please analyze this image from Hermes",
|
|
||||||
images: ["iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const caps = detectRequiredCapabilities(body);
|
|
||||||
expect(caps.has("vision")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects vision from Vercel AI SDK / Hermes experimental_attachments", () => {
|
|
||||||
const body = {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: "Describe this attachment",
|
|
||||||
experimental_attachments: [
|
|
||||||
{
|
|
||||||
contentType: "image/png",
|
|
||||||
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const caps = detectRequiredCapabilities(body);
|
|
||||||
expect(caps.has("vision")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects vision from Hermes attachments array", () => {
|
|
||||||
const body = {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: "Look at this photo",
|
|
||||||
attachments: [
|
|
||||||
{
|
|
||||||
mediaType: "image/jpeg",
|
|
||||||
url: "https://example.com/photo.jpg",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const caps = detectRequiredCapabilities(body);
|
|
||||||
expect(caps.has("vision")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects vision from embedded data:image URI in string content", () => {
|
|
||||||
const body = {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: "Here is an inline image: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const caps = detectRequiredCapabilities(body);
|
|
||||||
expect(caps.has("vision")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("auto-switches non-vision model (deepseek-v4-pro) to Vision Adapter model (Kimi-K3)", () => {
|
|
||||||
const body = {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: "Analyze image",
|
|
||||||
images: ["base64data..."],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const reqCaps = detectRequiredCapabilities(body);
|
|
||||||
const settings = {
|
|
||||||
capacityAdapter: {
|
|
||||||
vision: {
|
|
||||||
enabled: true,
|
|
||||||
models: ["cmc/moonshotai/Kimi-K3"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const augmented = augmentModelsWithCapacityAdapter(["cmc/deepseek/deepseek-v4-pro"], reqCaps, settings);
|
|
||||||
expect(augmented).toEqual(["cmc/moonshotai/Kimi-K3", "cmc/deepseek/deepseek-v4-pro"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips msg.images and attachments when model does not support vision", () => {
|
|
||||||
const body = {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: "Test text",
|
|
||||||
images: ["base64..."],
|
|
||||||
experimental_attachments: [{ contentType: "image/png", url: "data:image/png;base64,..." }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const noVisionCaps = { vision: false, pdf: false, audioInput: false };
|
|
||||||
|
|
||||||
stripUnsupportedModalities(body, FORMATS.OPENAI, noVisionCaps);
|
|
||||||
|
|
||||||
expect(body.messages[0].images).toBeUndefined();
|
|
||||||
expect(body.messages[0].experimental_attachments).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -14,13 +14,6 @@ describe("Kiro MITM model slots", () => {
|
||||||
expect(Array.isArray(kiro.defaultModels)).toBe(true);
|
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", () => {
|
it("offers a mappable slot for Claude Sonnet 5", () => {
|
||||||
const sonnet5 = kiro.defaultModels.find((m) => m.id === "claude-sonnet-5");
|
const sonnet5 = kiro.defaultModels.find((m) => m.id === "claude-sonnet-5");
|
||||||
expect(sonnet5).toBeTruthy();
|
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