merge: update from decolua/9router upstream (SAML, providers, opencode session headers)
This commit is contained in:
@@ -1,3 +1,89 @@
|
||||
# v0.5.55 (2026-08-14)
|
||||
|
||||
## Features
|
||||
- **Auth**: native SAML 2.0 SSO alongside OIDC — AuthnRequest generation, ACS
|
||||
assertion handling, SP metadata export, admin config test, replay-protected
|
||||
via a `saml_state` cookie matched against `InResponseTo`
|
||||
- **Providers**: add Alibaba Token Plan (`token-plan.ap-southeast-1`) — the
|
||||
fourth Alibaba key type, Singapore-only and OpenAI-compatible transport only
|
||||
- **Providers**: add `glm-5.3` to GLM Coding and GLM (China)
|
||||
- **Providers**: Kimchi accepts API keys as well as OAuth (dual auth), with a
|
||||
working Test Connection for both modes
|
||||
- **Antigravity**: add Gemini 3.7 Flash and its tiered high/medium/low variants
|
||||
(also in the Gemini registry) with pricing and quota tracking
|
||||
- **TTS**: add Fish Audio — model id travels in an HTTP `model` header, voice
|
||||
is a `reference_id` (preset or cloned voice model)
|
||||
- **OpenCode-Go**: route by request format via declared transports instead of
|
||||
forcing every client into `/messages` — Codex/OpenAI clients no longer pay a
|
||||
lossy Responses→OpenAI→Claude double translation. Per-model `supportedFormats`
|
||||
guard; the bespoke executor is gone (its shared `_lastModel` cache could cross
|
||||
auth headers between concurrent requests)
|
||||
- **Usage**: dedup + cache Claude quota calls (120s TTL keyed by access token,
|
||||
in-flight promise dedup, last-good read on soft failure) to stop multiple
|
||||
tabs tripping 429; manual refresh (↻) sends `force=1` to bypass the cache
|
||||
|
||||
## Fixes
|
||||
- **Docker**: ship `sql.js` in the image so the pure-JS DB fallback can start —
|
||||
file tracing carried the package's JS without `dist/sql-wasm.wasm`, so a
|
||||
container with no native driver aborted with ENOENT and never got a database
|
||||
(#3248)
|
||||
- **Usage**: read Gemini `usageMetadata` out of the antigravity `{ response }`
|
||||
envelope — every non-streaming antigravity request logged `IN 0 | OUT 0`
|
||||
(#3260)
|
||||
- **Claude**: re-anchor passthrough cache breakpoints — the client's own
|
||||
`cache_control` markers point at pre-normalization offsets, so the tail was
|
||||
re-cached every request. Last system block and last tool pinned at 1h TTL,
|
||||
last assistant turn at 5m, mid-conversation system messages folded into the
|
||||
neighbouring user turn instead of hoisted into `body.system`
|
||||
- **Combos**: detect images from Hermes and attachment payloads (`images[]`,
|
||||
`experimental_attachments`, message-level `image_url`/`audio_url`, inline
|
||||
`data:` URIs) so the Vision Adapter auto-switch fires for Hermes/Ollama/
|
||||
Vercel AI SDK shapes
|
||||
- **Kiro**: intercept chat via `x-amz-target` — Kiro IDE 1.0.228+ moved
|
||||
`GenerateAssistantResponse` to `POST /` + header, bypassing MITM. Also emit
|
||||
the now-mandatory initial-response frame and map the `auto` model slot
|
||||
- **Kiro**: report real output tokens and stop discarding usable turns
|
||||
- **Qoder**: detect billing blocks at stream start and return a synthetic 403
|
||||
so combo/account fallback triggers instead of leaking the error into chat
|
||||
- **Antigravity**: strip competitive system prompts (Zed IDE's Claude-agent
|
||||
prompt) that Antigravity flags with a 429 Quota Exhausted
|
||||
- **OpenCode**: send the official client fingerprint on free-tier requests so
|
||||
the Console stops classifying traffic as unidentified and rate-limiting it;
|
||||
session id resolves conversation-stable to preserve prompt caching
|
||||
- **Responses**: don't close the message on an empty `tool_calls` array — some
|
||||
providers attach one to every chunk, and the truthy check ended the message
|
||||
on the first content token (#3234)
|
||||
- **Translator**: preserve `prompt_cache_key` when converting chat to responses
|
||||
- **Models**: expose snake_case token limits on `/v1/models`
|
||||
- **Combos**: strip `stream_options` from the Fusion panel fan-out to avoid a
|
||||
DeepSeek 400 (#3024); raise the dashboard model-test probe budget to 1024 and
|
||||
soft-pass reasoning-only responses (#3010)
|
||||
- **Headroom**: the toggle reflects the `headroomEnabled` setting even when the
|
||||
proxy is down — it previously showed OFF while the engine kept calling
|
||||
`/v1/compress`; proxy status stays visible via the status chip
|
||||
- **Hermes**: add the `api_key` parameter to the model block in YAML config
|
||||
- **Providers**: add llm7 to provider test support
|
||||
|
||||
## Docs
|
||||
- **i18n**: add Spanish, French, and Brazilian Portuguese README translations
|
||||
|
||||
## Security
|
||||
- **Real IP**: `x-9r-real-ip` and the Host fallback were trusted from
|
||||
client-controlled headers whenever `custom-server.js` was not in the request
|
||||
path (`npm run start`, `start:bun`), letting a remote caller pose as local to
|
||||
skip API key auth and reach `LOCAL_ONLY_PATHS` (`/api/mcp/*`,
|
||||
`/api/tunnel/enable`, `/api/auth/reset-password`). The server now stamps a
|
||||
per-process `x-9r-peer-token` on every request it sanitizes and only trusts
|
||||
`x-9r-real-ip` behind it — falling back to Host in development and failing
|
||||
closed in production (GHSA-pjm4-8fpg-f9p6). Also fixes IPv6 loopback
|
||||
detection (`::1`, `::ffff:127.0.0.1`) and routes `npm run start` /
|
||||
`start:bun` through `custom-server.js`
|
||||
- **Search**: `resolveBaseUrl()` rejects client-supplied non-public baseUrls
|
||||
(SSRF guard on `/v1/search`)
|
||||
- **Login**: fresh-install remote login with the default password returns 403
|
||||
without issuing a JWT
|
||||
- **Usage**: `/api/usage/request-details` redacts request/response payloads
|
||||
|
||||
# v0.5.50 (2026-08-05)
|
||||
|
||||
## Features
|
||||
|
||||
@@ -37,6 +37,9 @@ COPY --from=builder /app/src/mitm ./src/mitm
|
||||
COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge
|
||||
# Ensure `next` is available at runtime in case tracing did not include it.
|
||||
COPY --from=builder /app/node_modules/next ./node_modules/next
|
||||
# sql.js loads dist/sql-wasm.wasm by path at runtime; tracing only follows JS imports,
|
||||
# so the last-resort DB driver would abort with ENOENT on the missing binary.
|
||||
COPY --from=builder /app/node_modules/sql.js ./node_modules/sql.js
|
||||
|
||||
RUN mkdir -p /app/data && chown -R node:node /app && \
|
||||
mkdir -p /app/data-home && chown node:node /app/data-home && \
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router",
|
||||
"version": "0.5.50",
|
||||
"version": "0.5.55",
|
||||
"description": "9Router CLI - Start and manage 9Router server",
|
||||
"bin": {
|
||||
"9router": "./cli.js"
|
||||
|
||||
@@ -216,7 +216,9 @@ function buildCliPackage() {
|
||||
fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
|
||||
console.log("✅ Copied custom-server.js\n");
|
||||
} else {
|
||||
console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n");
|
||||
console.error("❌ custom-server.js not found — without it no request can be proven local,");
|
||||
console.error(" so the packaged CLI would demand an API key for its own dashboard and /v1.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.
|
||||
|
||||
@@ -53,6 +53,9 @@ const PROVIDER_MODELS = {
|
||||
{ id: "glm-4.7" },
|
||||
],
|
||||
ag: [
|
||||
{ id: "gemini-3.7-flash-high" },
|
||||
{ id: "gemini-3.7-flash-medium" },
|
||||
{ id: "gemini-3.7-flash-low" },
|
||||
{ id: "gemini-3.6-flash-high" },
|
||||
{ id: "gemini-3.6-flash-medium" },
|
||||
{ id: "gemini-3.6-flash-low" },
|
||||
|
||||
+23
-1
@@ -1,9 +1,18 @@
|
||||
const http = require("http");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const { pathToFileURL } = require("url");
|
||||
|
||||
const origCreate = http.createServer.bind(http);
|
||||
|
||||
// Per-process secret proving x-9r-real-ip was stamped below rather than sent by the client.
|
||||
// A bare `next start` / `next dev` never loads this file, so it cannot produce a matching
|
||||
// header even though the env var is inherited by child processes. Named like x-9r-cli-token
|
||||
// so the request-detail header sanitizer redacts it too.
|
||||
const PEER_TOKEN = crypto.randomBytes(24).toString("hex");
|
||||
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
|
||||
|
||||
let backgroundRefreshStarted = false;
|
||||
|
||||
function startBackgroundTokenRefreshFromCustomServer() {
|
||||
@@ -57,7 +66,9 @@ http.createServer = (...args) => {
|
||||
delete req.headers["x-9r-real-ip"];
|
||||
delete req.headers["x-forwarded-for"];
|
||||
delete req.headers["x-9r-via-proxy"];
|
||||
delete req.headers["x-9r-peer-token"];
|
||||
req.headers["x-9r-real-ip"] = ip;
|
||||
req.headers["x-9r-peer-token"] = PEER_TOKEN;
|
||||
if (viaProxy) req.headers["x-9r-via-proxy"] = "1";
|
||||
return handler(req, res);
|
||||
};
|
||||
@@ -114,4 +125,15 @@ http.createServer = (...args) => {
|
||||
return server;
|
||||
};
|
||||
|
||||
if (require.main === module) require("./server.js");
|
||||
if (require.main === module) {
|
||||
const standalone = path.join(__dirname, "server.js");
|
||||
if (fs.existsSync(standalone)) {
|
||||
require(standalone);
|
||||
} else {
|
||||
// Repo checkout has no standalone build next to us. `next start` builds its HTTP
|
||||
// server in-process, so the wrapper above still sanitizes every request.
|
||||
const nextBin = require.resolve("next/dist/bin/next");
|
||||
process.argv = [process.argv[0], nextBin, "start", ...process.argv.slice(2)];
|
||||
require(nextBin);
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
// PROVIDER_MODELS now built from providers/registry (transport + models co-located)
|
||||
import { PROVIDER_MODELS } from "../providers/index.js";
|
||||
import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js";
|
||||
import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js";
|
||||
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
|
||||
export { PROVIDER_MODELS };
|
||||
|
||||
@@ -54,6 +54,14 @@ export function getModelTargetFormat(aliasOrId, modelId) {
|
||||
return modelTargetFormat(findModel(models, modelId, aliasOrId));
|
||||
}
|
||||
|
||||
// Declared upstream formats for a model (registry `supportedFormats`). Drives the
|
||||
// per-model guard on the sourceFormat-matched transport; null when undeclared.
|
||||
export function getModelSupportedFormats(aliasOrId, modelId) {
|
||||
const models = PROVIDER_MODELS[aliasOrId];
|
||||
if (!models) return null;
|
||||
return modelSupportedFormats(findModel(models, modelId, aliasOrId));
|
||||
}
|
||||
|
||||
export function getModelType(aliasOrId, modelId) {
|
||||
const models = PROVIDER_MODELS[aliasOrId];
|
||||
if (!models) return null;
|
||||
|
||||
@@ -245,6 +245,18 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
// Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects
|
||||
const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {};
|
||||
stripBlacklisted(requestWithoutTools);
|
||||
|
||||
// Rewrite competitive system prompts (e.g. Zed IDE's Claude prompt) to prevent Antigravity from
|
||||
// flagging the request and immediately blocking it with a 429 Quota Exhausted response.
|
||||
if (requestWithoutTools.systemInstruction?.parts) {
|
||||
const oldText = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
||||
for (const part of requestWithoutTools.systemInstruction.parts) {
|
||||
if (typeof part.text === "string" && part.text.includes(oldText)) {
|
||||
part.text = part.text.split(oldText).join("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const generationConfig = { ...(requestWithoutTools.generationConfig || {}) };
|
||||
if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) {
|
||||
generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS;
|
||||
|
||||
@@ -10,7 +10,6 @@ import { CodexExecutor } from "./codex.js";
|
||||
import { CursorExecutor } from "./cursor.js";
|
||||
import { VertexExecutor } from "./vertex.js";
|
||||
import { OpenCodeExecutor } from "./opencode.js";
|
||||
import { OpenCodeGoExecutor } from "./opencode-go.js";
|
||||
import { GrokWebExecutor } from "./grok-web.js";
|
||||
import { GrokCliExecutor } from "./grok-cli.js";
|
||||
import { PerplexityWebExecutor } from "./perplexity-web.js";
|
||||
@@ -42,7 +41,6 @@ const executors = {
|
||||
vertex: new VertexExecutor("vertex"),
|
||||
"vertex-partner": new VertexExecutor("vertex-partner"),
|
||||
opencode: new OpenCodeExecutor(),
|
||||
"opencode-go": new OpenCodeGoExecutor(),
|
||||
"grok-web": new GrokWebExecutor(),
|
||||
"grok-cli": new GrokCliExecutor(),
|
||||
gcli: new GrokCliExecutor(), // Alias
|
||||
@@ -88,7 +86,6 @@ export { CursorExecutor } from "./cursor.js";
|
||||
export { VertexExecutor } from "./vertex.js";
|
||||
export { DefaultExecutor } from "./default.js";
|
||||
export { OpenCodeExecutor } from "./opencode.js";
|
||||
export { OpenCodeGoExecutor } from "./opencode-go.js";
|
||||
export { GrokWebExecutor } from "./grok-web.js";
|
||||
export { GrokCliExecutor } from "./grok-cli.js";
|
||||
export { PerplexityWebExecutor } from "./perplexity-web.js";
|
||||
|
||||
+86
-30
@@ -144,6 +144,12 @@ function normalizeStopReason(value) {
|
||||
return reason || null;
|
||||
}
|
||||
|
||||
// Of the reasons stopDisposition() folds into "terminal_incomplete", only these
|
||||
// mean "usable as far as it got, then the budget ran out" -- the case
|
||||
// finish_reason "length" exists for. cancelled / pause_turn are abandoned turns
|
||||
// whose partial content must stay private, so they are deliberately absent.
|
||||
const KIRO_TRUNCATION_STOP_REASONS = new Set(["model_context_window_exceeded", "max_tokens"]);
|
||||
|
||||
function stopDisposition(stopReason, hasToolCalls) {
|
||||
if (["malformed_model_output", "invalid_model_output"].includes(stopReason)) return "retryable_protocol_failure";
|
||||
if (["cancelled", "pause_turn", "model_context_window_exceeded"].includes(stopReason)) return "terminal_incomplete";
|
||||
@@ -711,14 +717,25 @@ export class KiroExecutor extends BaseExecutor {
|
||||
};
|
||||
const emitTools = (controller) => {
|
||||
for (const tool of state.tools.values()) {
|
||||
const input = parsedToolInput(tool);
|
||||
if (tool.name === "tool_call") {
|
||||
if (typeof input.name !== "string" || !input.name.trim()) {
|
||||
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name");
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(input, "arguments")) {
|
||||
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments");
|
||||
// Validate per tool, not per turn: one unusable fragment used to throw out
|
||||
// of emitTools and take every other complete tool call in the same turn
|
||||
// with it, which the client saw as a turn that answered nothing.
|
||||
let input;
|
||||
try {
|
||||
input = parsedToolInput(tool);
|
||||
if (tool.name === "tool_call") {
|
||||
if (typeof input.name !== "string" || !input.name.trim()) {
|
||||
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name");
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(input, "arguments")) {
|
||||
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
state.droppedTools = (state.droppedTools || 0) + 1;
|
||||
state.toolValidationError ||= error.message;
|
||||
console.error(`[Kiro] dropping unusable tool call ${tool.id} (${tool.name}): ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
const index = state.toolCounter++;
|
||||
emitDelta(controller, {
|
||||
@@ -729,14 +746,26 @@ export class KiroExecutor extends BaseExecutor {
|
||||
function: { name: tool.name, arguments: "" }
|
||||
}]
|
||||
});
|
||||
const serializedInput = JSON.stringify(input);
|
||||
emitDelta(controller, {
|
||||
tool_calls: [{ index, function: { arguments: JSON.stringify(input) } }]
|
||||
tool_calls: [{ index, function: { arguments: serializedInput } }]
|
||||
});
|
||||
// Tool arguments are billed output like any other completion bytes. They
|
||||
// were never added to totalContentLength, so the /4 estimator in finish()
|
||||
// reported OUT 0 -- or the Math.max floor of 1 -- for every turn whose
|
||||
// entire answer was a tool call.
|
||||
state.totalContentLength += tool.name.length + serializedInput.length;
|
||||
state.hasToolCalls = true;
|
||||
}
|
||||
state.tools.clear();
|
||||
state.bufferedToolBytes = 0;
|
||||
if (state.stopReason === "tool_use" && !state.hasToolCalls) {
|
||||
// A declared tool turn that emitted no usable call is only fatal when the
|
||||
// turn produced nothing else. Throwing unconditionally here escaped
|
||||
// emitTools() with provenance "invalid_tool_call", which the integrity gate
|
||||
// re-derived into a repair retry -- discarding text the client had already
|
||||
// been promised.
|
||||
if (state.stopReason === "tool_use" && !state.hasToolCalls &&
|
||||
!state.hasText && !state.hasReasoning && !state.hasCode) {
|
||||
throw new Error("Kiro tool_use stop reason did not include a complete tool call");
|
||||
}
|
||||
};
|
||||
@@ -796,7 +825,6 @@ export class KiroExecutor extends BaseExecutor {
|
||||
emitDelta(controller, { content: event.payload.content });
|
||||
} else if (eventType === "toolUseEvent") {
|
||||
state.sawToolUse = true;
|
||||
if (state.toolValidationError) return true;
|
||||
const values = Array.isArray(event.payload) ? event.payload : [event.payload];
|
||||
if (!values[0]) throw new Error("Kiro toolUseEvent is empty");
|
||||
for (const value of values) {
|
||||
@@ -924,9 +952,10 @@ export class KiroExecutor extends BaseExecutor {
|
||||
} catch (error) {
|
||||
const bufferExceeded = error.code === "KIRO_BUFFER_EXCEEDED";
|
||||
if (!bufferExceeded) {
|
||||
// Keep whatever is already buffered: the rejected fragment belongs to
|
||||
// one tool, and clearing the map dropped the complete calls too.
|
||||
state.toolValidationError ||= error.message;
|
||||
state.tools.clear();
|
||||
state.bufferedToolBytes = 0;
|
||||
console.error(`[Kiro] tool fragment rejected, keeping ${state.tools.size} buffered tool(s): ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
fail(
|
||||
@@ -958,7 +987,16 @@ export class KiroExecutor extends BaseExecutor {
|
||||
}
|
||||
state.transportState = "clean_eof";
|
||||
const declaredDisposition = stopDisposition(state.stopReason, state.sawToolUse);
|
||||
if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) {
|
||||
// model_context_window_exceeded / max_tokens map to terminal_incomplete. When
|
||||
// they arrive after the model already streamed content, fail() threw away a
|
||||
// complete-enough answer; a truncated turn is what finish_reason "length" is
|
||||
// for. chunkIndex > 0 means at least one delta already reached the client.
|
||||
const declaredTruncatedAfterOutput = declaredDisposition === "terminal_incomplete" &&
|
||||
KIRO_TRUNCATION_STOP_REASONS.has(state.stopReason) && state.chunkIndex > 0;
|
||||
if (declaredTruncatedAfterOutput) {
|
||||
console.error(`[Kiro] truncated after ${state.chunkIndex} chunk(s) (stop_reason=${state.stopReason}); keeping output`);
|
||||
}
|
||||
if (!declaredTruncatedAfterOutput && ["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) {
|
||||
const code = declaredDisposition === "retryable_protocol_failure"
|
||||
? "kiro_retryable_protocol_failure"
|
||||
: declaredDisposition === "terminal_refusal"
|
||||
@@ -975,16 +1013,6 @@ export class KiroExecutor extends BaseExecutor {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (state.toolValidationError) {
|
||||
fail(
|
||||
controller,
|
||||
"invalid_tool_call",
|
||||
"invalid_kiro_tool_call",
|
||||
state.toolValidationError,
|
||||
{ transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
emitTools(controller);
|
||||
} catch (error) {
|
||||
@@ -997,6 +1025,22 @@ export class KiroExecutor extends BaseExecutor {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Fail only when the turn has nothing usable left. emitTools() validates
|
||||
// per tool and drops just the unusable ones, so this has to run AFTER it:
|
||||
// before, the rejected tool was still buffered and tools.size was never 0.
|
||||
// A turn that also produced text keeps that text -- the dropped call is
|
||||
// logged, not fatal.
|
||||
if (state.toolValidationError && !state.hasToolCalls &&
|
||||
!state.hasText && !state.hasReasoning && !state.hasCode) {
|
||||
fail(
|
||||
controller,
|
||||
"invalid_tool_call",
|
||||
"invalid_kiro_tool_call",
|
||||
state.toolValidationError,
|
||||
{ transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasOutput = state.hasText || state.hasReasoning || state.hasCode || state.hasToolCalls;
|
||||
if (!hasOutput && !state.explicitStop) {
|
||||
@@ -1011,7 +1055,13 @@ export class KiroExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
const disposition = stopDisposition(state.stopReason, state.hasToolCalls);
|
||||
if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) {
|
||||
// Same reasoning as declaredTruncatedAfterOutput above.
|
||||
const truncatedAfterOutput = disposition === "terminal_incomplete" &&
|
||||
KIRO_TRUNCATION_STOP_REASONS.has(state.stopReason) && state.chunkIndex > 0;
|
||||
if (truncatedAfterOutput) {
|
||||
console.error(`[Kiro] truncated after ${state.chunkIndex} chunk(s) (stop_reason=${state.stopReason}); closing as length`);
|
||||
}
|
||||
if (!truncatedAfterOutput && ["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) {
|
||||
const code = disposition === "retryable_protocol_failure"
|
||||
? "kiro_retryable_protocol_failure"
|
||||
: disposition === "terminal_refusal"
|
||||
@@ -1041,18 +1091,24 @@ export class KiroExecutor extends BaseExecutor {
|
||||
total_tokens: prompt + completion
|
||||
};
|
||||
}
|
||||
const finishReason = state.hasToolCalls
|
||||
? "tool_calls"
|
||||
: disposition === "length"
|
||||
? "length"
|
||||
: "stop";
|
||||
const finishReason = truncatedAfterOutput
|
||||
? "length"
|
||||
: state.hasToolCalls
|
||||
? "tool_calls"
|
||||
: disposition === "length"
|
||||
? "length"
|
||||
: "stop";
|
||||
controller.enqueue(sseChunk({}, finishReason, state.usage));
|
||||
controller.enqueue(encoder.encode(SSE_DONE));
|
||||
state.finished = true;
|
||||
options.onTerminalState?.(diagnostics({
|
||||
terminal_provenance: state.terminalProvenance || "clean_eventstream_eof",
|
||||
transport_state: state.transportState,
|
||||
stop_disposition: disposition
|
||||
// Report what this exit actually did, not the raw disposition. The
|
||||
// integrity gate re-derives its verdict from stop_disposition, so
|
||||
// reporting "terminal_incomplete" for a turn we deliberately kept made
|
||||
// it discard the very bytes we just released to the client.
|
||||
stop_disposition: truncatedAfterOutput ? "length" : disposition
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
||||
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
|
||||
|
||||
// Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth)
|
||||
const MESSAGES_FORMAT_MODELS = new Set([
|
||||
"minimax-m3",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.6-plus",
|
||||
]);
|
||||
|
||||
const BASE = "https://opencode.ai/zen/go/v1";
|
||||
|
||||
export class OpenCodeGoExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("opencode-go", PROVIDERS["opencode-go"]);
|
||||
}
|
||||
|
||||
// buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here
|
||||
buildUrl(model) {
|
||||
this._lastModel = model;
|
||||
return MESSAGES_FORMAT_MODELS.has(model)
|
||||
? `${BASE}/messages`
|
||||
: `${BASE}/chat/completions`;
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true) {
|
||||
const key = credentials?.apiKey || credentials?.accessToken;
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
if (MESSAGES_FORMAT_MODELS.has(this._lastModel)) {
|
||||
headers["x-api-key"] = key;
|
||||
headers["anthropic-version"] = ANTHROPIC_API_VERSION;
|
||||
} else {
|
||||
headers["Authorization"] = `Bearer ${key}`;
|
||||
}
|
||||
|
||||
if (stream) headers["Accept"] = "text/event-stream";
|
||||
return headers;
|
||||
}
|
||||
|
||||
transformRequest(model, body) {
|
||||
return injectReasoningContent({ provider: this.provider, model, body });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,35 @@
|
||||
import crypto from "crypto";
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
||||
import { resolveSessionId } from "../utils/sessionManager.js";
|
||||
|
||||
// Models that use /zen/v1/messages (claude format)
|
||||
const OPENCODE_UA = "opencode";
|
||||
const MESSAGES_MODELS = new Set();
|
||||
|
||||
function generateRequestId() {
|
||||
return `msg_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
}
|
||||
|
||||
function generateSessionId() {
|
||||
return `ses_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
}
|
||||
|
||||
// Normalize any resolved id into opencode's ses_ format (stable per-conversation)
|
||||
function toOpencodeSession(id) {
|
||||
const stripped = String(id || "").replace(/^ses_/, "").replace(/-/g, "");
|
||||
return stripped ? `ses_${stripped}` : null;
|
||||
}
|
||||
|
||||
function resolveOpencodeSession(body, credentials) {
|
||||
return toOpencodeSession(resolveSessionId({
|
||||
headers: credentials?.rawHeaders,
|
||||
body,
|
||||
connectionId: credentials?.connectionId,
|
||||
scope: "opencode",
|
||||
}));
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
constructor() {
|
||||
super("opencode", PROVIDERS.opencode);
|
||||
this._currentSessionId = null;
|
||||
}
|
||||
|
||||
transformRequest(model, body) {
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
this._currentSessionId = resolveOpencodeSession(body, credentials);
|
||||
return injectReasoningContent({ provider: this.provider, model, body });
|
||||
}
|
||||
|
||||
@@ -27,12 +54,23 @@ export class OpenCodeExecutor extends BaseExecutor {
|
||||
: `${base}/zen/v1/chat/completions`;
|
||||
}
|
||||
|
||||
buildHeaders() {
|
||||
buildHeaders(credentials, stream = true) {
|
||||
const raw = credentials?.rawHeaders || {};
|
||||
const lower = {};
|
||||
for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v;
|
||||
|
||||
const downstreamUa = lower["user-agent"] || "";
|
||||
const isOpencodeDownstream = downstreamUa.toLowerCase().includes("opencode");
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer public",
|
||||
"x-opencode-client": "desktop",
|
||||
"Accept": "text/event-stream"
|
||||
"User-Agent": isOpencodeDownstream ? downstreamUa : OPENCODE_UA,
|
||||
"x-opencode-client": lower["x-opencode-client"] || "desktop",
|
||||
"x-opencode-session": lower["x-opencode-session"] || this._currentSessionId || generateSessionId(),
|
||||
"x-opencode-request": lower["x-opencode-request"] || generateRequestId(),
|
||||
"x-opencode-project": lower["x-opencode-project"] || "global",
|
||||
"Accept": stream ? "text/event-stream" : "*/*",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,52 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a qoder error message indicates a billing/quota block.
|
||||
* Signatures: code 112 (quota exhausted), code 10605 (queue throttle), pricingUrl field.
|
||||
*/
|
||||
function isBillingBlock(inner) {
|
||||
if (!inner || typeof inner !== "string") return false;
|
||||
const lowerMsg = inner.toLowerCase();
|
||||
// Match: {"code":"112",...}, {"code":"10605",...}, or pricingUrl field
|
||||
return /\"code\"\s*:\s*\"(112|10605)\"/.test(inner) || lowerMsg.includes("pricingurl");
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek the first SSE frame to detect billing errors before piping.
|
||||
* Returns { isBilling, statusVal, message, consumed } — `consumed` is every
|
||||
* byte read so far (including the peeked line) so the caller can re-process
|
||||
* it and nothing is dropped from the stream.
|
||||
*/
|
||||
async function peekFirstQoderFrame(reader, decoder) {
|
||||
let consumed = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return { isBilling: false, consumed, upstreamDone: true };
|
||||
|
||||
consumed += decoder.decode(value, { stream: true });
|
||||
const nl = consumed.indexOf("\n");
|
||||
if (nl === -1) continue; // need a full line first
|
||||
|
||||
const line = consumed.slice(0, nl).replace(/\r$/, "").trim();
|
||||
if (!line.startsWith("data:")) continue;
|
||||
|
||||
const data = line.slice(5).trimStart();
|
||||
if (data === "[DONE]") return { isBilling: false, consumed };
|
||||
|
||||
let envelope;
|
||||
try { envelope = JSON.parse(data); } catch { return { isBilling: false, consumed }; }
|
||||
|
||||
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
|
||||
const inner = typeof envelope.body === "string" ? envelope.body : "";
|
||||
|
||||
if (statusVal !== 200 && isBillingBlock(inner)) {
|
||||
return { isBilling: true, statusVal, message: inner || `qoder billing block (${statusVal})` };
|
||||
}
|
||||
return { isBilling: false, consumed };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the upstream's `{statusCodeValue, body}` SSE envelope into plain
|
||||
* OpenAI SSE chunks the rest of the chatCore pipeline understands.
|
||||
@@ -229,16 +275,34 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
|
||||
* [DONE]/error frame (agent keepalive). Non-streaming clients drain via
|
||||
* response.text() which hangs until the socket closes — so on terminal
|
||||
* events we cancel the upstream reader and close our stream immediately.
|
||||
*
|
||||
* NEW: Peek first frame to detect billing blocks (code 112/10605/pricingUrl).
|
||||
* If detected, return 403 response so chatCore marks connection unavailable
|
||||
* and triggers combo fallback instead of leaking error text into chat.
|
||||
*/
|
||||
function wrapQoderSSE(response, model) {
|
||||
async function wrapQoderSSE(response, model) {
|
||||
if (!response.ok || !response.body) return response;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let buffer = "";
|
||||
let doneEmitted = false;
|
||||
const reader = response.body.getReader();
|
||||
|
||||
// Peek first frame to detect billing block
|
||||
const peek = await peekFirstQoderFrame(reader, decoder);
|
||||
if (peek?.isBilling) {
|
||||
// Billing block detected — return 403 so chatCore fails this connection
|
||||
await reader.cancel().catch(() => {});
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: peek.message, code: peek.statusVal } }),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Normal flow: re-process every byte the peek consumed, then continue.
|
||||
let buffer = peek.consumed || "";
|
||||
const upstreamDrained = peek.upstreamDone === true;
|
||||
const encoder = new TextEncoder();
|
||||
let doneEmitted = false;
|
||||
|
||||
// Process one already-extracted SSE line (no trailing newline).
|
||||
const processLine = (line, controller) => {
|
||||
const trimmed = line.replace(/\r$/, "").trim();
|
||||
@@ -287,7 +351,28 @@ function wrapQoderSSE(response, model) {
|
||||
// enqueueing would never be re-invoked, hanging consumers like .text().
|
||||
async start(controller) {
|
||||
try {
|
||||
while (!doneEmitted) {
|
||||
// Drain whatever the peek already pulled off the socket first.
|
||||
let nlSeed;
|
||||
while ((nlSeed = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, nlSeed);
|
||||
buffer = buffer.slice(nlSeed + 1);
|
||||
processLine(line, controller);
|
||||
if (doneEmitted) {
|
||||
await reader.cancel().catch(() => {});
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (upstreamDrained) {
|
||||
// Peek hit end-of-stream: flush any trailing partial line.
|
||||
buffer += decoder.decode();
|
||||
if (buffer.length > 0) {
|
||||
processLine(buffer, controller);
|
||||
buffer = "";
|
||||
}
|
||||
}
|
||||
|
||||
while (!doneEmitted && !upstreamDrained) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
buffer += decoder.decode();
|
||||
@@ -472,7 +557,7 @@ export class QoderExecutor extends BaseExecutor {
|
||||
return { response, url, headers, transformedBody: payload };
|
||||
}
|
||||
|
||||
const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`);
|
||||
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`);
|
||||
return { response: wrapped, url, headers, transformedBody: payload };
|
||||
}
|
||||
|
||||
@@ -496,4 +581,5 @@ export const __test__ = {
|
||||
normalizeMessages,
|
||||
wrapQoderSSE,
|
||||
buildQoderRequestBody,
|
||||
isBillingBlock,
|
||||
};
|
||||
|
||||
@@ -2,11 +2,11 @@ import { detectFormat, getTargetFormat, resolveTransport } from "../services/pro
|
||||
import { translateRequest } from "../translator/index.js";
|
||||
import { applyThinking, extractThinking, stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js";
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
import { normalizeClaudePassthrough } from "../translator/formats/claude.js";
|
||||
import { normalizeClaudePassthrough, anchorClaudeCache } from "../translator/formats/claude.js";
|
||||
import { createStreamController } from "../utils/streamHandler.js";
|
||||
import { refreshWithRetry } from "../services/tokenRefresh.js";
|
||||
import { createRequestLogger } from "../utils/requestLogger.js";
|
||||
import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||
import { getModelTargetFormat, getModelSupportedFormats, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
||||
import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js";
|
||||
@@ -84,10 +84,20 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
|
||||
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
const modelTargetFormat = getModelTargetFormat(alias, model);
|
||||
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation
|
||||
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation.
|
||||
// Per-model guard: only use the transport when the model declares support for that
|
||||
// sourceFormat — opencode-go models differ in endpoint support (kimi/glm only do
|
||||
// /chat/completions), so without this guard a claude-format request would wrongly
|
||||
// route kimi to /messages.
|
||||
const modelSupportedFormats = getModelSupportedFormats(alias, model);
|
||||
const runtimeTransport = resolveTransport(provider, sourceFormat);
|
||||
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider, credentials);
|
||||
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
|
||||
// Per-model guard: when a model declares supportedFormats, only use the
|
||||
// sourceFormat-matched transport if that format is declared (opencode-go models
|
||||
// differ — kimi/glm only do /chat/completions). Undeclared models keep the
|
||||
// upstream default (use the transport), preserving behavior for glm/deepseek/...
|
||||
const useTransport = (!modelSupportedFormats || modelSupportedFormats.includes(sourceFormat)) ? runtimeTransport : null;
|
||||
const targetFormat = modelTargetFormat || useTransport?.format || getTargetFormat(provider, credentials);
|
||||
if (useTransport && credentials) credentials.runtimeTransport = useTransport;
|
||||
const stripList = getModelStrip(alias, model);
|
||||
const upstreamModel = getModelUpstreamId(alias, model);
|
||||
|
||||
@@ -281,6 +291,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
|
||||
if (xf.length && log?.line) log.line(reqTag, "⚙", xf.join(" · "));
|
||||
|
||||
// Pin cache breakpoints to the final body — every saver above can reshape
|
||||
// system/tools/messages, and a stale anchor costs a full prefix rewrite.
|
||||
if (passthrough && clientTool === "claude") anchorClaudeCache(translatedBody);
|
||||
|
||||
const executor = getExecutor(provider);
|
||||
trackPendingRequest(model, provider, connectionId, true);
|
||||
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { });
|
||||
|
||||
@@ -44,13 +44,14 @@ export function extractUsageFromResponse(responseBody) {
|
||||
};
|
||||
}
|
||||
|
||||
// Gemini format
|
||||
if (responseBody.usageMetadata) {
|
||||
// Gemini format. Antigravity / gemini-cli wrap the payload in { response: {...} }.
|
||||
const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata;
|
||||
if (usageMetadata) {
|
||||
return {
|
||||
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
|
||||
cached_tokens: responseBody.usageMetadata.cachedContentTokenCount || 0,
|
||||
reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount || 0
|
||||
prompt_tokens: usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: usageMetadata.candidatesTokenCount || 0,
|
||||
cached_tokens: usageMetadata.cachedContentTokenCount || 0,
|
||||
reasoning_tokens: usageMetadata.thoughtsTokenCount || 0
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
* @property {Record<string,unknown>} [providerSpecificData]
|
||||
*/
|
||||
|
||||
import { assertPublicUrl } from "../../../src/shared/utils/ssrfGuard.js";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -63,12 +65,31 @@ export function getProviderSetting(params, key) {
|
||||
|
||||
/**
|
||||
* Resolve base URL with optional override from providerOptions.baseUrl.
|
||||
*
|
||||
* The override is client-controlled and therefore SSRF-hardened: only public
|
||||
* http(s) URLs are accepted (internal/private/loopback/metadata addresses are
|
||||
* rejected via assertPublicUrl). The provider's own configured baseUrl is
|
||||
* trusted as-is (admin-controlled).
|
||||
*
|
||||
* @param {SearchProviderConfig} config
|
||||
* @param {SearchRequestParams} params
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveBaseUrl(config, params) {
|
||||
const override = getProviderSetting(params, "baseUrl");
|
||||
if (override) {
|
||||
// SSRF guard: client-supplied base URLs must be public http(s) only.
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(override);
|
||||
} catch {
|
||||
throw new Error(`Invalid baseUrl: ${override}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`Invalid baseUrl protocol: ${parsed.protocol}`);
|
||||
}
|
||||
assertPublicUrl(override);
|
||||
}
|
||||
return (override || config.baseUrl).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,25 @@ async function huggingface({ baseUrl, apiKey, text, modelId }) {
|
||||
return responseToBase64(res, "wav");
|
||||
}
|
||||
|
||||
// Fish Audio: model travels in an HTTP header, the voice is a reference_id, returns binary
|
||||
async function fishAudio({ baseUrl, apiKey, text, modelId, voiceId }) {
|
||||
const res = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"model": modelId || "s2.1-pro-free",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
format: "mp3",
|
||||
...(voiceId ? { reference_id: voiceId } : {}),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) await throwUpstreamError(res);
|
||||
return responseToBase64(res, "mp3");
|
||||
}
|
||||
|
||||
// Inworld: Basic auth, JSON { audioContent }
|
||||
async function inworld({ baseUrl, apiKey, text, modelId, voiceId }) {
|
||||
const res = await fetch(baseUrl, {
|
||||
@@ -166,4 +185,5 @@ export const FORMAT_HANDLERS = {
|
||||
tortoise,
|
||||
openai: openaiCompat,
|
||||
"minimax-tts": minimaxTts,
|
||||
"fish-audio": fishAudio,
|
||||
};
|
||||
|
||||
@@ -205,6 +205,7 @@ export const PATTERN_CAPABILITIES = [
|
||||
|
||||
// ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─
|
||||
{ pattern: "*gemini*image*", caps: { vision: true, imageOutput: true, contextWindow: 1048576 } },
|
||||
{ pattern: "*gemini-3.7*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
|
||||
{ pattern: "*gemini-3*pro*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65535 } },
|
||||
{ pattern: "*gemini-3*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
|
||||
{ pattern: "*gemini-2.5*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-budget", thinkingRange: { min: 0, max: 24576 }, contextWindow: 1048576, maxOutput: 65536 } },
|
||||
|
||||
@@ -38,3 +38,11 @@ export function modelStrip(model) {
|
||||
export function modelTargetFormat(model) {
|
||||
return model?.targetFormat || MODEL_DEFAULTS.targetFormat;
|
||||
}
|
||||
|
||||
// Per-model declared upstream formats (e.g. ["openai", "claude"]). Guards the
|
||||
// sourceFormat-matched transport for multi-endpoint providers whose models differ
|
||||
// in endpoint support (opencode-go: kimi/glm only do /chat/completions, minimax/qwen
|
||||
// also do /messages, deepseek also does /responses).
|
||||
export function modelSupportedFormats(model) {
|
||||
return model?.supportedFormats || null;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ export const MODEL_PRICING = {
|
||||
"o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
|
||||
|
||||
// === Gemini ===
|
||||
"gemini-3.7-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.7-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.7-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.7-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.6-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.6-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Token Plan — credit subscription keys on token-plan.<region>.maas.aliyuncs.com.
|
||||
// Fourth Alibaba key type: Coding Plan (alicode/alicode-intl) and Model Studio
|
||||
// (alims-intl) both reject these keys, and they reject Model Studio keys back.
|
||||
// Singapore is the only region that serves the plan; eu-central-1 answers
|
||||
// IllegalEndpoint. The Anthropic surface (/apps/anthropic/v1/messages) is not
|
||||
// authorized for this plan, so OpenAI-compatible mode is the only transport.
|
||||
export default {
|
||||
id: "alitp-intl",
|
||||
priority: 11,
|
||||
alias: "alitp-intl",
|
||||
display: {
|
||||
name: "Alibaba Token Plan",
|
||||
icon: "cloud",
|
||||
color: "#FF6A00",
|
||||
textIcon: "ATP",
|
||||
website: "https://www.alibabacloud.com/campaign/ai-landing-page-token",
|
||||
notice: {
|
||||
apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1",
|
||||
},
|
||||
},
|
||||
category: "apikey",
|
||||
transport: {
|
||||
baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
headers: {},
|
||||
quirks: { preserveCacheControl: true },
|
||||
},
|
||||
models: [
|
||||
{ id: "qwen3.8-max-preview", name: "Qwen3.8 Max Preview" },
|
||||
{ id: "qwen3.7-max", name: "Qwen3.7 Max" },
|
||||
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus" },
|
||||
{ id: "qwen3.6-flash", name: "Qwen3.6 Flash" },
|
||||
{ id: "glm-5.2", name: "GLM 5.2" },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
||||
],
|
||||
};
|
||||
@@ -45,6 +45,9 @@ export default {
|
||||
clientSecret: "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",
|
||||
},
|
||||
models: [
|
||||
{ id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", upstreamModelId: "gemini-3.7-flash-tiered(high)" },
|
||||
{ id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)", upstreamModelId: "gemini-3.7-flash-tiered(medium)" },
|
||||
{ id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)", upstreamModelId: "gemini-3.7-flash-tiered(low)" },
|
||||
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" },
|
||||
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" },
|
||||
{ id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" },
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Fish Audio TTS — the model id travels in an HTTP `model` header rather than the
|
||||
// JSON body, and the voice is a reference_id (a cloned or preset voice model).
|
||||
export default {
|
||||
id: "fish-audio",
|
||||
alias: "fish",
|
||||
display: {
|
||||
name: "Fish Audio",
|
||||
icon: "record_voice_over",
|
||||
color: "#1E9BF0",
|
||||
textIcon: "FA",
|
||||
website: "https://fish.audio",
|
||||
notice: {
|
||||
apiKeyUrl: "https://fish.audio/app/api-keys/",
|
||||
},
|
||||
},
|
||||
category: "apikey",
|
||||
authType: "apikey",
|
||||
serviceKinds: ["tts"],
|
||||
ttsConfig: {
|
||||
baseUrl: "https://api.fish.audio/v1/tts",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
format: "fish-audio",
|
||||
models: [
|
||||
{ id: "s2.1-pro-free", name: "S2.1 Pro Free" },
|
||||
{ id: "s2.1-pro", name: "S2.1 Pro" },
|
||||
{ id: "s2-pro", name: "S2 Pro" },
|
||||
{ id: "s1", name: "S1" },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -36,6 +36,7 @@ export default {
|
||||
},
|
||||
},
|
||||
models: [
|
||||
{ id: "gemini-3.7-flash", name: "Gemini 3.7 Flash" },
|
||||
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" },
|
||||
{ id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" },
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||
|
||||
@@ -21,6 +21,7 @@ export default {
|
||||
},
|
||||
},
|
||||
models: [
|
||||
{ id: "glm-5.3", name: "GLM 5.3" },
|
||||
{ id: "glm-5.2", name: "GLM 5.2" },
|
||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
|
||||
@@ -45,6 +45,7 @@ export default {
|
||||
},
|
||||
],
|
||||
models: [
|
||||
{ id: "glm-5.3", name: "GLM 5.3" },
|
||||
{ id: "glm-5.2", name: "GLM 5.2" },
|
||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
|
||||
@@ -118,6 +118,8 @@ import p115 from "./tokenrouter.js";
|
||||
import p116 from "./selfhosted-stt.js";
|
||||
import p117 from "./selfhosted-tts.js";
|
||||
import p118 from "./selfhosted-embedding.js";
|
||||
import p119 from "./fish-audio.js";
|
||||
import p120 from "./alitp-intl.js";
|
||||
|
||||
export default [
|
||||
p0,
|
||||
@@ -239,4 +241,6 @@ export default [
|
||||
p116,
|
||||
p117,
|
||||
p118,
|
||||
p119,
|
||||
p120,
|
||||
];
|
||||
|
||||
@@ -14,7 +14,7 @@ export default {
|
||||
},
|
||||
},
|
||||
category: "freeTier",
|
||||
authModes: ["oauth"],
|
||||
authModes: ["oauth", "apikey"],
|
||||
hasOAuth: true,
|
||||
transport: {
|
||||
baseUrl: "https://llm.kimchi.dev/openai/v1/chat/completions",
|
||||
|
||||
@@ -22,20 +22,28 @@ export default {
|
||||
baseUrl: "https://opencode.ai/zen/go/v1/chat/completions",
|
||||
headers: {},
|
||||
},
|
||||
// Multi-endpoint: pick the transport matching the client sourceFormat to skip
|
||||
// translation. Guarded per-model by `supportedFormats` (see chatCore) because
|
||||
// opencode-go models differ in endpoint support.
|
||||
transports: [
|
||||
{ format: "openai", baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
|
||||
{ format: "claude", baseUrl: "https://opencode.ai/zen/go/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } },
|
||||
{ format: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
|
||||
],
|
||||
models: [
|
||||
{ id: "glm-5.2", name: "GLM 5.2" },
|
||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
|
||||
{ id: "mimo-v2.5", name: "MiMo V2.5" },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
|
||||
{ id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
|
||||
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", targetFormat: "claude" },
|
||||
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", targetFormat: "claude" },
|
||||
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", targetFormat: "claude" },
|
||||
{ id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] },
|
||||
{ id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] },
|
||||
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] },
|
||||
{ id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] },
|
||||
{ id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -138,8 +138,42 @@ export function detectRequiredCapabilities(body) {
|
||||
if (Array.isArray(content)) for (const b of content) scanBlock(b);
|
||||
};
|
||||
|
||||
const scanMessage = (m) => {
|
||||
if (!m || typeof m !== "object") return;
|
||||
|
||||
// Ollama / Hermes images array (strings or objects)
|
||||
if (Array.isArray(m.images) && m.images.length > 0) {
|
||||
required.add("vision");
|
||||
}
|
||||
|
||||
// Vercel AI SDK / Hermes attachments / experimental_attachments
|
||||
const attachments = m.experimental_attachments || m.attachments;
|
||||
if (Array.isArray(attachments)) {
|
||||
for (const att of attachments) {
|
||||
if (!att) continue;
|
||||
const mime = att.contentType || att.mediaType || (typeof att.url === "string" && att.url.match(/^data:([^;,]+)/)?.[1]);
|
||||
if (mime) addByMime(mime);
|
||||
else if (att.url || att.data) required.add("vision");
|
||||
}
|
||||
}
|
||||
|
||||
// Direct message-level modality properties
|
||||
if (m.image_url || m.image) required.add("vision");
|
||||
if (m.audio_url || m.audio) required.add("audioInput");
|
||||
|
||||
// Scan array content blocks
|
||||
scanContent(m.content);
|
||||
|
||||
// Scan string content for embedded data URIs
|
||||
if (typeof m.content === "string") {
|
||||
if (m.content.includes("data:image/")) required.add("vision");
|
||||
else if (m.content.includes("data:audio/")) required.add("audioInput");
|
||||
else if (m.content.includes("data:application/pdf")) required.add("pdf");
|
||||
}
|
||||
};
|
||||
|
||||
// Modalities: current user turn only (trailing user run across each known shape).
|
||||
for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude
|
||||
for (const m of trailingUserItems(body.messages)) scanMessage(m); // openai / claude / hermes / ollama
|
||||
for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses
|
||||
const contents = body.contents || body.request?.contents; // gemini / antigravity
|
||||
for (const c of trailingUserItems(contents)) scanContent(c.parts);
|
||||
@@ -530,7 +564,10 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
|
||||
log.info("FUSION", `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`);
|
||||
|
||||
// 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose).
|
||||
const { tools, tool_choice, ...rest } = body;
|
||||
const { tools, tool_choice, stream_options, ...rest } = body;
|
||||
// Fusion runs panel models non-streaming; drop stream_options too, or providers
|
||||
// like DeepSeek reject it with "stream_options should be set along with stream = true".
|
||||
// See issue #3024.
|
||||
const panelBody = { ...rest, stream: false };
|
||||
|
||||
// Flatten tool turns to prose so panel models keep context without emitting tool_calls.
|
||||
|
||||
@@ -34,7 +34,7 @@ const USAGE_HANDLERS = {
|
||||
github: (c) => getGitHubUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||
"gemini-cli": (c) => getGeminiUsage(c.accessToken, c.providerDataWithProjectId, c.proxyOptions),
|
||||
antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions),
|
||||
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }),
|
||||
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
|
||||
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||
qoder: async (c) => {
|
||||
@@ -58,7 +58,7 @@ const USAGE_HANDLERS = {
|
||||
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 providerDataWithProjectId = {
|
||||
...(providerSpecificData || {}),
|
||||
@@ -67,5 +67,13 @@ export async function getUsageForProvider(connection, proxyOptions = null) {
|
||||
|
||||
const handler = USAGE_HANDLERS[provider];
|
||||
if (!handler) return { message: `Usage API not implemented for ${provider}` };
|
||||
return await handler({ provider, accessToken, apiKey, providerSpecificData, providerDataWithProjectId, proxyOptions });
|
||||
return await handler({
|
||||
provider,
|
||||
accessToken,
|
||||
apiKey,
|
||||
providerSpecificData,
|
||||
providerDataWithProjectId,
|
||||
proxyOptions,
|
||||
force: options.force === true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,43 @@ const CLAUDE_CONFIG = {
|
||||
const OAUTH_429_COOLDOWN_MS = 180000;
|
||||
const oauthCooldown = new Map();
|
||||
|
||||
export async function getClaudeUsage(accessToken, proxyOptions = null) {
|
||||
// Dedup + short TTL cache per access token. Many tabs / many accounts / auto-refresh
|
||||
// all funnel through here; without this each call hits Anthropic and triggers 429.
|
||||
const USAGE_CACHE_TTL_MS = 300000;
|
||||
const usageCache = new Map(); // token -> { promise } | { result, expiresAt }
|
||||
|
||||
export async function getClaudeUsage(accessToken, proxyOptions = null, options = {}) {
|
||||
const force = options?.force === true;
|
||||
|
||||
// Serve in-flight or fresh cached result (skip on manual force)
|
||||
if (!force && accessToken) {
|
||||
const hit = usageCache.get(accessToken);
|
||||
if (hit?.promise) return hit.promise;
|
||||
if (hit && hit.expiresAt > Date.now()) return hit.result;
|
||||
}
|
||||
|
||||
const stale = (!force && accessToken && usageCache.get(accessToken)?.result) || null;
|
||||
|
||||
const promise = (async () => {
|
||||
const result = await fetchClaudeUsageRaw(accessToken, proxyOptions);
|
||||
// Only cache real quota data, not soft-failure {message: ...} payloads
|
||||
if (accessToken && result?.quotas) {
|
||||
usageCache.set(accessToken, {
|
||||
result,
|
||||
expiresAt: Date.now() + USAGE_CACHE_TTL_MS,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
// Soft failure (429/error): prefer the last good read over a transient error
|
||||
if (stale) return stale;
|
||||
return result;
|
||||
})();
|
||||
|
||||
if (accessToken) usageCache.set(accessToken, { promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
|
||||
try {
|
||||
// Skip OAuth usage call while this token is cooling down from a recent 429
|
||||
const cooldownUntil = oauthCooldown.get(accessToken);
|
||||
|
||||
@@ -161,6 +161,9 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
||||
if (data.models) {
|
||||
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
|
||||
const importantModels = [
|
||||
'gemini-3.7-flash-high',
|
||||
'gemini-3.7-flash-medium',
|
||||
'gemini-3.7-flash-low',
|
||||
'gemini-3.6-flash-high',
|
||||
'gemini-3.6-flash-medium',
|
||||
'gemini-3.6-flash-low',
|
||||
|
||||
@@ -62,6 +62,19 @@ function stripOpenAI(body, caps) {
|
||||
if (!Array.isArray(body.messages)) return;
|
||||
const last = body.messages.length - 1;
|
||||
body.messages.forEach((msg, i) => {
|
||||
if (caps.vision === false) {
|
||||
if (Array.isArray(msg.images)) delete msg.images;
|
||||
if (Array.isArray(msg.experimental_attachments)) {
|
||||
msg.experimental_attachments = msg.experimental_attachments.filter(
|
||||
(a) => !(a?.contentType?.startsWith("image/") || (typeof a?.url === "string" && a.url.startsWith("data:image/")))
|
||||
);
|
||||
}
|
||||
if (Array.isArray(msg.attachments)) {
|
||||
msg.attachments = msg.attachments.filter(
|
||||
(a) => !(a?.contentType?.startsWith("image/") || (typeof a?.url === "string" && a.url.startsWith("data:image/")))
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(msg.content)) return;
|
||||
const removed = new Set();
|
||||
msg.content = filterBlocks(msg.content, capForOpenAIBlock, caps, removed, i === last);
|
||||
|
||||
@@ -9,6 +9,9 @@ import { PROVIDERS } from "../../providers/index.js";
|
||||
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
|
||||
import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js";
|
||||
|
||||
const CACHE_CONTROL_5M = { type: "ephemeral" };
|
||||
const CACHE_CONTROL_1H = { type: "ephemeral", ttl: "1h" };
|
||||
|
||||
// Check if message has valid non-empty content
|
||||
export function hasValidContent(msg) {
|
||||
if (typeof msg.content === "string" && msg.content.trim()) return true;
|
||||
@@ -124,32 +127,38 @@ export function normalizeClaudePassthrough(body, model = "") {
|
||||
if (Object.keys(body.output_config).length === 0) delete body.output_config;
|
||||
}
|
||||
|
||||
// 2. Hoist mid-conversation system messages into the top-level system field
|
||||
// 2. Fold mid-conversation system messages into the neighbouring turn.
|
||||
// Hoisting them into body.system would insert volatile content (token counters,
|
||||
// reminders) ahead of the whole conversation and invalidate the prefix cache on
|
||||
// every request. Folding in place keeps the cached prefix stable.
|
||||
if (Array.isArray(body.messages)) {
|
||||
const systemBlocks = [];
|
||||
const messages = [];
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role === ROLE.SYSTEM) {
|
||||
const text = typeof msg.content === "string"
|
||||
? msg.content
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n")
|
||||
: "";
|
||||
if (text.trim()) systemBlocks.push({ type: CLAUDE_BLOCK.TEXT, text });
|
||||
if (msg.role !== ROLE.SYSTEM) {
|
||||
messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
messages.push(msg);
|
||||
}
|
||||
const text = typeof msg.content === "string"
|
||||
? msg.content
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n")
|
||||
: "";
|
||||
if (!text.trim()) continue;
|
||||
|
||||
if (systemBlocks.length > 0) {
|
||||
const existing = Array.isArray(body.system)
|
||||
? body.system
|
||||
: typeof body.system === "string" && body.system.trim()
|
||||
? [{ type: "text", text: body.system }]
|
||||
: [];
|
||||
body.system = [...existing, ...systemBlocks];
|
||||
body.messages = messages;
|
||||
// Copy-on-write: the caller's body is reused across account-fallback
|
||||
// attempts, so folding must never mutate the original message.
|
||||
const block = { type: CLAUDE_BLOCK.TEXT, text };
|
||||
const prev = messages[messages.length - 1];
|
||||
if (prev?.role === ROLE.USER) {
|
||||
const content = typeof prev.content === "string"
|
||||
? [{ type: CLAUDE_BLOCK.TEXT, text: prev.content }]
|
||||
: Array.isArray(prev.content) ? [...prev.content] : [];
|
||||
messages[messages.length - 1] = { ...prev, content: [...content, block] };
|
||||
continue;
|
||||
}
|
||||
messages.push({ role: ROLE.USER, content: [block] });
|
||||
}
|
||||
body.messages = messages;
|
||||
}
|
||||
|
||||
// 3. Drop thinking blocks whose signature is not Claude's (combo mixes models,
|
||||
@@ -182,6 +191,70 @@ export function normalizeClaudePassthrough(body, model = "") {
|
||||
return body;
|
||||
}
|
||||
|
||||
// Put a 5m breakpoint on the last cache-eligible block of a message.
|
||||
// thinking/redacted_thinking blocks do not accept cache_control.
|
||||
function markLastCacheableBlock(msg) {
|
||||
if (!Array.isArray(msg?.content)) return false;
|
||||
for (let i = msg.content.length - 1; i >= 0; i--) {
|
||||
const block = msg.content[i];
|
||||
if (typeof block !== "object" || block === null) continue;
|
||||
if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) continue;
|
||||
block.cache_control = { ...CACHE_CONTROL_5M };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-anchor cache breakpoints on a Claude passthrough body (same policy as
|
||||
// prepareClaudeRequest): last tool + last system block at 1h, last assistant at 5m.
|
||||
// The client's own markers point at pre-normalization offsets, so they are dropped.
|
||||
// Must run LAST, after every step that can reshape system/tools/messages
|
||||
// (normalize, tool dedupe, token savers) — otherwise the anchor drifts off the tail.
|
||||
export function anchorClaudeCache(body) {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
|
||||
if (Array.isArray(body.system)) {
|
||||
const last = body.system.length - 1;
|
||||
body.system.forEach((block, i) => {
|
||||
if (typeof block !== "object" || block === null) return;
|
||||
if (i === last) block.cache_control = { ...CACHE_CONTROL_1H };
|
||||
else delete block.cache_control;
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body.tools)) {
|
||||
const last = body.tools.length - 1;
|
||||
body.tools.forEach((tool, i) => {
|
||||
if (i === last) tool.cache_control = { ...CACHE_CONTROL_1H };
|
||||
else delete tool.cache_control;
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body.messages)) {
|
||||
let anchored = null;
|
||||
for (let i = body.messages.length - 1; i >= 0; i--) {
|
||||
const msg = body.messages[i];
|
||||
if (!Array.isArray(msg.content)) continue;
|
||||
for (const block of msg.content) delete block.cache_control;
|
||||
|
||||
// Prefer the last assistant turn: it ends a completed exchange, so the
|
||||
// prefix up to it stays byte-stable across the following requests.
|
||||
if (anchored || msg.role !== ROLE.ASSISTANT) continue;
|
||||
anchored = markLastCacheableBlock(msg);
|
||||
}
|
||||
|
||||
// First turn of a conversation has no assistant yet — anchor the final
|
||||
// message instead, so the opening prompt is cached rather than paid twice.
|
||||
if (!anchored) {
|
||||
for (let i = body.messages.length - 1; i >= 0 && !anchored; i--) {
|
||||
anchored = markLastCacheableBlock(body.messages[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
// Prepare request for Claude format endpoints
|
||||
// - Cleanup cache_control
|
||||
// - Filter empty messages
|
||||
|
||||
@@ -287,6 +287,18 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||
toolSpecs,
|
||||
nameMap,
|
||||
});
|
||||
// canonicalizeKiroConversation() already ran its second-chance repair (flatten
|
||||
// every structured tool turn to text, then re-validate). A body that is STILL
|
||||
// invalid here cannot be made shippable, and Kiro answers it with
|
||||
// 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"}.
|
||||
// Fail locally instead: chatCore turns a falsy return into a 400 without
|
||||
// spending an upstream call or a per-account cooldown. The taxonomy
|
||||
// (role:N | pair:N | id:N | spec:N | orphan:0 | current) names the offending
|
||||
// turn so the shape can be diagnosed from the log alone.
|
||||
if (!canonical.valid) {
|
||||
console.error(`[Kiro] refusing invalid conversation (claude → kiro): ${(canonical.errors || []).join(", ") || "unknown"} | turns=${(canonical.history || []).length + 1}`);
|
||||
return null;
|
||||
}
|
||||
const replayCurrent = canonical.currentMessage.userInputMessage;
|
||||
const userInputMessage = {
|
||||
content: replayCurrent.content || "",
|
||||
|
||||
@@ -421,6 +421,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
|
||||
if (body.reasoning !== undefined) result.reasoning = body.reasoning;
|
||||
if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" };
|
||||
if (body.service_tier !== undefined) result.service_tier = body.service_tier;
|
||||
if (body.prompt_cache_key !== undefined) result.prompt_cache_key = body.prompt_cache_key;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -379,6 +379,18 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||
toolSpecs,
|
||||
nameMap,
|
||||
});
|
||||
// canonicalizeKiroConversation() already ran its second-chance repair (flatten
|
||||
// every structured tool turn to text, then re-validate). A body that is STILL
|
||||
// invalid here cannot be made shippable, and Kiro answers it with
|
||||
// 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"}.
|
||||
// Fail locally instead: chatCore turns a falsy return into a 400 without
|
||||
// spending an upstream call or a per-account cooldown. The taxonomy
|
||||
// (role:N | pair:N | id:N | spec:N | orphan:0 | current) names the offending
|
||||
// turn so the shape can be diagnosed from the log alone.
|
||||
if (!canonical.valid) {
|
||||
console.error(`[Kiro] refusing invalid conversation (openai → kiro): ${(canonical.errors || []).join(", ") || "unknown"} | turns=${(canonical.history || []).length + 1}`);
|
||||
return null;
|
||||
}
|
||||
const replayCurrent = canonical.currentMessage.userInputMessage;
|
||||
|
||||
const payload = {
|
||||
|
||||
@@ -75,6 +75,15 @@ export function kiroToClaudeResponse(chunk, state) {
|
||||
? data.usage.completion_tokens
|
||||
: 0;
|
||||
state.usage = { input_tokens: promptTokens, output_tokens: outputTokens };
|
||||
// Claude clients read cache_read/cache_creation to price a turn and to size
|
||||
// their prompt cache. Both spellings are accepted because the Kiro executor
|
||||
// emits the Chat shape and passthrough responses use the nested details form.
|
||||
const cacheRead = data.usage.cache_read_input_tokens
|
||||
?? data.usage.prompt_tokens_details?.cached_tokens;
|
||||
const cacheCreation = data.usage.cache_creation_input_tokens
|
||||
?? data.usage.prompt_tokens_details?.cache_creation_tokens;
|
||||
if (typeof cacheRead === "number") state.usage.cache_read_input_tokens = cacheRead;
|
||||
if (typeof cacheCreation === "number") state.usage.cache_creation_input_tokens = cacheCreation;
|
||||
}
|
||||
|
||||
// First chunk → emit message_start.
|
||||
@@ -254,6 +263,13 @@ export function kiroToClaudeNonStreaming(data) {
|
||||
usage: {
|
||||
input_tokens: usage.prompt_tokens || 0,
|
||||
output_tokens: usage.completion_tokens || 0,
|
||||
// Same cache preservation as the streaming path above.
|
||||
...(typeof (usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens) === "number"
|
||||
? { cache_read_input_tokens: usage.cache_read_input_tokens ?? usage.prompt_tokens_details.cached_tokens }
|
||||
: {}),
|
||||
...(typeof (usage.cache_creation_input_tokens ?? usage.prompt_tokens_details?.cache_creation_tokens) === "number"
|
||||
? { cache_creation_input_tokens: usage.cache_creation_input_tokens ?? usage.prompt_tokens_details.cache_creation_tokens }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,8 +99,8 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool_calls
|
||||
if (delta.tool_calls) {
|
||||
// Handle tool_calls (empty array is truthy; require a real call)
|
||||
if (delta.tool_calls && delta.tool_calls.length) {
|
||||
closeMessage(state, emit, idx);
|
||||
for (const tc of delta.tool_calls) {
|
||||
emitToolCall(state, emit, tc);
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.5.50",
|
||||
"version": "0.5.55",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -9,10 +9,10 @@
|
||||
"build": "next build --webpack",
|
||||
"postbuild": "node scripts/copy-standalone-assets.mjs",
|
||||
"postbuild:bun": "node scripts/copy-standalone-assets.mjs",
|
||||
"start": "next start --port 20127",
|
||||
"start": "node custom-server.js --port 20127",
|
||||
"dev:bun": "bun --bun next dev --webpack --port 20127",
|
||||
"build:bun": "bun --bun next build --webpack",
|
||||
"start:bun": "bun ./.next/standalone/server.js",
|
||||
"start:bun": "bun ./.next/standalone/custom-server.js",
|
||||
"cli:pack": "npm --prefix cli run pack:cli",
|
||||
"cli:publish": "npm --prefix cli run publish:cli"
|
||||
},
|
||||
@@ -23,8 +23,10 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@next/third-parties": "^16.2.9",
|
||||
"@node-saml/node-saml": "^5.1.0",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"chalk": "^5.6.2",
|
||||
"confbox": "^0.2.4",
|
||||
"dompurify": "^3.4.13",
|
||||
"express": "^5.2.1",
|
||||
@@ -38,6 +40,7 @@
|
||||
"node-machine-id": "^1.1.12",
|
||||
"open": "^11.0.0",
|
||||
"ora": "^9.1.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-is": "^16.13.1",
|
||||
|
||||
@@ -29,6 +29,14 @@ export function copyStandaloneAssets({ projectRoot = process.cwd(), distDir = pr
|
||||
cpSync(publicSource, publicDestination, { recursive: true, force: true });
|
||||
console.log(`[standalone-assets] Copied public assets to ${publicDestination}`);
|
||||
}
|
||||
|
||||
// Without it beside server.js the standalone build serves requests unsanitized.
|
||||
const serverWrapperSource = resolve(projectRoot, "custom-server.js");
|
||||
const serverWrapperDestination = resolve(standaloneDir, "custom-server.js");
|
||||
if (existsSync(serverWrapperSource)) {
|
||||
cpSync(serverWrapperSource, serverWrapperDestination, { force: true });
|
||||
console.log(`[standalone-assets] Copied custom-server.js to ${serverWrapperDestination}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(dirname(fileURLToPath(import.meta.url)), "copy-standalone-assets.mjs")) {
|
||||
|
||||
@@ -170,7 +170,7 @@ export default function HermesToolCard({
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n`;
|
||||
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n api_key: \${OPENAI_API_KEY}\n`;
|
||||
const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
|
||||
|
||||
return [
|
||||
|
||||
@@ -31,7 +31,7 @@ function getLocaleFromCookie() {
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { theme, setTheme, isDark } = useTheme();
|
||||
const [locale, setLocale] = useState("en");
|
||||
const [locale, setLocale] = useState(() => getLocaleFromCookie());
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||
const [isShuttingDown, setIsShuttingDown] = useState(false);
|
||||
@@ -56,8 +56,31 @@ export default function ProfilePage() {
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [oidcTestLoading, setOidcTestLoading] = useState(false);
|
||||
const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" });
|
||||
const [oidcRedirectUri, setOidcRedirectUri] = useState("/api/auth/oidc/callback");
|
||||
const [oidcExpanded, setOidcExpanded] = useState(false);
|
||||
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const oidcRedirectUri = origin ? `${origin}/api/auth/oidc/callback` : "/api/auth/oidc/callback";
|
||||
const samlAcsUrl = origin ? `${origin}/api/auth/saml/acs` : "/api/auth/saml/acs";
|
||||
const samlMetadataUrl = origin ? `${origin}/api/auth/saml/metadata` : "/api/auth/saml/metadata";
|
||||
|
||||
// SAML State
|
||||
const [ssoTypeTab, setSsoTypeTab] = useState("saml");
|
||||
const [samlForm, setSamlForm] = useState({
|
||||
samlEntryPoint: "",
|
||||
samlIssuer: "urn:9router:sp",
|
||||
samlCert: "",
|
||||
samlLoginLabel: "Sign in with SAML SSO",
|
||||
samlAttributeEmail: "email",
|
||||
samlAttributeName: "name",
|
||||
});
|
||||
const [samlStatus, setSamlStatus] = useState({ type: "", message: "" });
|
||||
const [samlLoading, setSamlLoading] = useState(false);
|
||||
const [samlTestLoading, setSamlTestLoading] = useState(false);
|
||||
const [samlTestStatus, setSamlTestStatus] = useState({ type: "", message: "" });
|
||||
const [showSamlGuide, setShowSamlGuide] = useState(false);
|
||||
const idpMetadataFileRef = useRef(null);
|
||||
const certFileRef = useRef(null);
|
||||
|
||||
const importFileRef = useRef(null);
|
||||
const [memoryInfo, setMemoryInfo] = useState(null);
|
||||
const [memoryLoading, setMemoryLoading] = useState(false);
|
||||
@@ -70,10 +93,6 @@ export default function ProfilePage() {
|
||||
const [proxyLoading, setProxyLoading] = useState(false);
|
||||
const [proxyTestLoading, setProxyTestLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLocale(getLocaleFromCookie());
|
||||
}, [langOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
.then((res) => res.json())
|
||||
@@ -87,7 +106,23 @@ export default function ProfilePage() {
|
||||
oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC",
|
||||
});
|
||||
setOidcClientSecret("");
|
||||
if (data?.authMode === "oidc" || data?.authMode === "both") setOidcExpanded(true);
|
||||
setSsoTypeTab(data?.ssoType || "saml");
|
||||
setSamlForm({
|
||||
samlEntryPoint: data?.samlEntryPoint || "",
|
||||
samlIssuer: data?.samlIssuer || "urn:9router:sp",
|
||||
samlCert: data?.samlCert || "",
|
||||
samlLoginLabel: data?.samlLoginLabel || "Sign in with SAML SSO",
|
||||
samlAttributeEmail: data?.samlAttributeEmail || "email",
|
||||
samlAttributeName: data?.samlAttributeName || "name",
|
||||
});
|
||||
if (
|
||||
data?.authMode === "sso" ||
|
||||
data?.authMode === "saml" ||
|
||||
data?.authMode === "oidc" ||
|
||||
data?.authMode === "both"
|
||||
) {
|
||||
setOidcExpanded(true);
|
||||
}
|
||||
setProxyForm({
|
||||
outboundProxyEnabled: data?.outboundProxyEnabled === true,
|
||||
outboundProxyUrl: data?.outboundProxyUrl || "",
|
||||
@@ -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) => {
|
||||
e.preventDefault();
|
||||
if (settings.outboundProxyEnabled !== true) return;
|
||||
@@ -343,6 +372,7 @@ export default function ProfilePage() {
|
||||
try {
|
||||
const payload = {
|
||||
authMode,
|
||||
ssoType: "oidc",
|
||||
oidcIssuerUrl: issuerUrl,
|
||||
oidcClientId: clientId,
|
||||
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) => {
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
@@ -793,7 +976,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* OIDC */}
|
||||
{/* Single Sign-On (SSO) */}
|
||||
<Card>
|
||||
<button
|
||||
type="button"
|
||||
@@ -804,9 +987,13 @@ export default function ProfilePage() {
|
||||
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold">OIDC Dashboard Login</h3>
|
||||
<h3 className="text-base sm:text-lg font-semibold">Single Sign-On (SSO)</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
{settings.authMode === "oidc" ? "OIDC active" : settings.authMode === "both" ? "Password + OIDC active" : "Optional SSO via Authentik/Keycloak/Google"}
|
||||
{settings.authMode === "sso" || settings.authMode === "oidc" || settings.authMode === "saml"
|
||||
? `${settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"} SSO active`
|
||||
: settings.authMode === "both"
|
||||
? `Password + ${settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"} active`
|
||||
: "Optional SSO via Okta, Entra ID, Keycloak, or OIDC"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-text-muted shrink-0">
|
||||
@@ -814,145 +1001,472 @@ export default function ProfilePage() {
|
||||
</span>
|
||||
</button>
|
||||
{oidcExpanded && (
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<p className="text-xs sm:text-sm text-text-muted">
|
||||
Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<p className="text-xs sm:text-sm text-text-muted">
|
||||
Configure enterprise Single Sign-On (SSO) for dashboard access using SAML 2.0 or OIDC.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{[
|
||||
{
|
||||
value: "password",
|
||||
title: "Password only",
|
||||
desc: "Keep the legacy password login.",
|
||||
},
|
||||
{
|
||||
value: "oidc",
|
||||
title: "OIDC only",
|
||||
desc: "Require OIDC for dashboard access.",
|
||||
},
|
||||
{
|
||||
value: "both",
|
||||
title: "Both",
|
||||
desc: "Allow either password or OIDC.",
|
||||
},
|
||||
].map((option) => {
|
||||
const active = oidcForm.authMode === option.value;
|
||||
return (
|
||||
{/* SSO Protocol Switcher Tabs */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">SSO Protocol</label>
|
||||
<div className="flex p-1 rounded-lg bg-black/5 dark:bg-white/5 border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSsoTypeTab("saml")}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
|
||||
ssoTypeTab === "saml"
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
SAML 2.0
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSsoTypeTab("oidc")}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
|
||||
ssoTypeTab === "oidc"
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
OIDC
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Mode selection */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{[
|
||||
{
|
||||
value: "password",
|
||||
title: "Password only",
|
||||
desc: "Keep legacy password login.",
|
||||
},
|
||||
{
|
||||
value: "sso",
|
||||
title: `${ssoTypeTab === "saml" ? "SAML" : "OIDC"} only`,
|
||||
desc: "Require SSO for dashboard access.",
|
||||
},
|
||||
{
|
||||
value: "both",
|
||||
title: "Both",
|
||||
desc: "Allow password or SSO login.",
|
||||
},
|
||||
].map((option) => {
|
||||
const currentMode = oidcForm.authMode;
|
||||
const active =
|
||||
option.value === "password"
|
||||
? currentMode === "password"
|
||||
: option.value === "sso"
|
||||
? currentMode === "sso" || currentMode === "saml" || currentMode === "oidc"
|
||||
: currentMode === "both";
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => updateOidcForm("authMode", option.value)}
|
||||
className={cn(
|
||||
"text-left rounded-lg border p-3 transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
|
||||
)}
|
||||
disabled={loading || oidcLoading || samlLoading}
|
||||
>
|
||||
<p className="font-medium text-sm sm:text-base">{option.title}</p>
|
||||
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ssoTypeTab === "saml" ? (
|
||||
/* SAML Configuration Panel */
|
||||
<div className="flex flex-col gap-4 pt-2 border-t border-border/50">
|
||||
{/* IdP Setup Guidelines Banner & Collapsible Drawer */}
|
||||
<div className="rounded-lg border border-border bg-bg/80 overflow-hidden">
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => updateOidcForm("authMode", option.value)}
|
||||
className={cn(
|
||||
"text-left rounded-lg border p-3 transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
|
||||
)}
|
||||
disabled={loading || oidcLoading}
|
||||
onClick={() => setShowSamlGuide((prev) => !prev)}
|
||||
className="w-full p-3 flex items-center justify-between gap-2 text-left hover:bg-surface/50 transition-colors"
|
||||
>
|
||||
<p className="font-medium text-sm sm:text-base">{option.title}</p>
|
||||
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-lg">menu_book</span>
|
||||
<div>
|
||||
<p className="font-semibold text-xs sm:text-sm text-text-main">
|
||||
IdP Setup Guidelines & Provider Configuration Instructions
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
Click to view setup steps for AWS IAM Identity Center, Okta, Entra ID, Keycloak, & Authentik
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="material-symbols-outlined text-text-muted transition-transform text-lg"
|
||||
style={{ transform: showSamlGuide ? "rotate(180deg)" : "none" }}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showSamlGuide && (
|
||||
<div className="p-4 border-t border-border bg-surface/30 text-xs text-text-main flex flex-col gap-3">
|
||||
<div className="p-2.5 rounded border border-primary/20 bg-primary/5 text-primary text-xs">
|
||||
<p className="font-semibold mb-1">🔑 Required Service Provider (SP) Values for your IdP Setup:</p>
|
||||
<ul className="list-disc pl-4 space-y-1 font-mono text-[11px]">
|
||||
<li>
|
||||
<b>Assertion Consumer Service (ACS) URL:</b>{" "}
|
||||
<code className="bg-bg px-1 py-0.5 rounded break-all">{samlAcsUrl}</code>
|
||||
</li>
|
||||
<li>
|
||||
<b>SP Entity ID / Audience URI:</b>{" "}
|
||||
<code className="bg-bg px-1 py-0.5 rounded break-all">{samlForm.samlIssuer || "urn:9router:sp"}</code>
|
||||
</li>
|
||||
<li>
|
||||
<b>NameID Format:</b>{" "}
|
||||
<code className="bg-bg px-1 py-0.5 rounded">EmailAddress</code> or <code className="bg-bg px-1 py-0.5 rounded">Unspecified</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-1">
|
||||
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
|
||||
<p className="font-semibold text-text-main flex items-center gap-1.5">
|
||||
<span>☁️</span> AWS IAM Identity Center
|
||||
</p>
|
||||
<ol className="list-decimal pl-4 text-text-muted space-y-1">
|
||||
<li>Applications → <b>Add application</b> → Select <b>Add custom SAML 2.0 application</b>.</li>
|
||||
<li>Set <b>Application ACS URL</b> to <code className="text-text-main font-mono">{samlAcsUrl}</code>.</li>
|
||||
<li>Set <b>Application SAML audience</b> to <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code>.</li>
|
||||
<li>Under <i>Attribute mappings</i>, map <code className="text-text-main font-mono">Subject</code> or <code className="text-text-main font-mono">email</code> to <code className="text-text-main font-mono">${`{user:email}`}</code>.</li>
|
||||
<li>Download <b>IAM Identity Center SAML metadata XML</b> file and use 1-Click Import below!</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
|
||||
<p className="font-semibold text-text-main flex items-center gap-1.5">
|
||||
<span>🔷</span> Microsoft Entra ID (Azure AD)
|
||||
</p>
|
||||
<ol className="list-decimal pl-4 text-text-muted space-y-1">
|
||||
<li>Enterprise Applications → <b>New application</b> → <b>Create your own application</b>.</li>
|
||||
<li>Select <b>Single sign-on</b> → <b>SAML</b>.</li>
|
||||
<li><b>Identifier (Entity ID):</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
|
||||
<li><b>Reply URL (ACS):</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
|
||||
<li>Download <b>Federation Metadata XML</b> and import or copy X.509 Certificate.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
|
||||
<p className="font-semibold text-text-main flex items-center gap-1.5">
|
||||
<span>🟢</span> Okta / Auth0
|
||||
</p>
|
||||
<ol className="list-decimal pl-4 text-text-muted space-y-1">
|
||||
<li>Applications → <b>Create App Integration</b> → Select <b>SAML 2.0</b>.</li>
|
||||
<li><b>Single Sign-On URL:</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
|
||||
<li><b>Audience URI (SP Entity ID):</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
|
||||
<li>Name ID format: <i>EmailAddress</i>.</li>
|
||||
<li>Download Identity Provider metadata XML or copy the X.509 cert.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
|
||||
<p className="font-semibold text-text-main flex items-center gap-1.5">
|
||||
<span>🛡️</span> Keycloak / Authentik
|
||||
</p>
|
||||
<ol className="list-decimal pl-4 text-text-muted space-y-1">
|
||||
<li>Clients → <b>Create client</b> → Select <b>SAML</b>.</li>
|
||||
<li><b>Client ID:</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
|
||||
<li><b>Master SAML Processing URL:</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
|
||||
<li>Export SAML Descriptor XML or copy IDP Certificate PEM.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Import Card */}
|
||||
<div className="p-3 rounded-lg border border-dashed border-primary/40 bg-primary/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-sm text-text-main">1-Click IdP Metadata XML Import</p>
|
||||
<p className="text-xs text-text-muted">Auto-fill SSO URL, Issuer & Cert from XML metadata</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="upload_file"
|
||||
onClick={() => idpMetadataFileRef.current?.click()}
|
||||
>
|
||||
Upload Metadata XML
|
||||
</Button>
|
||||
<input
|
||||
ref={idpMetadataFileRef}
|
||||
type="file"
|
||||
accept=".xml,application/xml,text/xml"
|
||||
className="hidden"
|
||||
onChange={handleIdpMetadataUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Single Sign-On Service URL (samlEntryPoint)</label>
|
||||
<Input
|
||||
placeholder="https://idp.example.com/app/saml/sso/..."
|
||||
value={samlForm.samlEntryPoint}
|
||||
onChange={(e) => updateSamlForm("samlEntryPoint", e.target.value)}
|
||||
disabled={loading || samlLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">SP Entity ID / Audience (samlIssuer)</label>
|
||||
<Input
|
||||
placeholder="urn:9router:sp"
|
||||
value={samlForm.samlIssuer}
|
||||
onChange={(e) => updateSamlForm("samlIssuer", e.target.value)}
|
||||
disabled={loading || samlLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium text-sm sm:text-base">IdP X.509 Certificate (samlCert)</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="file_upload"
|
||||
onClick={() => certFileRef.current?.click()}
|
||||
>
|
||||
Upload Certificate
|
||||
</Button>
|
||||
<input
|
||||
ref={certFileRef}
|
||||
type="file"
|
||||
accept=".crt,.pem,.cer,text/plain"
|
||||
className="hidden"
|
||||
onChange={handleCertFileUpload}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="-----BEGIN CERTIFICATE----- MIIC... -----END CERTIFICATE-----"
|
||||
value={samlForm.samlCert}
|
||||
onChange={(e) => updateSamlForm("samlCert", e.target.value)}
|
||||
className="w-full p-2.5 rounded-lg border border-border bg-bg text-xs font-mono text-text-main focus:outline-none focus:border-primary"
|
||||
disabled={loading || samlLoading}
|
||||
/>
|
||||
<p className="text-xs text-text-muted">Paste raw Base64 certificate or PEM block.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
|
||||
<Input
|
||||
placeholder="Sign in with SAML SSO"
|
||||
value={samlForm.samlLoginLabel}
|
||||
onChange={(e) => updateSamlForm("samlLoginLabel", e.target.value)}
|
||||
disabled={loading || samlLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Email Claim Attribute</label>
|
||||
<Input
|
||||
placeholder="email"
|
||||
value={samlForm.samlAttributeEmail}
|
||||
onChange={(e) => updateSamlForm("samlAttributeEmail", e.target.value)}
|
||||
disabled={loading || samlLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Display Name Claim</label>
|
||||
<Input
|
||||
placeholder="name"
|
||||
value={samlForm.samlAttributeName}
|
||||
onChange={(e) => updateSamlForm("samlAttributeName", e.target.value)}
|
||||
disabled={loading || samlLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-bg text-xs sm:text-sm text-text-muted">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium text-text-main">ACS Callback URL</p>
|
||||
<code className="block break-all font-mono text-xs">{samlAcsUrl}</code>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="content_copy"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(samlAcsUrl);
|
||||
setSamlStatus({ type: "success", message: "ACS URL copied to clipboard!" });
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border/50">
|
||||
<div>
|
||||
<p className="font-medium text-text-main">SP XML Metadata</p>
|
||||
<code className="block break-all font-mono text-xs">{samlMetadataUrl}</code>
|
||||
</div>
|
||||
<a
|
||||
href={samlMetadataUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download="9router-sp-metadata.xml"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">download</span>
|
||||
Download XML
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
loading={samlLoading}
|
||||
onClick={() => saveSamlSettings(oidcForm.authMode)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Save SAML settings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
loading={samlTestLoading}
|
||||
onClick={testSamlConnection}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Test SAML settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{samlTestStatus.message && (
|
||||
<p className={`text-xs sm:text-sm ${samlTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||
{samlTestStatus.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{samlStatus.message && (
|
||||
<p className={`text-xs sm:text-sm ${samlStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||
{samlStatus.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* OIDC Panel */
|
||||
<div className="flex flex-col gap-4 pt-2 border-t border-border/50">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Issuer URL</label>
|
||||
<Input
|
||||
placeholder="https://auth.example.com/application/o/9router/"
|
||||
value={oidcForm.oidcIssuerUrl}
|
||||
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Client ID</label>
|
||||
<Input
|
||||
placeholder="9router-dashboard"
|
||||
value={oidcForm.oidcClientId}
|
||||
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Client Secret</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Leave blank to keep existing secret"
|
||||
value={oidcClientSecret}
|
||||
onChange={(e) => setOidcClientSecret(e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Scopes</label>
|
||||
<Input
|
||||
placeholder="openid profile email"
|
||||
value={oidcForm.oidcScopes}
|
||||
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
|
||||
<Input
|
||||
placeholder="Sign in with OIDC"
|
||||
value={oidcForm.oidcLoginLabel}
|
||||
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
|
||||
<p className="font-medium text-text-main mb-1">Redirect URI</p>
|
||||
<code className="block break-all font-mono">{oidcRedirectUri}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
|
||||
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto">
|
||||
Save OIDC settings
|
||||
</Button>
|
||||
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto">
|
||||
Test connection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oidcTestStatus.message && (
|
||||
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||
{oidcTestStatus.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oidcStatus.message && (
|
||||
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||
{oidcStatus.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.authMode === "oidc" || settings.authMode === "saml" || settings.authMode === "sso" ? (
|
||||
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
|
||||
SSO login ({settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"}) is currently active. Password login is disabled until you switch back.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{settings.authMode === "both" && (
|
||||
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
|
||||
Password and SSO login ({settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"}) are both active.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Issuer URL</label>
|
||||
<Input
|
||||
placeholder="https://auth.example.com/application/o/9router/"
|
||||
value={oidcForm.oidcIssuerUrl}
|
||||
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Client ID</label>
|
||||
<Input
|
||||
placeholder="9router-dashboard"
|
||||
value={oidcForm.oidcClientId}
|
||||
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Client Secret</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Leave blank to keep existing secret"
|
||||
value={oidcClientSecret}
|
||||
onChange={(e) => setOidcClientSecret(e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Scopes</label>
|
||||
<Input
|
||||
placeholder="openid profile email"
|
||||
value={oidcForm.oidcScopes}
|
||||
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
|
||||
<Input
|
||||
placeholder="Sign in with OIDC"
|
||||
value={oidcForm.oidcLoginLabel}
|
||||
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
|
||||
<p className="font-medium text-text-main mb-1">Redirect URI</p>
|
||||
<code className="block break-all font-mono">{oidcRedirectUri}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
|
||||
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto">
|
||||
Save auth mode
|
||||
</Button>
|
||||
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto">
|
||||
Test connection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oidcTestStatus.message && (
|
||||
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||
{oidcTestStatus.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oidcStatus.message && (
|
||||
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||
{oidcStatus.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{settings.authMode === "oidc" && (
|
||||
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
|
||||
OIDC login is currently active. Password login is disabled until you switch back.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{settings.authMode === "both" && (
|
||||
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
|
||||
Password and OIDC login are both active.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user