merge: update from decolua/9router upstream (SAML, providers, opencode session headers)
This commit is contained in:
commit
f555236545
103 files changed
+10681
-568
No files matched your search
@@ -1,3 +1,89 @@
|
|||||||
|
# v0.5.55 (2026-08-14)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- **Auth**: native SAML 2.0 SSO alongside OIDC — AuthnRequest generation, ACS
|
||||||
|
assertion handling, SP metadata export, admin config test, replay-protected
|
||||||
|
via a `saml_state` cookie matched against `InResponseTo`
|
||||||
|
- **Providers**: add Alibaba Token Plan (`token-plan.ap-southeast-1`) — the
|
||||||
|
fourth Alibaba key type, Singapore-only and OpenAI-compatible transport only
|
||||||
|
- **Providers**: add `glm-5.3` to GLM Coding and GLM (China)
|
||||||
|
- **Providers**: Kimchi accepts API keys as well as OAuth (dual auth), with a
|
||||||
|
working Test Connection for both modes
|
||||||
|
- **Antigravity**: add Gemini 3.7 Flash and its tiered high/medium/low variants
|
||||||
|
(also in the Gemini registry) with pricing and quota tracking
|
||||||
|
- **TTS**: add Fish Audio — model id travels in an HTTP `model` header, voice
|
||||||
|
is a `reference_id` (preset or cloned voice model)
|
||||||
|
- **OpenCode-Go**: route by request format via declared transports instead of
|
||||||
|
forcing every client into `/messages` — Codex/OpenAI clients no longer pay a
|
||||||
|
lossy Responses→OpenAI→Claude double translation. Per-model `supportedFormats`
|
||||||
|
guard; the bespoke executor is gone (its shared `_lastModel` cache could cross
|
||||||
|
auth headers between concurrent requests)
|
||||||
|
- **Usage**: dedup + cache Claude quota calls (120s TTL keyed by access token,
|
||||||
|
in-flight promise dedup, last-good read on soft failure) to stop multiple
|
||||||
|
tabs tripping 429; manual refresh (↻) sends `force=1` to bypass the cache
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
- **Docker**: ship `sql.js` in the image so the pure-JS DB fallback can start —
|
||||||
|
file tracing carried the package's JS without `dist/sql-wasm.wasm`, so a
|
||||||
|
container with no native driver aborted with ENOENT and never got a database
|
||||||
|
(#3248)
|
||||||
|
- **Usage**: read Gemini `usageMetadata` out of the antigravity `{ response }`
|
||||||
|
envelope — every non-streaming antigravity request logged `IN 0 | OUT 0`
|
||||||
|
(#3260)
|
||||||
|
- **Claude**: re-anchor passthrough cache breakpoints — the client's own
|
||||||
|
`cache_control` markers point at pre-normalization offsets, so the tail was
|
||||||
|
re-cached every request. Last system block and last tool pinned at 1h TTL,
|
||||||
|
last assistant turn at 5m, mid-conversation system messages folded into the
|
||||||
|
neighbouring user turn instead of hoisted into `body.system`
|
||||||
|
- **Combos**: detect images from Hermes and attachment payloads (`images[]`,
|
||||||
|
`experimental_attachments`, message-level `image_url`/`audio_url`, inline
|
||||||
|
`data:` URIs) so the Vision Adapter auto-switch fires for Hermes/Ollama/
|
||||||
|
Vercel AI SDK shapes
|
||||||
|
- **Kiro**: intercept chat via `x-amz-target` — Kiro IDE 1.0.228+ moved
|
||||||
|
`GenerateAssistantResponse` to `POST /` + header, bypassing MITM. Also emit
|
||||||
|
the now-mandatory initial-response frame and map the `auto` model slot
|
||||||
|
- **Kiro**: report real output tokens and stop discarding usable turns
|
||||||
|
- **Qoder**: detect billing blocks at stream start and return a synthetic 403
|
||||||
|
so combo/account fallback triggers instead of leaking the error into chat
|
||||||
|
- **Antigravity**: strip competitive system prompts (Zed IDE's Claude-agent
|
||||||
|
prompt) that Antigravity flags with a 429 Quota Exhausted
|
||||||
|
- **OpenCode**: send the official client fingerprint on free-tier requests so
|
||||||
|
the Console stops classifying traffic as unidentified and rate-limiting it;
|
||||||
|
session id resolves conversation-stable to preserve prompt caching
|
||||||
|
- **Responses**: don't close the message on an empty `tool_calls` array — some
|
||||||
|
providers attach one to every chunk, and the truthy check ended the message
|
||||||
|
on the first content token (#3234)
|
||||||
|
- **Translator**: preserve `prompt_cache_key` when converting chat to responses
|
||||||
|
- **Models**: expose snake_case token limits on `/v1/models`
|
||||||
|
- **Combos**: strip `stream_options` from the Fusion panel fan-out to avoid a
|
||||||
|
DeepSeek 400 (#3024); raise the dashboard model-test probe budget to 1024 and
|
||||||
|
soft-pass reasoning-only responses (#3010)
|
||||||
|
- **Headroom**: the toggle reflects the `headroomEnabled` setting even when the
|
||||||
|
proxy is down — it previously showed OFF while the engine kept calling
|
||||||
|
`/v1/compress`; proxy status stays visible via the status chip
|
||||||
|
- **Hermes**: add the `api_key` parameter to the model block in YAML config
|
||||||
|
- **Providers**: add llm7 to provider test support
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
- **i18n**: add Spanish, French, and Brazilian Portuguese README translations
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- **Real IP**: `x-9r-real-ip` and the Host fallback were trusted from
|
||||||
|
client-controlled headers whenever `custom-server.js` was not in the request
|
||||||
|
path (`npm run start`, `start:bun`), letting a remote caller pose as local to
|
||||||
|
skip API key auth and reach `LOCAL_ONLY_PATHS` (`/api/mcp/*`,
|
||||||
|
`/api/tunnel/enable`, `/api/auth/reset-password`). The server now stamps a
|
||||||
|
per-process `x-9r-peer-token` on every request it sanitizes and only trusts
|
||||||
|
`x-9r-real-ip` behind it — falling back to Host in development and failing
|
||||||
|
closed in production (GHSA-pjm4-8fpg-f9p6). Also fixes IPv6 loopback
|
||||||
|
detection (`::1`, `::ffff:127.0.0.1`) and routes `npm run start` /
|
||||||
|
`start:bun` through `custom-server.js`
|
||||||
|
- **Search**: `resolveBaseUrl()` rejects client-supplied non-public baseUrls
|
||||||
|
(SSRF guard on `/v1/search`)
|
||||||
|
- **Login**: fresh-install remote login with the default password returns 403
|
||||||
|
without issuing a JWT
|
||||||
|
- **Usage**: `/api/usage/request-details` redacts request/response payloads
|
||||||
|
|
||||||
# v0.5.50 (2026-08-05)
|
# v0.5.50 (2026-08-05)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ COPY --from=builder /app/src/mitm ./src/mitm
|
|||||||
COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge
|
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 && \
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "9router",
|
"name": "9router",
|
||||||
"version": "0.5.50",
|
"version": "0.5.55",
|
||||||
"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,7 +216,9 @@ 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.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n");
|
console.error("❌ custom-server.js not found — without it no request can be proven local,");
|
||||||
|
console.error(" so the packaged CLI would demand an API key for its own dashboard and /v1.");
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.
|
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ const PROVIDER_MODELS = {
|
|||||||
{ id: "glm-4.7" },
|
{ 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" },
|
||||||
|
|||||||
+23
-1
@@ -1,9 +1,18 @@
|
|||||||
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() {
|
||||||
@@ -57,7 +66,9 @@ 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);
|
||||||
};
|
};
|
||||||
@@ -114,4 +125,15 @@ http.createServer = (...args) => {
|
|||||||
return server;
|
return server;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (require.main === module) require("./server.js");
|
if (require.main === module) {
|
||||||
|
const standalone = path.join(__dirname, "server.js");
|
||||||
|
if (fs.existsSync(standalone)) {
|
||||||
|
require(standalone);
|
||||||
|
} else {
|
||||||
|
// Repo checkout has no standalone build next to us. `next start` builds its HTTP
|
||||||
|
// server in-process, so the wrapper above still sanitizes every request.
|
||||||
|
const nextBin = require.resolve("next/dist/bin/next");
|
||||||
|
process.argv = [process.argv[0], nextBin, "start", ...process.argv.slice(2)];
|
||||||
|
require(nextBin);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
+1445
File diff suppressed because it is too large.
Load diff
+1445
File diff suppressed because it is too large.
Load diff
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, normalizeModelId } from "../providers/models/schema.js";
|
import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js";
|
||||||
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
|
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
|
||||||
export { PROVIDER_MODELS };
|
export { PROVIDER_MODELS };
|
||||||
|
|
||||||
@@ -54,6 +54,14 @@ 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,6 +245,18 @@ 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,7 +10,6 @@ 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";
|
||||||
@@ -42,7 +41,6 @@ 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
|
||||||
@@ -88,7 +86,6 @@ 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";
|
||||||
|
|||||||
+86
-30
@@ -144,6 +144,12 @@ 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";
|
||||||
@@ -711,14 +717,25 @@ 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()) {
|
||||||
const input = parsedToolInput(tool);
|
// Validate per tool, not per turn: one unusable fragment used to throw out
|
||||||
if (tool.name === "tool_call") {
|
// of emitTools and take every other complete tool call in the same turn
|
||||||
if (typeof input.name !== "string" || !input.name.trim()) {
|
// with it, which the client saw as a turn that answered nothing.
|
||||||
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name");
|
let input;
|
||||||
}
|
try {
|
||||||
if (!Object.prototype.hasOwnProperty.call(input, "arguments")) {
|
input = parsedToolInput(tool);
|
||||||
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments");
|
if (tool.name === "tool_call") {
|
||||||
|
if (typeof input.name !== "string" || !input.name.trim()) {
|
||||||
|
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name");
|
||||||
|
}
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(input, "arguments")) {
|
||||||
|
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
state.droppedTools = (state.droppedTools || 0) + 1;
|
||||||
|
state.toolValidationError ||= error.message;
|
||||||
|
console.error(`[Kiro] dropping unusable tool call ${tool.id} (${tool.name}): ${error.message}`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
const index = state.toolCounter++;
|
const index = state.toolCounter++;
|
||||||
emitDelta(controller, {
|
emitDelta(controller, {
|
||||||
@@ -729,14 +746,26 @@ 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: JSON.stringify(input) } }]
|
tool_calls: [{ index, function: { arguments: serializedInput } }]
|
||||||
});
|
});
|
||||||
|
// Tool arguments are billed output like any other completion bytes. They
|
||||||
|
// were never added to totalContentLength, so the /4 estimator in finish()
|
||||||
|
// reported OUT 0 -- or the Math.max floor of 1 -- for every turn whose
|
||||||
|
// entire answer was a tool call.
|
||||||
|
state.totalContentLength += tool.name.length + serializedInput.length;
|
||||||
state.hasToolCalls = true;
|
state.hasToolCalls = true;
|
||||||
}
|
}
|
||||||
state.tools.clear();
|
state.tools.clear();
|
||||||
state.bufferedToolBytes = 0;
|
state.bufferedToolBytes = 0;
|
||||||
if (state.stopReason === "tool_use" && !state.hasToolCalls) {
|
// A declared tool turn that emitted no usable call is only fatal when the
|
||||||
|
// turn produced nothing else. Throwing unconditionally here escaped
|
||||||
|
// emitTools() with provenance "invalid_tool_call", which the integrity gate
|
||||||
|
// re-derived into a repair retry -- discarding text the client had already
|
||||||
|
// been promised.
|
||||||
|
if (state.stopReason === "tool_use" && !state.hasToolCalls &&
|
||||||
|
!state.hasText && !state.hasReasoning && !state.hasCode) {
|
||||||
throw new Error("Kiro tool_use stop reason did not include a complete tool call");
|
throw new Error("Kiro tool_use stop reason did not include a complete tool call");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -796,7 +825,6 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
emitDelta(controller, { content: event.payload.content });
|
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) {
|
||||||
@@ -924,9 +952,10 @@ 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;
|
||||||
state.tools.clear();
|
console.error(`[Kiro] tool fragment rejected, keeping ${state.tools.size} buffered tool(s): ${error.message}`);
|
||||||
state.bufferedToolBytes = 0;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
fail(
|
fail(
|
||||||
@@ -958,7 +987,16 @@ 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);
|
||||||
if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) {
|
// model_context_window_exceeded / max_tokens map to terminal_incomplete. When
|
||||||
|
// they arrive after the model already streamed content, fail() threw away a
|
||||||
|
// complete-enough answer; a truncated turn is what finish_reason "length" is
|
||||||
|
// for. chunkIndex > 0 means at least one delta already reached the client.
|
||||||
|
const declaredTruncatedAfterOutput = declaredDisposition === "terminal_incomplete" &&
|
||||||
|
KIRO_TRUNCATION_STOP_REASONS.has(state.stopReason) && state.chunkIndex > 0;
|
||||||
|
if (declaredTruncatedAfterOutput) {
|
||||||
|
console.error(`[Kiro] truncated after ${state.chunkIndex} chunk(s) (stop_reason=${state.stopReason}); keeping output`);
|
||||||
|
}
|
||||||
|
if (!declaredTruncatedAfterOutput && ["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) {
|
||||||
const code = declaredDisposition === "retryable_protocol_failure"
|
const code = declaredDisposition === "retryable_protocol_failure"
|
||||||
? "kiro_retryable_protocol_failure"
|
? "kiro_retryable_protocol_failure"
|
||||||
: declaredDisposition === "terminal_refusal"
|
: declaredDisposition === "terminal_refusal"
|
||||||
@@ -975,16 +1013,6 @@ 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) {
|
||||||
@@ -997,6 +1025,22 @@ 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) {
|
||||||
@@ -1011,7 +1055,13 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disposition = stopDisposition(state.stopReason, state.hasToolCalls);
|
const disposition = stopDisposition(state.stopReason, state.hasToolCalls);
|
||||||
if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) {
|
// Same reasoning as declaredTruncatedAfterOutput above.
|
||||||
|
const truncatedAfterOutput = disposition === "terminal_incomplete" &&
|
||||||
|
KIRO_TRUNCATION_STOP_REASONS.has(state.stopReason) && state.chunkIndex > 0;
|
||||||
|
if (truncatedAfterOutput) {
|
||||||
|
console.error(`[Kiro] truncated after ${state.chunkIndex} chunk(s) (stop_reason=${state.stopReason}); closing as length`);
|
||||||
|
}
|
||||||
|
if (!truncatedAfterOutput && ["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) {
|
||||||
const code = disposition === "retryable_protocol_failure"
|
const code = disposition === "retryable_protocol_failure"
|
||||||
? "kiro_retryable_protocol_failure"
|
? "kiro_retryable_protocol_failure"
|
||||||
: disposition === "terminal_refusal"
|
: disposition === "terminal_refusal"
|
||||||
@@ -1041,18 +1091,24 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
total_tokens: prompt + completion
|
total_tokens: prompt + completion
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const finishReason = state.hasToolCalls
|
const finishReason = truncatedAfterOutput
|
||||||
? "tool_calls"
|
? "length"
|
||||||
: disposition === "length"
|
: state.hasToolCalls
|
||||||
? "length"
|
? "tool_calls"
|
||||||
: "stop";
|
: disposition === "length"
|
||||||
|
? "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,
|
||||||
stop_disposition: disposition
|
// Report what this exit actually did, not the raw disposition. The
|
||||||
|
// integrity gate re-derives its verdict from stop_disposition, so
|
||||||
|
// reporting "terminal_incomplete" for a turn we deliberately kept made
|
||||||
|
// it discard the very bytes we just released to the client.
|
||||||
|
stop_disposition: truncatedAfterOutput ? "length" : disposition
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { BaseExecutor } from "./base.js";
|
|
||||||
import { PROVIDERS } from "../config/providers.js";
|
|
||||||
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
|
||||||
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
|
|
||||||
|
|
||||||
// Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth)
|
|
||||||
const MESSAGES_FORMAT_MODELS = new Set([
|
|
||||||
"minimax-m3",
|
|
||||||
"minimax-m2.7",
|
|
||||||
"minimax-m2.5",
|
|
||||||
"qwen3.7-max",
|
|
||||||
"qwen3.7-plus",
|
|
||||||
"qwen3.6-plus",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const BASE = "https://opencode.ai/zen/go/v1";
|
|
||||||
|
|
||||||
export class OpenCodeGoExecutor extends BaseExecutor {
|
|
||||||
constructor() {
|
|
||||||
super("opencode-go", PROVIDERS["opencode-go"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here
|
|
||||||
buildUrl(model) {
|
|
||||||
this._lastModel = model;
|
|
||||||
return MESSAGES_FORMAT_MODELS.has(model)
|
|
||||||
? `${BASE}/messages`
|
|
||||||
: `${BASE}/chat/completions`;
|
|
||||||
}
|
|
||||||
|
|
||||||
buildHeaders(credentials, stream = true) {
|
|
||||||
const key = credentials?.apiKey || credentials?.accessToken;
|
|
||||||
const headers = { "Content-Type": "application/json" };
|
|
||||||
|
|
||||||
if (MESSAGES_FORMAT_MODELS.has(this._lastModel)) {
|
|
||||||
headers["x-api-key"] = key;
|
|
||||||
headers["anthropic-version"] = ANTHROPIC_API_VERSION;
|
|
||||||
} else {
|
|
||||||
headers["Authorization"] = `Bearer ${key}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream) headers["Accept"] = "text/event-stream";
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
transformRequest(model, body) {
|
|
||||||
return injectReasoningContent({ provider: this.provider, model, body });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,35 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
// Models that use /zen/v1/messages (claude format)
|
const OPENCODE_UA = "opencode";
|
||||||
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",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// OpenCode free tier is limited per egress IP — a 429/403 with a limit-ish
|
// OpenCode free tier is limited per egress IP — a 429/403 with a limit-ish
|
||||||
// body means the POOL's IP is exhausted, not the account. Declare it
|
// body means the POOL's IP is exhausted, not the account. Declare it
|
||||||
// pool-scoped so chatCore marks the pool unfit, retries via another pool, and
|
// pool-scoped so chatCore marks the pool unfit, retries via another pool, and
|
||||||
@@ -14,9 +39,11 @@ const IP_LIMIT_BODY = /limit|rate|quota|exhausted|capacity|too many|retry/i;
|
|||||||
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) {
|
transformRequest(model, body, stream, credentials) {
|
||||||
|
this._currentSessionId = resolveOpencodeSession(body, credentials);
|
||||||
return injectReasoningContent({ provider: this.provider, model, body });
|
return injectReasoningContent({ provider: this.provider, model, body });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,12 +54,23 @@ export class OpenCodeExecutor extends BaseExecutor {
|
|||||||
: `${base}/zen/v1/chat/completions`;
|
: `${base}/zen/v1/chat/completions`;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildHeaders() {
|
buildHeaders(credentials, stream = true) {
|
||||||
|
const raw = credentials?.rawHeaders || {};
|
||||||
|
const lower = {};
|
||||||
|
for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v;
|
||||||
|
|
||||||
|
const downstreamUa = lower["user-agent"] || "";
|
||||||
|
const isOpencodeDownstream = downstreamUa.toLowerCase().includes("opencode");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": "Bearer public",
|
"Authorization": "Bearer public",
|
||||||
"x-opencode-client": "desktop",
|
"User-Agent": isOpencodeDownstream ? downstreamUa : OPENCODE_UA,
|
||||||
"Accept": "text/event-stream"
|
"x-opencode-client": lower["x-opencode-client"] || "desktop",
|
||||||
|
"x-opencode-session": lower["x-opencode-session"] || this._currentSessionId || generateSessionId(),
|
||||||
|
"x-opencode-request": lower["x-opencode-request"] || generateRequestId(),
|
||||||
|
"x-opencode-project": lower["x-opencode-project"] || "global",
|
||||||
|
"Accept": stream ? "text/event-stream" : "*/*",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,52 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a qoder error message indicates a billing/quota block.
|
||||||
|
* Signatures: code 112 (quota exhausted), code 10605 (queue throttle), pricingUrl field.
|
||||||
|
*/
|
||||||
|
function isBillingBlock(inner) {
|
||||||
|
if (!inner || typeof inner !== "string") return false;
|
||||||
|
const lowerMsg = inner.toLowerCase();
|
||||||
|
// Match: {"code":"112",...}, {"code":"10605",...}, or pricingUrl field
|
||||||
|
return /\"code\"\s*:\s*\"(112|10605)\"/.test(inner) || lowerMsg.includes("pricingurl");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peek the first SSE frame to detect billing errors before piping.
|
||||||
|
* Returns { isBilling, statusVal, message, consumed } — `consumed` is every
|
||||||
|
* byte read so far (including the peeked line) so the caller can re-process
|
||||||
|
* it and nothing is dropped from the stream.
|
||||||
|
*/
|
||||||
|
async function peekFirstQoderFrame(reader, decoder) {
|
||||||
|
let consumed = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) return { isBilling: false, consumed, upstreamDone: true };
|
||||||
|
|
||||||
|
consumed += decoder.decode(value, { stream: true });
|
||||||
|
const nl = consumed.indexOf("\n");
|
||||||
|
if (nl === -1) continue; // need a full line first
|
||||||
|
|
||||||
|
const line = consumed.slice(0, nl).replace(/\r$/, "").trim();
|
||||||
|
if (!line.startsWith("data:")) continue;
|
||||||
|
|
||||||
|
const data = line.slice(5).trimStart();
|
||||||
|
if (data === "[DONE]") return { isBilling: false, consumed };
|
||||||
|
|
||||||
|
let envelope;
|
||||||
|
try { envelope = JSON.parse(data); } catch { return { isBilling: false, consumed }; }
|
||||||
|
|
||||||
|
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
|
||||||
|
const inner = typeof envelope.body === "string" ? envelope.body : "";
|
||||||
|
|
||||||
|
if (statusVal !== 200 && isBillingBlock(inner)) {
|
||||||
|
return { isBilling: true, statusVal, message: inner || `qoder billing block (${statusVal})` };
|
||||||
|
}
|
||||||
|
return { isBilling: false, consumed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap the upstream's `{statusCodeValue, body}` SSE envelope into plain
|
* 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.
|
||||||
@@ -229,16 +275,34 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
|
|||||||
* [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.
|
||||||
*/
|
*/
|
||||||
function wrapQoderSSE(response, model) {
|
async 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 encoder = new TextEncoder();
|
|
||||||
let buffer = "";
|
|
||||||
let doneEmitted = false;
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
|
|
||||||
|
// Peek first frame to detect billing block
|
||||||
|
const peek = await peekFirstQoderFrame(reader, decoder);
|
||||||
|
if (peek?.isBilling) {
|
||||||
|
// Billing block detected — return 403 so chatCore fails this connection
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: { message: peek.message, code: peek.statusVal } }),
|
||||||
|
{ status: 403, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal flow: re-process every byte the peek consumed, then continue.
|
||||||
|
let buffer = peek.consumed || "";
|
||||||
|
const upstreamDrained = peek.upstreamDone === true;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let doneEmitted = false;
|
||||||
|
|
||||||
// Process one already-extracted SSE line (no trailing newline).
|
// Process one already-extracted SSE line (no trailing newline).
|
||||||
const processLine = (line, controller) => {
|
const processLine = (line, controller) => {
|
||||||
const trimmed = line.replace(/\r$/, "").trim();
|
const trimmed = line.replace(/\r$/, "").trim();
|
||||||
@@ -287,7 +351,28 @@ 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 {
|
||||||
while (!doneEmitted) {
|
// Drain whatever the peek already pulled off the socket first.
|
||||||
|
let nlSeed;
|
||||||
|
while ((nlSeed = buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = buffer.slice(0, nlSeed);
|
||||||
|
buffer = buffer.slice(nlSeed + 1);
|
||||||
|
processLine(line, controller);
|
||||||
|
if (doneEmitted) {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (upstreamDrained) {
|
||||||
|
// Peek hit end-of-stream: flush any trailing partial line.
|
||||||
|
buffer += decoder.decode();
|
||||||
|
if (buffer.length > 0) {
|
||||||
|
processLine(buffer, controller);
|
||||||
|
buffer = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!doneEmitted && !upstreamDrained) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) {
|
if (done) {
|
||||||
buffer += decoder.decode();
|
buffer += decoder.decode();
|
||||||
@@ -472,7 +557,7 @@ export class QoderExecutor extends BaseExecutor {
|
|||||||
return { response, url, headers, transformedBody: payload };
|
return { response, url, headers, transformedBody: payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`);
|
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`);
|
||||||
return { response: wrapped, url, headers, transformedBody: payload };
|
return { response: wrapped, url, headers, transformedBody: payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,4 +581,5 @@ 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 } from "../translator/formats/claude.js";
|
import { normalizeClaudePassthrough, anchorClaudeCache } 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, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
import { getModelTargetFormat, getModelSupportedFormats, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||||
import { PROVIDERS } from "../config/providers.js";
|
import { 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";
|
||||||
@@ -84,10 +84,20 @@ 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);
|
||||||
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider, credentials);
|
// Per-model guard: when a model declares supportedFormats, only use the
|
||||||
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
|
// sourceFormat-matched transport if that format is declared (opencode-go models
|
||||||
|
// differ — kimi/glm only do /chat/completions). Undeclared models keep the
|
||||||
|
// upstream default (use the transport), preserving behavior for glm/deepseek/...
|
||||||
|
const useTransport = (!modelSupportedFormats || modelSupportedFormats.includes(sourceFormat)) ? runtimeTransport : null;
|
||||||
|
const targetFormat = modelTargetFormat || useTransport?.format || getTargetFormat(provider, credentials);
|
||||||
|
if (useTransport && credentials) credentials.runtimeTransport = useTransport;
|
||||||
const stripList = getModelStrip(alias, model);
|
const stripList = getModelStrip(alias, model);
|
||||||
const upstreamModel = getModelUpstreamId(alias, model);
|
const upstreamModel = getModelUpstreamId(alias, model);
|
||||||
|
|
||||||
@@ -281,6 +291,10 @@ 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,13 +44,14 @@ export function extractUsageFromResponse(responseBody) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gemini format
|
// Gemini format. Antigravity / gemini-cli wrap the payload in { response: {...} }.
|
||||||
if (responseBody.usageMetadata) {
|
const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata;
|
||||||
|
if (usageMetadata) {
|
||||||
return {
|
return {
|
||||||
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
|
prompt_tokens: usageMetadata.promptTokenCount || 0,
|
||||||
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
|
completion_tokens: usageMetadata.candidatesTokenCount || 0,
|
||||||
cached_tokens: responseBody.usageMetadata.cachedContentTokenCount || 0,
|
cached_tokens: usageMetadata.cachedContentTokenCount || 0,
|
||||||
reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount || 0
|
reasoning_tokens: usageMetadata.thoughtsTokenCount || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
* @property {Record<string,unknown>} [providerSpecificData]
|
* @property {Record<string,unknown>} [providerSpecificData]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { assertPublicUrl } from "../../../src/shared/utils/ssrfGuard.js";
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,12 +65,31 @@ 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,6 +51,25 @@ 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, {
|
||||||
@@ -166,4 +185,5 @@ export const FORMAT_HANDLERS = {
|
|||||||
tortoise,
|
tortoise,
|
||||||
openai: openaiCompat,
|
openai: openaiCompat,
|
||||||
"minimax-tts": minimaxTts,
|
"minimax-tts": minimaxTts,
|
||||||
|
"fish-audio": fishAudio,
|
||||||
};
|
};
|
||||||
@@ -205,6 +205,7 @@ 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,3 +38,11 @@ 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,6 +57,10 @@ export const MODEL_PRICING = {
|
|||||||
"o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
|
"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 },
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Token Plan — credit subscription keys on token-plan.<region>.maas.aliyuncs.com.
|
||||||
|
// Fourth Alibaba key type: Coding Plan (alicode/alicode-intl) and Model Studio
|
||||||
|
// (alims-intl) both reject these keys, and they reject Model Studio keys back.
|
||||||
|
// Singapore is the only region that serves the plan; eu-central-1 answers
|
||||||
|
// IllegalEndpoint. The Anthropic surface (/apps/anthropic/v1/messages) is not
|
||||||
|
// authorized for this plan, so OpenAI-compatible mode is the only transport.
|
||||||
|
export default {
|
||||||
|
id: "alitp-intl",
|
||||||
|
priority: 11,
|
||||||
|
alias: "alitp-intl",
|
||||||
|
display: {
|
||||||
|
name: "Alibaba Token Plan",
|
||||||
|
icon: "cloud",
|
||||||
|
color: "#FF6A00",
|
||||||
|
textIcon: "ATP",
|
||||||
|
website: "https://www.alibabacloud.com/campaign/ai-landing-page-token",
|
||||||
|
notice: {
|
||||||
|
apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: "apikey",
|
||||||
|
transport: {
|
||||||
|
baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||||
|
headers: {},
|
||||||
|
quirks: { preserveCacheControl: true },
|
||||||
|
},
|
||||||
|
models: [
|
||||||
|
{ id: "qwen3.8-max-preview", name: "Qwen3.8 Max Preview" },
|
||||||
|
{ id: "qwen3.7-max", name: "Qwen3.7 Max" },
|
||||||
|
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus" },
|
||||||
|
{ id: "qwen3.6-flash", name: "Qwen3.6 Flash" },
|
||||||
|
{ id: "glm-5.2", name: "GLM 5.2" },
|
||||||
|
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -45,6 +45,9 @@ export default {
|
|||||||
clientSecret: "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",
|
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)" },
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Fish Audio TTS — the model id travels in an HTTP `model` header rather than the
|
||||||
|
// JSON body, and the voice is a reference_id (a cloned or preset voice model).
|
||||||
|
export default {
|
||||||
|
id: "fish-audio",
|
||||||
|
alias: "fish",
|
||||||
|
display: {
|
||||||
|
name: "Fish Audio",
|
||||||
|
icon: "record_voice_over",
|
||||||
|
color: "#1E9BF0",
|
||||||
|
textIcon: "FA",
|
||||||
|
website: "https://fish.audio",
|
||||||
|
notice: {
|
||||||
|
apiKeyUrl: "https://fish.audio/app/api-keys/",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: "apikey",
|
||||||
|
authType: "apikey",
|
||||||
|
serviceKinds: ["tts"],
|
||||||
|
ttsConfig: {
|
||||||
|
baseUrl: "https://api.fish.audio/v1/tts",
|
||||||
|
authType: "apikey",
|
||||||
|
authHeader: "bearer",
|
||||||
|
format: "fish-audio",
|
||||||
|
models: [
|
||||||
|
{ id: "s2.1-pro-free", name: "S2.1 Pro Free" },
|
||||||
|
{ id: "s2.1-pro", name: "S2.1 Pro" },
|
||||||
|
{ id: "s2-pro", name: "S2 Pro" },
|
||||||
|
{ id: "s1", name: "S1" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -36,6 +36,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
models: [
|
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,6 +21,7 @@ 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,6 +45,7 @@ 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" },
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ import p115 from "./tokenrouter.js";
|
|||||||
import p116 from "./selfhosted-stt.js";
|
import p116 from "./selfhosted-stt.js";
|
||||||
import p117 from "./selfhosted-tts.js";
|
import p117 from "./selfhosted-tts.js";
|
||||||
import p118 from "./selfhosted-embedding.js";
|
import p118 from "./selfhosted-embedding.js";
|
||||||
|
import p119 from "./fish-audio.js";
|
||||||
|
import p120 from "./alitp-intl.js";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
p0,
|
p0,
|
||||||
@@ -239,4 +241,6 @@ export default [
|
|||||||
p116,
|
p116,
|
||||||
p117,
|
p117,
|
||||||
p118,
|
p118,
|
||||||
|
p119,
|
||||||
|
p120,
|
||||||
];
|
];
|
||||||
@@ -14,7 +14,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "freeTier",
|
category: "freeTier",
|
||||||
authModes: ["oauth"],
|
authModes: ["oauth", "apikey"],
|
||||||
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,20 +22,28 @@ 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" },
|
{ id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] },
|
||||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
{ id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] },
|
||||||
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
|
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] },
|
||||||
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
{ id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] },
|
||||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] },
|
||||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
|
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] },
|
||||||
{ id: "mimo-v2.5", name: "MiMo V2.5" },
|
{ id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] },
|
||||||
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
|
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] },
|
||||||
{ id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude" },
|
{ id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] },
|
||||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
|
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] },
|
||||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
|
{ id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] },
|
||||||
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", targetFormat: "claude" },
|
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] },
|
||||||
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", targetFormat: "claude" },
|
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] },
|
||||||
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", targetFormat: "claude" },
|
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -138,8 +138,42 @@ 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)) scanContent(m.content); // openai / claude
|
for (const m of trailingUserItems(body.messages)) scanMessage(m); // openai / claude / hermes / ollama
|
||||||
for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses
|
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);
|
||||||
@@ -530,7 +564,10 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
|
|||||||
log.info("FUSION", `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`);
|
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, ...rest } = body;
|
const { tools, tool_choice, stream_options, ...rest } = body;
|
||||||
|
// Fusion runs panel models non-streaming; drop stream_options too, or providers
|
||||||
|
// like DeepSeek reject it with "stream_options should be set along with stream = true".
|
||||||
|
// See issue #3024.
|
||||||
const panelBody = { ...rest, stream: false };
|
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.
|
||||||
|
|||||||
@@ -34,7 +34,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),
|
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }),
|
||||||
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) => {
|
||||||
@@ -58,7 +58,7 @@ const USAGE_HANDLERS = {
|
|||||||
freebuff: (c) => getFreebuffUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
freebuff: (c) => getFreebuffUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getUsageForProvider(connection, proxyOptions = null) {
|
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
|
||||||
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
|
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
|
||||||
const providerDataWithProjectId = {
|
const providerDataWithProjectId = {
|
||||||
...(providerSpecificData || {}),
|
...(providerSpecificData || {}),
|
||||||
@@ -67,5 +67,13 @@ export async function getUsageForProvider(connection, proxyOptions = null) {
|
|||||||
|
|
||||||
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({ provider, accessToken, apiKey, providerSpecificData, providerDataWithProjectId, proxyOptions });
|
return await handler({
|
||||||
|
provider,
|
||||||
|
accessToken,
|
||||||
|
apiKey,
|
||||||
|
providerSpecificData,
|
||||||
|
providerDataWithProjectId,
|
||||||
|
proxyOptions,
|
||||||
|
force: options.force === true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,43 @@ const CLAUDE_CONFIG = {
|
|||||||
const OAUTH_429_COOLDOWN_MS = 180000;
|
const OAUTH_429_COOLDOWN_MS = 180000;
|
||||||
const oauthCooldown = new Map();
|
const oauthCooldown = new Map();
|
||||||
|
|
||||||
export async function getClaudeUsage(accessToken, proxyOptions = null) {
|
// Dedup + short TTL cache per access token. Many tabs / many accounts / auto-refresh
|
||||||
|
// all funnel through here; without this each call hits Anthropic and triggers 429.
|
||||||
|
const USAGE_CACHE_TTL_MS = 300000;
|
||||||
|
const usageCache = new Map(); // token -> { promise } | { result, expiresAt }
|
||||||
|
|
||||||
|
export async function getClaudeUsage(accessToken, proxyOptions = null, options = {}) {
|
||||||
|
const force = options?.force === true;
|
||||||
|
|
||||||
|
// Serve in-flight or fresh cached result (skip on manual force)
|
||||||
|
if (!force && accessToken) {
|
||||||
|
const hit = usageCache.get(accessToken);
|
||||||
|
if (hit?.promise) return hit.promise;
|
||||||
|
if (hit && hit.expiresAt > Date.now()) return hit.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stale = (!force && accessToken && usageCache.get(accessToken)?.result) || null;
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
const result = await fetchClaudeUsageRaw(accessToken, proxyOptions);
|
||||||
|
// Only cache real quota data, not soft-failure {message: ...} payloads
|
||||||
|
if (accessToken && result?.quotas) {
|
||||||
|
usageCache.set(accessToken, {
|
||||||
|
result,
|
||||||
|
expiresAt: Date.now() + USAGE_CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// Soft failure (429/error): prefer the last good read over a transient error
|
||||||
|
if (stale) return stale;
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (accessToken) usageCache.set(accessToken, { promise });
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
|
||||||
try {
|
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,6 +161,9 @@ 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,6 +62,19 @@ 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,6 +9,9 @@ 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;
|
||||||
@@ -124,32 +127,38 @@ 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. Hoist mid-conversation system messages into the top-level system field
|
// 2. Fold mid-conversation system messages into the neighbouring turn.
|
||||||
|
// Hoisting them into body.system would insert volatile content (token counters,
|
||||||
|
// reminders) ahead of the whole conversation and invalidate the prefix cache on
|
||||||
|
// every request. Folding in place keeps the cached prefix stable.
|
||||||
if (Array.isArray(body.messages)) {
|
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) {
|
||||||
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 (text.trim()) systemBlocks.push({ type: CLAUDE_BLOCK.TEXT, text });
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
messages.push(msg);
|
const text = typeof msg.content === "string"
|
||||||
}
|
? msg.content
|
||||||
|
: Array.isArray(msg.content)
|
||||||
|
? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n")
|
||||||
|
: "";
|
||||||
|
if (!text.trim()) continue;
|
||||||
|
|
||||||
if (systemBlocks.length > 0) {
|
// Copy-on-write: the caller's body is reused across account-fallback
|
||||||
const existing = Array.isArray(body.system)
|
// attempts, so folding must never mutate the original message.
|
||||||
? body.system
|
const block = { type: CLAUDE_BLOCK.TEXT, text };
|
||||||
: typeof body.system === "string" && body.system.trim()
|
const prev = messages[messages.length - 1];
|
||||||
? [{ type: "text", text: body.system }]
|
if (prev?.role === ROLE.USER) {
|
||||||
: [];
|
const content = typeof prev.content === "string"
|
||||||
body.system = [...existing, ...systemBlocks];
|
? [{ type: CLAUDE_BLOCK.TEXT, text: prev.content }]
|
||||||
body.messages = messages;
|
: 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,
|
||||||
@@ -182,6 +191,70 @@ 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
|
||||||
|
|||||||
@@ -287,6 +287,18 @@ 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,6 +421,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -379,6 +379,18 @@ 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,6 +75,15 @@ 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.
|
||||||
@@ -254,6 +263,13 @@ 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
|
// Handle tool_calls (empty array is truthy; require a real call)
|
||||||
if (delta.tool_calls) {
|
if (delta.tool_calls && delta.tool_calls.length) {
|
||||||
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);
|
||||||
|
|||||||
+6
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "0.5.50",
|
"version": "0.5.55",
|
||||||
"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": "next start --port 20127",
|
"start": "node custom-server.js --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/server.js",
|
"start:bun": "bun ./.next/standalone/custom-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,8 +23,10 @@
|
|||||||
"@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",
|
||||||
"dompurify": "^3.4.13",
|
"dompurify": "^3.4.13",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"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,6 +29,14 @@ 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`;
|
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n api_key: \${OPENAI_API_KEY}\n`;
|
||||||
const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
|
const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -31,7 +31,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("en");
|
const [locale, setLocale] = useState(() => getLocaleFromCookie());
|
||||||
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);
|
||||||
@@ -56,8 +56,31 @@ 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 [memoryInfo, setMemoryInfo] = useState(null);
|
const [memoryInfo, setMemoryInfo] = useState(null);
|
||||||
const [memoryLoading, setMemoryLoading] = useState(false);
|
const [memoryLoading, setMemoryLoading] = useState(false);
|
||||||
@@ -70,10 +93,6 @@ 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())
|
||||||
@@ -87,7 +106,23 @@ export default function ProfilePage() {
|
|||||||
oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC",
|
oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC",
|
||||||
});
|
});
|
||||||
setOidcClientSecret("");
|
setOidcClientSecret("");
|
||||||
if (data?.authMode === "oidc" || data?.authMode === "both") setOidcExpanded(true);
|
setSsoTypeTab(data?.ssoType || "saml");
|
||||||
|
setSamlForm({
|
||||||
|
samlEntryPoint: data?.samlEntryPoint || "",
|
||||||
|
samlIssuer: data?.samlIssuer || "urn:9router:sp",
|
||||||
|
samlCert: data?.samlCert || "",
|
||||||
|
samlLoginLabel: data?.samlLoginLabel || "Sign in with SAML SSO",
|
||||||
|
samlAttributeEmail: data?.samlAttributeEmail || "email",
|
||||||
|
samlAttributeName: data?.samlAttributeName || "name",
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
data?.authMode === "sso" ||
|
||||||
|
data?.authMode === "saml" ||
|
||||||
|
data?.authMode === "oidc" ||
|
||||||
|
data?.authMode === "both"
|
||||||
|
) {
|
||||||
|
setOidcExpanded(true);
|
||||||
|
}
|
||||||
setProxyForm({
|
setProxyForm({
|
||||||
outboundProxyEnabled: data?.outboundProxyEnabled === true,
|
outboundProxyEnabled: data?.outboundProxyEnabled === true,
|
||||||
outboundProxyUrl: data?.outboundProxyUrl || "",
|
outboundProxyUrl: data?.outboundProxyUrl || "",
|
||||||
@@ -101,12 +136,6 @@ 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;
|
||||||
@@ -343,6 +372,7 @@ 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",
|
||||||
@@ -457,6 +487,159 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateSamlForm = (field, value) => {
|
||||||
|
setSamlForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIdpMetadataUpload = (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (idpMetadataFileRef.current) idpMetadataFileRef.current.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const xmlText = e.target?.result || "";
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(xmlText, "text/xml");
|
||||||
|
const parserError = doc.querySelector("parsererror");
|
||||||
|
if (parserError) {
|
||||||
|
setSamlStatus({ type: "error", message: "Unable to parse valid SAML IdP metadata from XML file" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityID = doc.documentElement.getAttribute("entityID") || "";
|
||||||
|
const ssoNodes = Array.from(doc.querySelectorAll("SingleSignOnService, *|SingleSignOnService"));
|
||||||
|
let ssoUrl = "";
|
||||||
|
for (const node of ssoNodes) {
|
||||||
|
const binding = node.getAttribute("Binding") || "";
|
||||||
|
const location = node.getAttribute("Location") || "";
|
||||||
|
if (location) {
|
||||||
|
ssoUrl = location;
|
||||||
|
if (binding.includes("HTTP-Redirect")) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const certNodes = Array.from(doc.querySelectorAll("X509Certificate, *|X509Certificate"));
|
||||||
|
let certStr = "";
|
||||||
|
if (certNodes.length > 0) {
|
||||||
|
certStr = certNodes[0].textContent.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSamlForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
samlEntryPoint: ssoUrl || prev.samlEntryPoint,
|
||||||
|
samlIssuer: prev.samlIssuer || "urn:9router:sp",
|
||||||
|
samlCert: certStr || prev.samlCert,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSamlStatus({
|
||||||
|
type: "success",
|
||||||
|
message: `IdP Metadata imported! (SSO URL: ${ssoUrl ? "found" : "not found"}, EntityID: ${entityID ? "found" : "not found"}, Cert: ${certStr ? "found" : "not found"})`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setSamlStatus({ type: "error", message: "Error reading IdP Metadata XML file" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCertFileUpload = (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (certFileRef.current) certFileRef.current.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const text = e.target?.result || "";
|
||||||
|
setSamlForm((prev) => ({ ...prev, samlCert: text.trim() }));
|
||||||
|
setSamlStatus({ type: "success", message: "Certificate file loaded into configuration." });
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSamlSettings = async (targetAuthMode = oidcForm.authMode || "password") => {
|
||||||
|
setSamlLoading(true);
|
||||||
|
setSamlStatus({ type: "", message: "" });
|
||||||
|
setSamlTestStatus({ type: "", message: "" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
authMode: targetAuthMode,
|
||||||
|
ssoType: "saml",
|
||||||
|
samlEntryPoint: samlForm.samlEntryPoint.trim(),
|
||||||
|
samlIssuer: samlForm.samlIssuer.trim() || "urn:9router:sp",
|
||||||
|
samlCert: samlForm.samlCert.trim(),
|
||||||
|
samlLoginLabel: samlForm.samlLoginLabel.trim() || "Sign in with SAML SSO",
|
||||||
|
samlAttributeEmail: samlForm.samlAttributeEmail.trim() || "email",
|
||||||
|
samlAttributeName: samlForm.samlAttributeName.trim() || "name",
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch("/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setSettings((prev) => ({ ...prev, ...data }));
|
||||||
|
setSamlForm({
|
||||||
|
samlEntryPoint: data?.samlEntryPoint || payload.samlEntryPoint,
|
||||||
|
samlIssuer: data?.samlIssuer || payload.samlIssuer,
|
||||||
|
samlCert: data?.samlCert || payload.samlCert,
|
||||||
|
samlLoginLabel: data?.samlLoginLabel || payload.samlLoginLabel,
|
||||||
|
samlAttributeEmail: data?.samlAttributeEmail || payload.samlAttributeEmail,
|
||||||
|
samlAttributeName: data?.samlAttributeName || payload.samlAttributeName,
|
||||||
|
});
|
||||||
|
setSamlStatus({
|
||||||
|
type: "success",
|
||||||
|
message:
|
||||||
|
targetAuthMode === "sso" || targetAuthMode === "saml"
|
||||||
|
? "SAML SSO login enabled"
|
||||||
|
: targetAuthMode === "both"
|
||||||
|
? "Password and SAML SSO login enabled"
|
||||||
|
: "SAML 2.0 settings saved",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSamlStatus({ type: "error", message: data.error || "Failed to save SAML settings" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSamlStatus({ type: "error", message: "An error occurred while saving SAML settings" });
|
||||||
|
} finally {
|
||||||
|
setSamlLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testSamlConnection = async () => {
|
||||||
|
setSamlTestLoading(true);
|
||||||
|
setSamlStatus({ type: "", message: "" });
|
||||||
|
setSamlTestStatus({ type: "", message: "" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/saml/test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
samlEntryPoint: samlForm.samlEntryPoint.trim(),
|
||||||
|
samlIssuer: samlForm.samlIssuer.trim(),
|
||||||
|
samlCert: samlForm.samlCert.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.ok) {
|
||||||
|
setSamlTestStatus({ type: "success", message: data.message || "SAML configuration verified!" });
|
||||||
|
} else {
|
||||||
|
setSamlTestStatus({ type: "error", message: data.error || "SAML configuration test failed" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSamlTestStatus({ type: "error", message: "An error occurred while testing SAML configuration" });
|
||||||
|
} finally {
|
||||||
|
setSamlTestLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateObservabilityEnabled = async (enabled) => {
|
const updateObservabilityEnabled = async (enabled) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings", {
|
const res = await fetch("/api/settings", {
|
||||||
@@ -793,7 +976,7 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* OIDC */}
|
{/* Single Sign-On (SSO) */}
|
||||||
<Card>
|
<Card>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -804,9 +987,13 @@ 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">OIDC Dashboard Login</h3>
|
<h3 className="text-base sm:text-lg font-semibold">Single Sign-On (SSO)</h3>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
{settings.authMode === "oidc" ? "OIDC active" : settings.authMode === "both" ? "Password + OIDC active" : "Optional SSO via Authentik/Keycloak/Google"}
|
{settings.authMode === "sso" || settings.authMode === "oidc" || settings.authMode === "saml"
|
||||||
|
? `${settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"} SSO active`
|
||||||
|
: settings.authMode === "both"
|
||||||
|
? `Password + ${settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"} active`
|
||||||
|
: "Optional SSO via Okta, Entra ID, Keycloak, or OIDC"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="material-symbols-outlined text-text-muted shrink-0">
|
<span className="material-symbols-outlined text-text-muted shrink-0">
|
||||||
@@ -814,145 +1001,472 @@ 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">
|
||||||
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.
|
Configure enterprise Single Sign-On (SSO) for dashboard access using SAML 2.0 or OIDC.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
{/* SSO Protocol Switcher Tabs */}
|
||||||
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
|
<div className="flex flex-col gap-2">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<label className="font-medium text-sm sm:text-base">SSO Protocol</label>
|
||||||
{[
|
<div className="flex p-1 rounded-lg bg-black/5 dark:bg-white/5 border border-border">
|
||||||
{
|
<button
|
||||||
value: "password",
|
type="button"
|
||||||
title: "Password only",
|
onClick={() => setSsoTypeTab("saml")}
|
||||||
desc: "Keep the legacy password login.",
|
className={cn(
|
||||||
},
|
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
|
||||||
{
|
ssoTypeTab === "saml"
|
||||||
value: "oidc",
|
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||||
title: "OIDC only",
|
: "text-text-muted hover:text-text-main"
|
||||||
desc: "Require OIDC for dashboard access.",
|
)}
|
||||||
},
|
>
|
||||||
{
|
SAML 2.0
|
||||||
value: "both",
|
</button>
|
||||||
title: "Both",
|
<button
|
||||||
desc: "Allow either password or OIDC.",
|
type="button"
|
||||||
},
|
onClick={() => setSsoTypeTab("oidc")}
|
||||||
].map((option) => {
|
className={cn(
|
||||||
const active = oidcForm.authMode === option.value;
|
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
|
||||||
return (
|
ssoTypeTab === "oidc"
|
||||||
|
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||||
|
: "text-text-muted hover:text-text-main"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
OIDC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auth Mode selection */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
value: "password",
|
||||||
|
title: "Password only",
|
||||||
|
desc: "Keep legacy password login.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "sso",
|
||||||
|
title: `${ssoTypeTab === "saml" ? "SAML" : "OIDC"} only`,
|
||||||
|
desc: "Require SSO for dashboard access.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "both",
|
||||||
|
title: "Both",
|
||||||
|
desc: "Allow password or SSO login.",
|
||||||
|
},
|
||||||
|
].map((option) => {
|
||||||
|
const currentMode = oidcForm.authMode;
|
||||||
|
const active =
|
||||||
|
option.value === "password"
|
||||||
|
? currentMode === "password"
|
||||||
|
: option.value === "sso"
|
||||||
|
? currentMode === "sso" || currentMode === "saml" || currentMode === "oidc"
|
||||||
|
: currentMode === "both";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateOidcForm("authMode", option.value)}
|
||||||
|
className={cn(
|
||||||
|
"text-left rounded-lg border p-3 transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
)}
|
||||||
|
disabled={loading || oidcLoading || samlLoading}
|
||||||
|
>
|
||||||
|
<p className="font-medium text-sm sm:text-base">{option.title}</p>
|
||||||
|
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ssoTypeTab === "saml" ? (
|
||||||
|
/* SAML Configuration Panel */
|
||||||
|
<div className="flex flex-col gap-4 pt-2 border-t border-border/50">
|
||||||
|
{/* IdP Setup Guidelines Banner & Collapsible Drawer */}
|
||||||
|
<div className="rounded-lg border border-border bg-bg/80 overflow-hidden">
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateOidcForm("authMode", option.value)}
|
onClick={() => setShowSamlGuide((prev) => !prev)}
|
||||||
className={cn(
|
className="w-full p-3 flex items-center justify-between gap-2 text-left hover:bg-surface/50 transition-colors"
|
||||||
"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}
|
|
||||||
>
|
>
|
||||||
<p className="font-medium text-sm sm:text-base">{option.title}</p>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
|
<span className="material-symbols-outlined text-primary text-lg">menu_book</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-xs sm:text-sm text-text-main">
|
||||||
|
IdP Setup Guidelines & Provider Configuration Instructions
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-text-muted">
|
||||||
|
Click to view setup steps for AWS IAM Identity Center, Okta, Entra ID, Keycloak, & Authentik
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-text-muted transition-transform text-lg"
|
||||||
|
style={{ transform: showSamlGuide ? "rotate(180deg)" : "none" }}
|
||||||
|
>
|
||||||
|
expand_more
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
|
||||||
})}
|
{showSamlGuide && (
|
||||||
</div>
|
<div className="p-4 border-t border-border bg-surface/30 text-xs text-text-main flex flex-col gap-3">
|
||||||
|
<div className="p-2.5 rounded border border-primary/20 bg-primary/5 text-primary text-xs">
|
||||||
|
<p className="font-semibold mb-1">🔑 Required Service Provider (SP) Values for your IdP Setup:</p>
|
||||||
|
<ul className="list-disc pl-4 space-y-1 font-mono text-[11px]">
|
||||||
|
<li>
|
||||||
|
<b>Assertion Consumer Service (ACS) URL:</b>{" "}
|
||||||
|
<code className="bg-bg px-1 py-0.5 rounded break-all">{samlAcsUrl}</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>SP Entity ID / Audience URI:</b>{" "}
|
||||||
|
<code className="bg-bg px-1 py-0.5 rounded break-all">{samlForm.samlIssuer || "urn:9router:sp"}</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>NameID Format:</b>{" "}
|
||||||
|
<code className="bg-bg px-1 py-0.5 rounded">EmailAddress</code> or <code className="bg-bg px-1 py-0.5 rounded">Unspecified</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-1">
|
||||||
|
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
|
||||||
|
<p className="font-semibold text-text-main flex items-center gap-1.5">
|
||||||
|
<span>☁️</span> AWS IAM Identity Center
|
||||||
|
</p>
|
||||||
|
<ol className="list-decimal pl-4 text-text-muted space-y-1">
|
||||||
|
<li>Applications → <b>Add application</b> → Select <b>Add custom SAML 2.0 application</b>.</li>
|
||||||
|
<li>Set <b>Application ACS URL</b> to <code className="text-text-main font-mono">{samlAcsUrl}</code>.</li>
|
||||||
|
<li>Set <b>Application SAML audience</b> to <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code>.</li>
|
||||||
|
<li>Under <i>Attribute mappings</i>, map <code className="text-text-main font-mono">Subject</code> or <code className="text-text-main font-mono">email</code> to <code className="text-text-main font-mono">${`{user:email}`}</code>.</li>
|
||||||
|
<li>Download <b>IAM Identity Center SAML metadata XML</b> file and use 1-Click Import below!</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
|
||||||
|
<p className="font-semibold text-text-main flex items-center gap-1.5">
|
||||||
|
<span>🔷</span> Microsoft Entra ID (Azure AD)
|
||||||
|
</p>
|
||||||
|
<ol className="list-decimal pl-4 text-text-muted space-y-1">
|
||||||
|
<li>Enterprise Applications → <b>New application</b> → <b>Create your own application</b>.</li>
|
||||||
|
<li>Select <b>Single sign-on</b> → <b>SAML</b>.</li>
|
||||||
|
<li><b>Identifier (Entity ID):</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
|
||||||
|
<li><b>Reply URL (ACS):</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
|
||||||
|
<li>Download <b>Federation Metadata XML</b> and import or copy X.509 Certificate.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
|
||||||
|
<p className="font-semibold text-text-main flex items-center gap-1.5">
|
||||||
|
<span>🟢</span> Okta / Auth0
|
||||||
|
</p>
|
||||||
|
<ol className="list-decimal pl-4 text-text-muted space-y-1">
|
||||||
|
<li>Applications → <b>Create App Integration</b> → Select <b>SAML 2.0</b>.</li>
|
||||||
|
<li><b>Single Sign-On URL:</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
|
||||||
|
<li><b>Audience URI (SP Entity ID):</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
|
||||||
|
<li>Name ID format: <i>EmailAddress</i>.</li>
|
||||||
|
<li>Download Identity Provider metadata XML or copy the X.509 cert.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
|
||||||
|
<p className="font-semibold text-text-main flex items-center gap-1.5">
|
||||||
|
<span>🛡️</span> Keycloak / Authentik
|
||||||
|
</p>
|
||||||
|
<ol className="list-decimal pl-4 text-text-muted space-y-1">
|
||||||
|
<li>Clients → <b>Create client</b> → Select <b>SAML</b>.</li>
|
||||||
|
<li><b>Client ID:</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
|
||||||
|
<li><b>Master SAML Processing URL:</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
|
||||||
|
<li>Export SAML Descriptor XML or copy IDP Certificate PEM.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Import Card */}
|
||||||
|
<div className="p-3 rounded-lg border border-dashed border-primary/40 bg-primary/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm text-text-main">1-Click IdP Metadata XML Import</p>
|
||||||
|
<p className="text-xs text-text-muted">Auto-fill SSO URL, Issuer & Cert from XML metadata</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="upload_file"
|
||||||
|
onClick={() => idpMetadataFileRef.current?.click()}
|
||||||
|
>
|
||||||
|
Upload Metadata XML
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={idpMetadataFileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xml,application/xml,text/xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleIdpMetadataUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium text-sm sm:text-base">Single Sign-On Service URL (samlEntryPoint)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="https://idp.example.com/app/saml/sso/..."
|
||||||
|
value={samlForm.samlEntryPoint}
|
||||||
|
onChange={(e) => updateSamlForm("samlEntryPoint", e.target.value)}
|
||||||
|
disabled={loading || samlLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium text-sm sm:text-base">SP Entity ID / Audience (samlIssuer)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="urn:9router:sp"
|
||||||
|
value={samlForm.samlIssuer}
|
||||||
|
onChange={(e) => updateSamlForm("samlIssuer", e.target.value)}
|
||||||
|
disabled={loading || samlLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="font-medium text-sm sm:text-base">IdP X.509 Certificate (samlCert)</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="file_upload"
|
||||||
|
onClick={() => certFileRef.current?.click()}
|
||||||
|
>
|
||||||
|
Upload Certificate
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={certFileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".crt,.pem,.cer,text/plain"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleCertFileUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
placeholder="-----BEGIN CERTIFICATE----- MIIC... -----END CERTIFICATE-----"
|
||||||
|
value={samlForm.samlCert}
|
||||||
|
onChange={(e) => updateSamlForm("samlCert", e.target.value)}
|
||||||
|
className="w-full p-2.5 rounded-lg border border-border bg-bg text-xs font-mono text-text-main focus:outline-none focus:border-primary"
|
||||||
|
disabled={loading || samlLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-muted">Paste raw Base64 certificate or PEM block.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Sign in with SAML SSO"
|
||||||
|
value={samlForm.samlLoginLabel}
|
||||||
|
onChange={(e) => updateSamlForm("samlLoginLabel", e.target.value)}
|
||||||
|
disabled={loading || samlLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium text-sm sm:text-base">Email Claim Attribute</label>
|
||||||
|
<Input
|
||||||
|
placeholder="email"
|
||||||
|
value={samlForm.samlAttributeEmail}
|
||||||
|
onChange={(e) => updateSamlForm("samlAttributeEmail", e.target.value)}
|
||||||
|
disabled={loading || samlLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium text-sm sm:text-base">Display Name Claim</label>
|
||||||
|
<Input
|
||||||
|
placeholder="name"
|
||||||
|
value={samlForm.samlAttributeName}
|
||||||
|
onChange={(e) => updateSamlForm("samlAttributeName", e.target.value)}
|
||||||
|
disabled={loading || samlLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-bg text-xs sm:text-sm text-text-muted">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-text-main">ACS Callback URL</p>
|
||||||
|
<code className="block break-all font-mono text-xs">{samlAcsUrl}</code>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="content_copy"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(samlAcsUrl);
|
||||||
|
setSamlStatus({ type: "success", message: "ACS URL copied to clipboard!" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border/50">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-text-main">SP XML Metadata</p>
|
||||||
|
<code className="block break-all font-mono text-xs">{samlMetadataUrl}</code>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={samlMetadataUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
download="9router-sp-metadata.xml"
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px]">download</span>
|
||||||
|
Download XML
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
loading={samlLoading}
|
||||||
|
onClick={() => saveSamlSettings(oidcForm.authMode)}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Save SAML settings
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
loading={samlTestLoading}
|
||||||
|
onClick={testSamlConnection}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Test SAML settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{samlTestStatus.message && (
|
||||||
|
<p className={`text-xs sm:text-sm ${samlTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||||
|
{samlTestStatus.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{samlStatus.message && (
|
||||||
|
<p className={`text-xs sm:text-sm ${samlStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||||
|
{samlStatus.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* OIDC Panel */
|
||||||
|
<div className="flex flex-col gap-4 pt-2 border-t border-border/50">
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium text-sm sm:text-base">Issuer URL</label>
|
||||||
|
<Input
|
||||||
|
placeholder="https://auth.example.com/application/o/9router/"
|
||||||
|
value={oidcForm.oidcIssuerUrl}
|
||||||
|
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)}
|
||||||
|
disabled={loading || oidcLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium text-sm sm:text-base">Client ID</label>
|
||||||
|
<Input
|
||||||
|
placeholder="9router-dashboard"
|
||||||
|
value={oidcForm.oidcClientId}
|
||||||
|
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)}
|
||||||
|
disabled={loading || oidcLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium text-sm sm:text-base">Client Secret</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Leave blank to keep existing secret"
|
||||||
|
value={oidcClientSecret}
|
||||||
|
onChange={(e) => setOidcClientSecret(e.target.value)}
|
||||||
|
disabled={loading || oidcLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium text-sm sm:text-base">Scopes</label>
|
||||||
|
<Input
|
||||||
|
placeholder="openid profile email"
|
||||||
|
value={oidcForm.oidcScopes}
|
||||||
|
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)}
|
||||||
|
disabled={loading || oidcLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Sign in with OIDC"
|
||||||
|
value={oidcForm.oidcLoginLabel}
|
||||||
|
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)}
|
||||||
|
disabled={loading || oidcLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
|
||||||
|
<p className="font-medium text-text-main mb-1">Redirect URI</p>
|
||||||
|
<code className="block break-all font-mono">{oidcRedirectUri}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
|
||||||
|
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto">
|
||||||
|
Save OIDC settings
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto">
|
||||||
|
Test connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{oidcTestStatus.message && (
|
||||||
|
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||||
|
{oidcTestStatus.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{oidcStatus.message && (
|
||||||
|
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||||
|
{oidcStatus.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings.authMode === "oidc" || settings.authMode === "saml" || settings.authMode === "sso" ? (
|
||||||
|
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
SSO login ({settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"}) is currently active. Password login is disabled until you switch back.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{settings.authMode === "both" && (
|
||||||
|
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
Password and SSO login ({settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"}) are both active.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|||||||
Loaded 50 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user