Compare commits

...

12 Commits

Author SHA1 Message Date
diegosouzapw fe5c20a04e feat(release): v2.8.0 — Bailian Coding Plan, editable provider URLs, 812 tests
Build Electron Desktop App / Validate version (push) Failing after 34s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-19 02:28:45 -03:00
diegosouzapw 246fd05fae feat(providers): add Bailian Coding Plan provider with editable base URL (#467) 2026-03-19 02:25:29 -03:00
diegosouzapw a09b298127 feat(release): v2.7.10 — Alibaba Cloud Coding, Kimi Coding API-key, Docker pino fix
Build Electron Desktop App / Validate version (push) Failing after 34s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-19 01:50:00 -03:00
Jefferson Nunn f89f40778f feat: add API-key Kimi Coding provider path (#463)
* feat: add api-key Kimi Coding provider support

* fix(kimi-coding): honor apikey auth header in executor

Ensure DefaultExecutor sends x-api-key for kimi-coding-apikey at runtime
and deduplicate shared kimi coding config blocks in registry and models
config to reduce drift between oauth and apikey variants.

---------

Co-authored-by: OmniRoute Agent <agent@omniroute.local>
2026-03-19 01:48:26 -03:00
dtk 3d0c8d8d45 feat: add alibaba cloud coding plan provider support (#465)
Co-authored-by: dtk <git@derzsi.cloud>
2026-03-19 01:48:23 -03:00
diegosouzapw 0e5e8bf14e fix(docker): add missing split2 dependency to container image (#459) 2026-03-19 01:46:26 -03:00
diegosouzapw ce34d329d3 chore(release): v2.7.9
Build Electron Desktop App / Validate version (push) Failing after 28s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-18 17:19:42 -03:00
diegosouzapw eaf4a5805c "fix: resolved UI combo setting schema strip (#458)"
"fix: safe crypto fallback for MITM on windows (#456)"
2026-03-18 17:18:31 -03:00
Sergey Morozov 8420e565d4 feat: add responses subpath passthrough for codex (#457) 2026-03-18 17:18:29 -03:00
diegosouzapw 1b68deb0f6 feat(release): v2.7.8 — budget save fix + combo agent UI + omniModel tag strip
Build Electron Desktop App / Validate version (push) Failing after 32s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
- fix(budget): warningThreshold sent as fraction 0-1 not percentage 0-100 (#451)
- feat(combos): Agent Features UI in combo modal (system_message, tool_filter_regex,
  context_cache_protection) — previously server-only (#454)
- fix(combos): strip <omniModel> tags before forwarding to provider (#454)
2026-03-18 15:38:04 -03:00
Diego Rodrigues de Sa e Souza d1497c9ac8 Merge pull request #455 from diegosouzapw/fix/issue-451-454-budget-combo-ui
fix: budget warningThreshold + combo agent UI fields + omniModel tag strip
2026-03-18 15:37:17 -03:00
diegosouzapw 03d4cbf6d5 fix: budget warningThreshold fraction mismatch + combo agent UI fields + omniModel tag strip
- fix(budget): BudgetTab sent integer percentage (80) but schema validated
  fraction (0-1). Now divides by 100 on POST and multiplies by 100 on GET (#451)

- fix(combos): expose Agent Features UI in combo create/edit modal — fields for
  system_message override, tool_filter_regex, and context_cache_protection were
  implemented server-side (#399/#401) but missing from the dashboard UI (#454)

- fix(combos): strip <omniModel> tags from messages before forwarding to provider.
  The internal cache-pinning tag was being sent to the provider, causing cache
  misses as providers treated each tagged request as a new session (#454)
2026-03-18 15:32:47 -03:00
30 changed files with 1702 additions and 63 deletions
+2
View File
@@ -55,6 +55,8 @@ logs/*
# analysis directories (generated, not tracked)
.analysis/
antigravity-manager-analysis/
.sisyphus/
.plans/
# docs (allow specific tracked files)
docs/*
+60
View File
@@ -4,6 +4,66 @@
---
## [2.8.0] — 2026-03-19
> Sprint: Bailian Coding Plan provider with editable base URLs, plus community contributions for Alibaba Cloud and Kimi Coding.
### ✨ Features
- **feat(providers)**: Added Bailian Coding Plan (`bailian-coding-plan`) — Alibaba Model Studio with Anthropic-compatible API. Static catalog of 8 models including Qwen3.5 Plus, Qwen3 Coder, MiniMax M2.5, GLM 5, and Kimi K2.5. Includes custom auth validation (400=valid, 401/403=invalid) (#467, @Mind-Dragon)
- **feat(admin)**: Editable default URL in Provider Admin create/edit flows — users can configure custom base URLs per connection. Persisted in `providerSpecificData.baseUrl` with Zod schema validation rejecting non-http(s) schemes (#467)
### 🧪 Tests
- Added 30+ unit tests and 2 e2e scenarios for Bailian Coding Plan provider covering auth validation, schema hardening, route-level behavior, and cross-layer integration
---
## [2.7.10] — 2026-03-19
> Sprint: Two new community-contributed providers (Alibaba Cloud Coding, Kimi Coding API-key) and Docker pino fix.
### ✨ Features
- **feat(providers)**: Added Alibaba Cloud Coding Plan support with two OpenAI-compatible endpoints — `alicode` (China) and `alicode-intl` (International), each with 8 models (#465, @dtk1985)
- **feat(providers)**: Added dedicated `kimi-coding-apikey` provider path — API-key-based Kimi Coding access is no longer forced through OAuth-only `kimi-coding` route. Includes registry, constants, models API, config, and validation test (#463, @Mind-Dragon)
### 🐛 Bug Fixes
- **fix(docker)**: Added missing `split2` dependency to Docker image — `pino-abstract-transport` requires it at runtime but it was not being copied into the standalone container, causing `Cannot find module 'split2'` crashes (#459)
---
## [2.7.9] — 2026-03-18
> Sprint: Codex responses subpath passthrough natively supported, Windows MITM crash fixed, and Combos agent schemas adjusted.
### ✨ Features
- **feat(codex)**: Native responses subpath passthrough for Codex — natively routes `POST /v1/responses/compact` to Codex upstream, maintaining Claude Code compatibility without stripping the `/compact` suffix (#457)
### 🐛 Bug Fixes
- **fix(combos)**: Zod schemas (`updateComboSchema` and `createComboSchema`) now include `system_message`, `tool_filter_regex`, and `context_cache_protection`. Fixes bug where agent-specific settings created via the dashboard were silently discarded by the backend validation layer (#458)
- **fix(mitm)**: Kiro MITM profile crash on Windows fixed — `node-machine-id` failed due to missing `REG.exe` env, and the fallback threw a fatal `crypto is not defined` error. Fallback now safely and correctly imports crypto (#456)
---
## [2.7.8] — 2026-03-18
> Sprint: Budget save bug + combo agent features UI + omniModel tag security fix.
### 🐛 Bug Fixes
- **fix(budget)**: "Save Limits" no longer returns 422 — `warningThreshold` is now correctly sent as fraction (01) instead of percentage (0100) (#451)
- **fix(combos)**: `<omniModel>` internal cache tag is now stripped before forwarding requests to providers, preventing cache session breaks (#454)
### ✨ Features
- **feat(combos)**: Agent Features section added to combo create/edit modal — expose `system_message` override, `tool_filter_regex`, and `context_cache_protection` directly from the dashboard (#454)
---
## [2.7.7] — 2026-03-18
> Sprint: Docker pino crash, Codex CLI responses worker fix, package-lock sync.
+1
View File
@@ -36,6 +36,7 @@ COPY --from=builder /app/node_modules/@swc/helpers ./node_modules/@swc/helpers
# pino-abstract-transport at runtime; Next.js standalone trace does not capture it (#449)
COPY --from=builder /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
COPY --from=builder /app/node_modules/pino-pretty ./node_modules/pino-pretty
COPY --from=builder /app/node_modules/split2 ./node_modules/split2
COPY --from=builder /app/scripts/run-standalone.mjs ./run-standalone.mjs
COPY --from=builder /app/scripts/runtime-env.mjs ./runtime-env.mjs
COPY --from=builder /app/scripts/bootstrap-env.mjs ./bootstrap-env.mjs
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 2.7.7
version: 2.8.0
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,
+4
View File
@@ -121,6 +121,10 @@ const nextConfig = {
source: "/responses",
destination: "/api/v1/responses",
},
{
source: "/responses/:path*",
destination: "/api/v1/responses/:path*",
},
{
source: "/models",
destination: "/api/v1/models",
+90 -13
View File
@@ -78,6 +78,22 @@ interface LegacyProvider {
clientVersion?: string;
}
const KIMI_CODING_SHARED = {
format: "claude",
executor: "default",
baseUrl: "https://api.kimi.com/coding/v1/messages",
authHeader: "x-api-key",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
models: [
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
{ id: "kimi-latest", name: "Kimi Latest" },
] as RegistryModel[],
} as const;
// ── Registry ──────────────────────────────────────────────────────────────
export const REGISTRY: Record<string, RegistryEntry> = {
@@ -521,6 +537,32 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},
"bailian-coding-plan": {
id: "bailian-coding-plan",
alias: "bcp",
format: "claude",
executor: "default",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages",
chatPath: "/messages",
urlSuffix: "?beta=true",
authType: "apikey",
authHeader: "x-api-key",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
models: [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max (2026-01-23)" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
],
},
zai: {
id: "zai",
alias: "zai",
@@ -559,16 +601,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"kimi-coding": {
id: "kimi-coding",
alias: "kmc",
format: "claude",
executor: "default",
baseUrl: "https://api.kimi.com/coding/v1/messages",
...KIMI_CODING_SHARED,
urlSuffix: "?beta=true",
authType: "oauth",
authHeader: "x-api-key",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
oauth: {
clientIdEnv: "KIMI_CODING_OAUTH_CLIENT_ID",
clientIdDefault: "17e5f671-d194-4dfb-9706-5516cb48c098",
@@ -576,11 +611,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
refreshUrl: "https://auth.kimi.com/api/oauth/token",
authUrl: "https://auth.kimi.com/api/oauth/device_authorization",
},
models: [
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
{ id: "kimi-latest", name: "Kimi Latest" },
],
},
"kimi-coding-apikey": {
id: "kimi-coding-apikey",
alias: "kmca",
...KIMI_CODING_SHARED,
authType: "apikey",
},
kilocode: {
@@ -699,6 +736,46 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},
alicode: {
id: "alicode",
alias: "alicode",
format: "openai",
executor: "default",
baseUrl: "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "glm-4.7", name: "GLM 4.7" },
],
},
"alicode-intl": {
id: "alicode-intl",
alias: "alicode-intl",
format: "openai",
executor: "default",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "glm-4.7", name: "GLM 4.7" },
],
},
deepseek: {
id: "deepseek",
alias: "ds",
+1
View File
@@ -26,6 +26,7 @@ export type ProviderCredentials = {
expiresAt?: string;
connectionId?: string; // T07: used for API key rotation index
providerSpecificData?: JsonRecord;
requestEndpointPath?: string;
};
export type ExecutorLog = {
+38 -3
View File
@@ -9,6 +9,17 @@ type EffortLevel = (typeof EFFORT_ORDER)[number];
const CODEX_FAST_WIRE_VALUE = "priority";
let defaultFastServiceTierEnabled = false;
function getResponsesSubpath(endpointPath: unknown): string | null {
const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, "");
const match = normalizedEndpoint.match(/(?:^|\/)responses(?:(\/.*))?$/i);
if (!match) return null;
return match[1] || "";
}
function isCompactResponsesEndpoint(endpointPath: unknown): boolean {
return getResponsesSubpath(endpointPath)?.toLowerCase() === "/compact";
}
function normalizeServiceTierValue(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
@@ -60,13 +71,31 @@ export class CodexExecutor extends BaseExecutor {
super("codex", PROVIDERS.codex);
}
buildUrl(model, stream, urlIndex = 0, credentials = null) {
void model;
void stream;
void urlIndex;
const responsesSubpath = getResponsesSubpath(credentials?.requestEndpointPath);
if (responsesSubpath !== null) {
const baseUrl = String(this.config.baseUrl || "").replace(/\/$/, "");
if (baseUrl.endsWith("/responses")) {
return `${baseUrl}${responsesSubpath}`;
}
return `${baseUrl}/responses${responsesSubpath}`;
}
return super.buildUrl(model, stream, urlIndex, credentials);
}
/**
* Codex Responses endpoint is SSE-first.
* Always request event-stream from upstream, even when client requested stream=false.
* Includes chatgpt-account-id header for strict workspace binding.
*/
buildHeaders(credentials, stream = true) {
const headers = super.buildHeaders(credentials, true);
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
const headers = super.buildHeaders(credentials, isCompactRequest ? false : true);
// Add workspace binding header if workspaceId is persisted
const workspaceId = credentials?.providerSpecificData?.workspaceId;
@@ -107,9 +136,15 @@ export class CodexExecutor extends BaseExecutor {
*/
transformRequest(model, body, stream, credentials) {
const nativeCodexPassthrough = body?._nativeCodexPassthrough === true;
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
// Codex /responses rejects stream=false; we aggregate SSE back to JSON when needed.
body.stream = true;
// Codex /responses rejects stream=false, but /responses/compact rejects the stream field entirely.
if (isCompactRequest) {
delete body.stream;
delete body.stream_options;
} else {
body.stream = true;
}
delete body._nativeCodexPassthrough;
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
+2
View File
@@ -54,6 +54,8 @@ export class DefaultExecutor extends BaseExecutor {
break;
case "glm":
case "kimi-coding":
case "bailian-coding-plan":
case "kimi-coding-apikey":
case "minimax":
case "minimax-cn":
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
+8 -7
View File
@@ -60,9 +60,8 @@ export function shouldUseNativeCodexPassthrough({
}): boolean {
if (provider !== "codex") return false;
if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false;
return String(endpointPath || "")
.toLowerCase()
.endsWith("/responses");
const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, "");
return /(?:^|\/)responses(?:\/.*)?$/i.test(normalizedEndpoint);
}
/**
@@ -140,8 +139,8 @@ export async function handleChatCore({
}
const sourceFormat = detectFormat(body);
const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase();
const isResponsesEndpoint = endpointPath.endsWith("/responses");
const endpointPath = String(clientRawRequest?.endpoint || "");
const isResponsesEndpoint = /(?:^|\/)responses(?:\/.*)?$/i.test(endpointPath);
const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({
provider,
sourceFormat,
@@ -385,6 +384,8 @@ export async function handleChatCore({
// Get executor for this provider
const executor = getExecutor(provider);
const getExecutionCredentials = () =>
nativeCodexPassthrough ? { ...credentials, requestEndpointPath: endpointPath } : credentials;
// Create stream controller for disconnect detection
const streamController = createStreamController({ onDisconnect, log, provider, model });
@@ -405,7 +406,7 @@ export async function handleChatCore({
model: modelToCall,
body: bodyToSend,
stream,
credentials,
credentials: getExecutionCredentials(),
signal: streamController.signal,
log,
extendedContext,
@@ -545,7 +546,7 @@ export async function handleChatCore({
model,
body: translatedBody,
stream,
credentials,
credentials: getExecutionCredentials(),
signal: streamController.signal,
log,
extendedContext,
+19
View File
@@ -123,6 +123,20 @@ export function applyToolFilter(
});
}
/**
* Strip all <omniModel> tags from message content before forwarding to the provider.
* The tag is an internal OmniRoute marker; providers must never see it or their
* cache will treat every tagged request as a new session (#454).
*/
export function stripModelTags(messages: Message[]): Message[] {
return messages.map((msg) => {
if (typeof msg.content === "string" && CACHE_TAG_PATTERN.test(msg.content)) {
return { ...msg, content: msg.content.replace(CACHE_TAG_PATTERN, "").trimEnd() };
}
return msg;
});
}
// ── Main Middleware ──────────────────────────────────────────────────────────
/**
@@ -158,6 +172,11 @@ export function applyComboAgentMiddleware(
comboConfig.tool_filter_regex
);
// 4. Strip internal <omniModel> tags before forwarding to provider (#454)
// These tags are OmniRoute-internal markers and must never reach the provider
// since providers would treat each tagged request as a new cache session.
messages = stripModelTags(messages);
return {
body: {
...body,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.7.7",
"version": "2.7.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.7.7",
"version": "2.7.9",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.7.7",
"version": "2.8.0",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -1181,6 +1181,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const [config, setConfig] = useState(combo?.config || {});
const [showStrategyNudge, setShowStrategyNudge] = useState(false);
const strategyChangeMountedRef = useRef(false);
// Agent features (#399 / #401 / #454)
const [agentSystemMessage, setAgentSystemMessage] = useState<string>(combo?.system_message || "");
const [agentToolFilter, setAgentToolFilter] = useState<string>(combo?.tool_filter_regex || "");
const [agentContextCache, setAgentContextCache] = useState<boolean>(
!!combo?.context_cache_protection
);
// DnD state
const hasPricingForModel = useCallback(
@@ -1532,6 +1538,14 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
saveData.config = configToSave;
}
// Agent features (#399 / #401 / #454)
if (agentSystemMessage.trim()) saveData.system_message = agentSystemMessage.trim();
else delete saveData.system_message;
if (agentToolFilter.trim()) saveData.tool_filter_regex = agentToolFilter.trim();
else delete saveData.tool_filter_regex;
if (agentContextCache) saveData.context_cache_protection = true;
else delete saveData.context_cache_protection;
await onSave(saveData);
setSaving(false);
};
@@ -2052,6 +2066,72 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</div>
)}
{/* Agent Features (#399 / #401 / #454) */}
<div className="flex flex-col gap-2 p-3 bg-black/[0.02] dark:bg-white/[0.02] rounded-lg border border-black/5 dark:border-white/5">
<div className="flex items-center gap-1.5 mb-1">
<span className="material-symbols-outlined text-[14px] text-primary">smart_toy</span>
<p className="text-xs font-medium">Agent Features</p>
<span className="text-[10px] text-text-muted">
optional, for agent/tool workflows
</span>
</div>
{/* System Message Override */}
<div>
<label className="text-[11px] font-medium text-text-muted block mb-0.5">
System Message Override
</label>
<textarea
rows={2}
value={agentSystemMessage}
onChange={(e) => setAgentSystemMessage(e.target.value)}
placeholder="Override the system prompt for all requests routed through this combo…"
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none resize-none"
/>
<p className="text-[10px] text-text-muted mt-0.5">
Replaces any system message sent by the client. Leave empty to pass through client
system messages.
</p>
</div>
{/* Tool Filter Regex */}
<div>
<label className="text-[11px] font-medium text-text-muted block mb-0.5">
Tool Filter Regex
</label>
<input
type="text"
value={agentToolFilter}
onChange={(e) => setAgentToolFilter(e.target.value)}
placeholder="e.g. ^(bash|computer)$"
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none font-mono"
/>
<p className="text-[10px] text-text-muted mt-0.5">
Only tools whose name matches this regex are forwarded to the provider. Leave empty
to forward all tools.
</p>
</div>
{/* Context Cache Protection */}
<div className="flex items-center justify-between gap-2">
<div>
<label className="text-[11px] font-medium text-text-muted block">
Context Cache Protection
</label>
<p className="text-[10px] text-text-muted">
Pins the provider/model across turns to preserve cache sessions. Internal tags are
stripped before forwarding to the provider.
</p>
</div>
<input
type="checkbox"
checked={agentContextCache}
onChange={(e) => setAgentContextCache(e.target.checked)}
className="accent-primary shrink-0"
/>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-1">
<Button onClick={onClose} variant="ghost" fullWidth size="sm">
@@ -286,9 +286,13 @@ export default function ProviderDetailPage() {
if (res.ok) {
await fetchConnections();
setShowEditModal(false);
return null;
}
const data = await res.json().catch(() => ({}));
return data.error?.message || data.error || t("failedSaveConnection");
} catch (error) {
console.log("Error updating connection:", error);
return t("failedSaveConnectionRetry");
}
};
@@ -2618,10 +2622,14 @@ function AddApiKeyModal({
onClose,
}) {
const t = useTranslations("providers");
const isBailian = provider === "bailian-coding-plan";
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
const [formData, setFormData] = useState({
name: "",
apiKey: "",
priority: 1,
baseUrl: isBailian ? defaultBailianUrl : "",
});
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
@@ -2652,6 +2660,16 @@ function AddApiKeyModal({
setSaving(true);
setSaveError(null);
try {
let validatedBailianBaseUrl = null;
if (isBailian) {
const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBailianUrl);
if (checked.error) {
setSaveError(checked.error);
return;
}
validatedBailianBaseUrl = checked.value;
}
let isValid = false;
try {
setValidating(true);
@@ -2675,12 +2693,22 @@ function AddApiKeyModal({
return;
}
const error = await onSave({
const payload = {
name: formData.name,
apiKey: formData.apiKey,
priority: formData.priority,
testStatus: "active",
});
providerSpecificData: undefined,
};
// Include baseUrl in providerSpecificData for bailian-coding-plan
if (isBailian) {
payload.providerSpecificData = {
baseUrl: validatedBailianBaseUrl,
};
}
const error = await onSave(payload);
if (error) {
setSaveError(typeof error === "string" ? error : t("failedSaveConnection"));
}
@@ -2751,6 +2779,15 @@ function AddApiKeyModal({
setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })
}
/>
{isBailian && (
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={defaultBailianUrl}
hint="Optional: Custom base URL for bailian-coding-plan provider"
/>
)}
<div className="flex gap-2">
<Button
onClick={handleSubmit}
@@ -2778,6 +2815,19 @@ AddApiKeyModal.propTypes = {
onClose: PropTypes.func.isRequired,
};
function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) {
const value = (typeof rawValue === "string" ? rawValue.trim() : "") || fallbackUrl;
try {
const parsed = new URL(value);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return { value: null, error: "Base URL must use http or https" };
}
return { value, error: null };
} catch {
return { value: null, error: "Base URL must be a valid URL" };
}
}
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
const t = useTranslations("providers");
const [formData, setFormData] = useState({
@@ -2785,22 +2835,29 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
priority: 1,
apiKey: "",
healthCheckInterval: 60,
baseUrl: "",
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [extraApiKeys, setExtraApiKeys] = useState<string[]>([]);
const [newExtraKey, setNewExtraKey] = useState("");
const isBailian = connection?.provider === "bailian-coding-plan";
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
useEffect(() => {
if (connection) {
const existingBaseUrl = connection.providerSpecificData?.baseUrl;
setFormData({
name: connection.name || "",
priority: connection.priority || 1,
apiKey: "",
healthCheckInterval: connection.healthCheckInterval ?? 60,
baseUrl: existingBaseUrl || (isBailian ? defaultBailianUrl : ""),
});
// Load existing extra keys from providerSpecificData
const existing = connection.providerSpecificData?.extraApiKeys;
@@ -2808,8 +2865,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
setNewExtraKey("");
setTestResult(null);
setValidationResult(null);
setSaveError(null);
}
}, [connection]);
}, [connection, isBailian]);
const handleTest = async () => {
if (!connection?.provider) return;
@@ -2855,12 +2913,24 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
const handleSubmit = async () => {
setSaving(true);
setSaveError(null);
try {
const updates: any = {
name: formData.name,
priority: formData.priority,
healthCheckInterval: formData.healthCheckInterval,
};
let validatedBailianBaseUrl = null;
if (isBailian) {
const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBailianUrl);
if (checked.error) {
setSaveError(checked.error);
return;
}
validatedBailianBaseUrl = checked.value;
}
if (!isOAuth && formData.apiKey) {
updates.apiKey = formData.apiKey;
let isValid = validationResult === "success";
@@ -2892,14 +2962,21 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
updates.rateLimitedUntil = null;
}
}
// Persist extra API keys in providerSpecificData
// Persist extra API keys and baseUrl in providerSpecificData
if (!isOAuth) {
updates.providerSpecificData = {
...(connection.providerSpecificData || {}),
extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
};
// Update baseUrl for bailian-coding-plan
if (isBailian) {
updates.providerSpecificData.baseUrl = validatedBailianBaseUrl;
}
}
const error = await onSave(updates);
if (error) {
setSaveError(typeof error === "string" ? error : t("failedSaveConnection"));
}
await onSave(updates);
} finally {
setSaving(false);
}
@@ -2980,9 +3057,24 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
{validationResult === "success" ? t("valid") : t("invalid")}
</Badge>
)}
{saveError && (
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{saveError}
</div>
)}
</>
)}
{isBailian && (
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={defaultBailianUrl}
hint="Custom base URL for bailian-coding-plan provider"
/>
)}
{/* T07: Extra API Keys for round-robin rotation */}
{!isOAuth && (
<div className="flex flex-col gap-2">
@@ -83,7 +83,11 @@ export default function BudgetTab() {
if (data.monthlyLimitUsd)
setForm((f) => ({ ...f, monthlyLimitUsd: String(data.monthlyLimitUsd) }));
if (data.warningThreshold)
setForm((f) => ({ ...f, warningThreshold: String(data.warningThreshold) }));
// stored as fraction (01), display as percentage (0100)
setForm((f) => ({
...f,
warningThreshold: String(Math.round(data.warningThreshold * 100)),
}));
}
} catch {
// silent
@@ -104,7 +108,8 @@ export default function BudgetTab() {
apiKeyId: selectedKey,
dailyLimitUsd: form.dailyLimitUsd ? parseFloat(form.dailyLimitUsd) : null,
monthlyLimitUsd: form.monthlyLimitUsd ? parseFloat(form.monthlyLimitUsd) : null,
warningThreshold: parseInt(form.warningThreshold) || 80,
// schema expects a fraction (01); UI shows percentage (0100)
warningThreshold: (parseInt(form.warningThreshold) || 80) / 100,
}),
});
if (res.ok) {
+36 -6
View File
@@ -28,8 +28,16 @@ type ProviderModelsConfigEntry = {
parseResponse: (data: any) => any;
};
const KIMI_CODING_MODELS_CONFIG: ProviderModelsConfigEntry = {
url: "https://api.kimi.com/coding/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "x-api-key",
parseResponse: (data) => data.data || data.models || [],
};
// Providers that return hardcoded models (no remote /models API)
const STATIC_MODEL_PROVIDERS = {
const STATIC_MODEL_PROVIDERS: Record<string, () => Array<{ id: string; name: string }>> = {
deepgram: () => [
{ id: "nova-3", name: "Nova 3 (Transcription)" },
{ id: "nova-2", name: "Nova 2 (Transcription)" },
@@ -53,8 +61,31 @@ const STATIC_MODEL_PROVIDERS = {
{ id: "sonar-reasoning-pro", name: "Sonar Reasoning Pro (Advanced CoT + Search)" },
{ id: "sonar-deep-research", name: "Sonar Deep Research (Expert Analysis)" },
],
"bailian-coding-plan": () => [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max (2026-01-23)" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
],
};
/**
* Get static models for a provider (if available).
* Exported for testing purposes.
* @param provider - Provider ID
* @returns Array of models or undefined if provider doesn't use static models
*/
export function getStaticModelsForProvider(
provider: string
): Array<{ id: string; name: string }> | undefined {
const staticModelsFn = STATIC_MODEL_PROVIDERS[provider];
return staticModelsFn ? staticModelsFn() : undefined;
}
// Provider models endpoints configuration
const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
claude: {
@@ -134,11 +165,10 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
parseResponse: (data) => data.data || [],
},
"kimi-coding": {
url: "https://api.kimi.com/coding/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "x-api-key",
parseResponse: (data) => data.data || data.models || [],
...KIMI_CODING_MODELS_CONFIG,
},
"kimi-coding-apikey": {
...KIMI_CODING_MODELS_CONFIG,
},
anthropic: {
url: "https://api.anthropic.com/v1/models",
+13 -3
View File
@@ -46,8 +46,16 @@ export async function POST(request: Request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } =
validation.data;
const {
provider,
apiKey,
name,
priority,
globalPriority,
defaultModel,
testStatus,
providerSpecificData: incomingPsd,
} = validation.data;
// Business validation
const isValidProvider =
@@ -59,7 +67,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
}
let providerSpecificData: Record<string, any> | null = null;
let providerSpecificData = incomingPsd || null;
const allowMultipleCompatibleConnections =
process.env.ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE === "true";
@@ -78,6 +86,7 @@ export async function POST(request: Request) {
}
providerSpecificData = {
...(providerSpecificData || {}),
prefix: node.prefix,
apiType: node.apiType,
baseUrl: node.baseUrl,
@@ -100,6 +109,7 @@ export async function POST(request: Request) {
}
providerSpecificData = {
...(providerSpecificData || {}),
prefix: node.prefix,
baseUrl: node.baseUrl,
nodeName: node.name,
@@ -0,0 +1,33 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
let initialized = false;
async function ensureInitialized() {
if (!initialized) {
await initTranslators();
initialized = true;
console.log("[SSE] Translators initialized for /v1/responses/*");
}
}
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
/**
* POST /v1/responses/:path* - OpenAI Responses subpaths
* Reuses the shared chat handler so native Codex passthrough can keep
* arbitrary Responses suffixes all the way to the upstream provider.
*/
export async function POST(request) {
await ensureInitialized();
return await handleChat(request);
}
+47
View File
@@ -300,6 +300,52 @@ async function validateInworldProvider({ apiKey }: any) {
}
}
async function validateBailianCodingPlanProvider({ apiKey, providerSpecificData = {} }: any) {
try {
const rawBaseUrl =
normalizeBaseUrl(providerSpecificData.baseUrl) ||
"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
const baseUrl = rawBaseUrl.endsWith("/messages")
? rawBaseUrl.slice(0, -"/messages".length)
: rawBaseUrl;
// bailian-coding-plan uses DashScope Anthropic-compatible messages endpoint
// It does NOT expose /v1/models — use messages probe directly
const messagesUrl = `${baseUrl}/messages`;
const response = await fetch(messagesUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "qwen3-coder-plus",
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
// 401/403 => invalid key
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
}
// Non-auth 4xx (e.g., 400 bad request) means auth passed but request was malformed
if (response.status >= 400 && response.status < 500) {
return { valid: true, error: null };
}
if (response.ok) {
return { valid: true, error: null };
}
return { valid: false, error: `Validation failed: ${response.status}` };
} catch (error: any) {
return { valid: false, error: error.message || "Validation failed" };
}
}
async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = {} }: any) {
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
if (!baseUrl) {
@@ -537,6 +583,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
nanobanana: validateNanoBananaProvider,
elevenlabs: validateElevenLabsProvider,
inworld: validateInworldProvider,
"bailian-coding-plan": validateBailianCodingPlanProvider,
// Search providers — use factored validator
...Object.fromEntries(
Object.entries(SEARCH_VALIDATOR_CONFIGS).map(([id, configFn]) => [
+2
View File
@@ -33,8 +33,10 @@ export const API_ENDPOINTS = {
export const PROVIDER_ENDPOINTS = {
openrouter: "https://openrouter.ai/api/v1/chat/completions",
glm: "https://api.z.ai/api/anthropic/v1/messages",
"bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages",
kimi: "https://api.moonshot.ai/v1/chat/completions",
"kimi-coding": "https://api.kimi.com/coding/v1/messages",
"kimi-coding-apikey": "https://api.kimi.com/coding/v1/messages",
minimax: "https://api.minimax.io/anthropic/v1/messages",
"minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages",
openai: "https://api.openai.com/v1/chat/completions",
+37
View File
@@ -53,6 +53,7 @@ export const OAUTH_PROVIDERS = {
},
};
// API Key Providers
export const APIKEY_PROVIDERS = {
openrouter: {
id: "openrouter",
@@ -73,6 +74,15 @@ export const APIKEY_PROVIDERS = {
textIcon: "GL",
website: "https://open.bigmodel.cn",
},
"bailian-coding-plan": {
id: "bailian-coding-plan",
alias: "bcp",
name: "Alibaba Coding Plan",
icon: "code",
color: "#FF6A00",
textIcon: "BCP",
website: "https://www.alibabacloud.com/help/en/model-studio/coding-plan",
},
kimi: {
id: "kimi",
alias: "kimi",
@@ -82,6 +92,15 @@ export const APIKEY_PROVIDERS = {
textIcon: "KM",
website: "https://kimi.moonshot.cn",
},
"kimi-coding-apikey": {
id: "kimi-coding-apikey",
alias: "kmca",
name: "Kimi Coding (API Key)",
icon: "psychology",
color: "#1E40AF",
textIcon: "KC",
website: "https://kimi.com",
},
minimax: {
id: "minimax",
alias: "minimax",
@@ -100,6 +119,24 @@ export const APIKEY_PROVIDERS = {
textIcon: "MC",
website: "https://www.minimaxi.com",
},
alicode: {
id: "alicode",
alias: "alicode",
name: "Alibaba",
icon: "cloud",
color: "#FF6A00",
textIcon: "ALi",
website: "https://bailian.console.aliyun.com",
},
"alicode-intl": {
id: "alicode-intl",
alias: "alicode-intl",
name: "Alibaba Intl",
icon: "cloud",
color: "#FF6A00",
textIcon: "ALi",
website: "https://modelstudio.console.alibabacloud.com",
},
openai: {
id: "openai",
alias: "openai",
+20 -14
View File
@@ -23,13 +23,16 @@ export async function getConsistentMachineId(salt = null) {
} catch (error) {
console.log("Error getting machine ID:", error);
// Fallback to random ID if node-machine-id fails
return crypto.randomUUID
? crypto.randomUUID()
: "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
try {
const cryptoFallback = await import("crypto");
return cryptoFallback.randomUUID();
} catch {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}
}
@@ -44,13 +47,16 @@ export async function getRawMachineId() {
} catch (error) {
console.log("Error getting raw machine ID:", error);
// Fallback to random ID if node-machine-id fails
return crypto.randomUUID
? crypto.randomUUID()
: "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
try {
const cryptoFallback = await import("crypto");
return cryptoFallback.randomUUID();
} catch {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}
}
+49 -2
View File
@@ -1,5 +1,14 @@
import { z } from "zod";
function isHttpUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
// Re-export validation helpers from dedicated module to avoid webpack barrel-file
// optimization bug that truncates exports from large files.
export { validateBody, isValidationFailure } from "./helpers";
@@ -15,6 +24,21 @@ export const createProviderSchema = z.object({
globalPriority: z.number().int().min(1).max(100).nullable().optional(),
defaultModel: z.string().max(200).nullable().optional(),
testStatus: z.string().max(50).optional(),
providerSpecificData: z
.record(z.string(), z.unknown())
.optional()
.superRefine((data, ctx) => {
if (!data) return;
const baseUrl = data.baseUrl;
if (baseUrl === undefined) return;
if (typeof baseUrl !== "string" || !isHttpUrl(baseUrl)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.baseUrl must be a valid http(s) URL",
path: ["baseUrl"],
});
}
}),
});
// ──── API Key Schemas ────
@@ -80,6 +104,9 @@ export const createComboSchema = z.object({
strategy: comboStrategySchema.optional().default("priority"),
config: comboConfigSchema,
allowedProviders: z.array(z.string().max(200)).optional(),
system_message: z.string().max(50000).optional(),
tool_filter_regex: z.string().max(1000).optional(),
context_cache_protection: z.boolean().optional(),
});
// ──── Auto-Combo Schemas ────
@@ -813,6 +840,9 @@ export const updateComboSchema = z
config: comboRuntimeConfigSchema.optional(),
isActive: z.boolean().optional(),
allowedProviders: z.array(z.string().max(200)).optional(),
system_message: z.string().max(50000).optional(),
tool_filter_regex: z.string().max(1000).optional(),
context_cache_protection: z.boolean().optional(),
})
.superRefine((value, ctx) => {
if (
@@ -821,7 +851,10 @@ export const updateComboSchema = z
value.strategy === undefined &&
value.config === undefined &&
value.isActive === undefined &&
value.allowedProviders === undefined
value.allowedProviders === undefined &&
value.system_message === undefined &&
value.tool_filter_regex === undefined &&
value.context_cache_protection === undefined
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -936,7 +969,21 @@ export const updateProviderConnectionSchema = z
healthCheckInterval: z.coerce.number().int().min(0).optional(),
group: z.union([z.string().max(100), z.null()]).optional(),
// Partial patch of per-connection provider-specific settings (e.g. quota toggles)
providerSpecificData: z.record(z.string(), z.unknown()).optional(),
providerSpecificData: z
.record(z.string(), z.unknown())
.optional()
.superRefine((data, ctx) => {
if (!data) return;
const baseUrl = data.baseUrl;
if (baseUrl === undefined) return;
if (typeof baseUrl !== "string" || !isHttpUrl(baseUrl)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.baseUrl must be a valid http(s) URL",
path: ["baseUrl"],
});
}
}),
})
.superRefine((value, ctx) => {
if (Object.keys(value).length === 0) {
@@ -0,0 +1,239 @@
import { expect, test } from "@playwright/test";
const DEFAULT_BAILIAN_URL = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
test.describe("Bailian Coding Plan Provider", () => {
test.describe.configure({ mode: "serial" });
test("default URL visible and editable in Add API Key modal", async ({ page }) => {
const capturedPayloads: { createProvider?: Record<string, unknown> } = {};
await page.route("**/api/providers", async (route) => {
const method = route.request().method();
if (method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ connections: [] }),
});
return;
}
if (method === "POST") {
const payload = route.request().postDataJSON();
capturedPayloads.createProvider = payload;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
connection: {
id: "conn-bailian-test",
provider: "bailian-coding-plan",
name: payload.name || "Test Connection",
testStatus: "active",
providerSpecificData: payload.providerSpecificData,
},
}),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/api/providers/validate", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ valid: true }),
});
});
await page.route("**/api/provider-nodes", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ nodes: [] }),
});
});
await page.goto("/dashboard/providers/bailian-coding-plan");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
const addKeyButton = page.getByRole("button", {
name: /add.*api.*key|add.*key|add.*connection|connect/i,
});
if (
await addKeyButton
.first()
.isVisible({ timeout: 5000 })
.catch(() => false)
) {
await addKeyButton.first().click();
}
const dialog = page.getByRole("dialog").first();
await expect(dialog).toBeVisible({ timeout: 10000 });
const baseUrlInput = dialog
.getByLabel(/base.*url/i)
.or(dialog.locator("input").filter({ has: page.locator("..").getByText(/base.*url/i) }));
await expect(baseUrlInput).toBeVisible({ timeout: 5000 });
const inputValue = await baseUrlInput.inputValue();
expect(inputValue).toBe(DEFAULT_BAILIAN_URL);
const nameInput = dialog.getByLabel(/name/i).or(dialog.locator("input").first());
await nameInput.fill("Test Bailian Connection");
const apiKeyInput = dialog
.getByLabel(/api.*key/i)
.or(dialog.locator('input[type="password"]').first());
await apiKeyInput.fill("test-api-key-12345");
const customUrl = "https://custom.example.com/anthropic/v1";
await baseUrlInput.fill(customUrl);
const saveButton = dialog
.getByRole("button", {
name: /save|add|create|connect/i,
})
.last();
await expect(saveButton).toBeEnabled({ timeout: 5000 });
await saveButton.click();
await expect(dialog)
.toBeHidden({ timeout: 10000 })
.catch(() => undefined);
expect(capturedPayloads.createProvider).toBeDefined();
const payload = capturedPayloads.createProvider;
expect(payload?.providerSpecificData).toBeDefined();
expect((payload?.providerSpecificData as Record<string, unknown>)?.baseUrl).toBe(customUrl);
});
test("invalid URL blocks save with validation error", async ({ page }) => {
let validationErrorCaptured = false;
let createAttempted = false;
await page.route("**/api/providers", async (route) => {
const method = route.request().method();
if (method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ connections: [] }),
});
return;
}
if (method === "POST") {
createAttempted = true;
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({
message: "Invalid request",
details: [
{
field: "providerSpecificData.baseUrl",
message: "providerSpecificData.baseUrl must be a valid URL",
},
],
}),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/api/providers/validate", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ valid: true }),
});
});
await page.route("**/api/provider-nodes", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ nodes: [] }),
});
});
await page.goto("/dashboard/providers/bailian-coding-plan");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
const addKeyButton = page.getByRole("button", {
name: /add.*api.*key|add.*key|add.*connection|connect/i,
});
if (
await addKeyButton
.first()
.isVisible({ timeout: 5000 })
.catch(() => false)
) {
await addKeyButton.first().click();
}
const dialog = page.getByRole("dialog").first();
await expect(dialog).toBeVisible({ timeout: 10000 });
const baseUrlInput = dialog
.getByLabel(/base.*url/i)
.or(dialog.locator("input").filter({ has: page.locator("..").getByText(/base.*url/i) }));
await expect(baseUrlInput).toBeVisible({ timeout: 5000 });
const nameInput = dialog.getByLabel(/name/i).or(dialog.locator("input").first());
await nameInput.fill("Test Invalid URL Connection");
const apiKeyInput = dialog
.getByLabel(/api.*key/i)
.or(dialog.locator('input[type="password"]').first());
await apiKeyInput.fill("test-api-key-12345");
await baseUrlInput.fill("not-a-url");
const saveButton = dialog
.getByRole("button", {
name: /save|add|create|connect/i,
})
.last();
await saveButton.click();
const errorLocator = page
.locator("text=/invalid.*url|url.*invalid|must be a valid url/i")
.or(
page
.locator(".text-red-500")
.or(page.locator('[class*="error"]').or(page.locator('[class*="text-destructive"]')))
);
await page.waitForTimeout(1000);
const errorVisible = await errorLocator.isVisible({ timeout: 5000 }).catch(() => false);
if (!errorVisible) {
await page.waitForTimeout(2000);
const modalStillOpen = await dialog.isVisible();
if (modalStillOpen) {
validationErrorCaptured = true;
}
}
expect(errorVisible).toBe(true);
expect(createAttempted).toBe(false);
});
});
@@ -0,0 +1,631 @@
import test from "node:test";
import assert from "node:assert/strict";
// Import the constants directly
const { APIKEY_PROVIDERS, OAUTH_PROVIDERS } =
await import("../../src/shared/constants/providers.ts");
// Import validateProviderApiKey for Scenario C tests
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
test("APIKEY_PROVIDERS includes bailian-coding-plan", () => {
assert.ok(
APIKEY_PROVIDERS["bailian-coding-plan"],
"bailian-coding-plan should be present in APIKEY_PROVIDERS"
);
const provider = APIKEY_PROVIDERS["bailian-coding-plan"];
assert.equal(provider.id, "bailian-coding-plan", "Provider id should be 'bailian-coding-plan'");
assert.equal(provider.alias, "bcp", "Provider alias should be 'bcp'");
assert.ok(provider.name, "Provider should have a name");
});
test("bailian-coding-plan not in OAUTH_PROVIDERS", () => {
assert.equal(
OAUTH_PROVIDERS["bailian-coding-plan"],
undefined,
"bailian-coding-plan should NOT be present in OAUTH_PROVIDERS"
);
});
// Schema validation tests for providerSpecificData.baseUrl
const { validateBody, createProviderSchema, updateProviderConnectionSchema } =
await import("../../src/shared/validation/schemas.ts");
const VALID_BAILIAN_URL = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
test("createProviderSchema accepts valid baseUrl in providerSpecificData", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-test-key",
name: "Test Bailian",
providerSpecificData: {
baseUrl: VALID_BAILIAN_URL,
},
});
assert.equal(validation.success, true, "Should accept valid URL");
if (validation.success) {
assert.equal(
validation.data.providerSpecificData?.baseUrl,
VALID_BAILIAN_URL,
"Should preserve valid baseUrl"
);
}
});
test("createProviderSchema accepts missing providerSpecificData", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-test-key",
name: "Test Bailian",
});
assert.equal(validation.success, true, "Should accept without providerSpecificData");
});
test("createProviderSchema accepts empty providerSpecificData", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-test-key",
name: "Test Bailian",
providerSpecificData: {},
});
assert.equal(validation.success, true, "Should accept empty providerSpecificData");
});
test("createProviderSchema rejects invalid baseUrl in providerSpecificData", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-test-key",
name: "Test Bailian",
providerSpecificData: {
baseUrl: "not-a-valid-url",
},
});
assert.equal(validation.success, false, "Should reject invalid URL");
if (!validation.success && typeof validation.error === "object" && validation.error !== null) {
const errorObj = validation.error;
const details = Array.isArray(errorObj.details) ? errorObj.details : [];
const errorStr = details.map((d) => d.message || "").join(", ");
assert.ok(
errorStr.includes("baseUrl") && errorStr.includes("URL"),
`Error should mention baseUrl and URL. Got: ${errorStr}`
);
}
});
test("createProviderSchema rejects malformed baseUrl (no protocol)", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-test-key",
name: "Test Bailian",
providerSpecificData: {
baseUrl: "example.com/path",
},
});
assert.equal(validation.success, false, "Should reject URL without protocol");
});
test("createProviderSchema rejects baseUrl with non-string value", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-test-key",
name: "Test Bailian",
providerSpecificData: {
baseUrl: 12345,
},
});
assert.equal(validation.success, false, "Should reject non-string baseUrl");
});
test("updateProviderConnectionSchema accepts valid baseUrl in providerSpecificData", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: VALID_BAILIAN_URL,
},
});
assert.equal(validation.success, true, "Should accept valid URL");
if (validation.success) {
assert.equal(
validation.data.providerSpecificData?.baseUrl,
VALID_BAILIAN_URL,
"Should preserve valid baseUrl"
);
}
});
test("updateProviderConnectionSchema rejects invalid baseUrl in providerSpecificData", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: "invalid-url-abc",
},
});
assert.equal(validation.success, false, "Should reject invalid URL");
if (!validation.success && typeof validation.error === "object" && validation.error !== null) {
const errorObj = validation.error;
const details = Array.isArray(errorObj.details) ? errorObj.details : [];
const errorStr = details.map((d) => d.message || "").join(", ");
assert.ok(
errorStr.includes("baseUrl") && errorStr.includes("URL"),
`Error should mention baseUrl and URL. Got: ${errorStr}`
);
}
});
test("updateProviderConnectionSchema accepts partial update without baseUrl", () => {
const validation = validateBody(updateProviderConnectionSchema, {
name: "Updated Name",
priority: 5,
});
assert.equal(validation.success, true, "Should accept update without baseUrl");
});
test("updateProviderConnectionSchema rejects baseUrl with trailing garbage", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: "https://example.com not-a-url",
},
});
assert.equal(validation.success, false, "Should reject URL with trailing garbage");
});
test("updateProviderConnectionSchema accepts https protocol", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: "https://secure.example.com/v1",
},
});
assert.equal(validation.success, true, "Should accept https URL");
});
test("updateProviderConnectionSchema accepts http protocol", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: "http://localhost:3000/v1",
},
});
assert.equal(validation.success, true, "Should accept http URL");
});
// ============================================================================
// ROUTE-LEVEL TESTS: Static model listing behavior for bailian-coding-plan
// ============================================================================
// Import the exported helper function from the route
const { getStaticModelsForProvider } =
await import("../../src/app/api/providers/[id]/models/route.ts");
test("getStaticModelsForProvider returns 8 models for bailian-coding-plan", () => {
const models = getStaticModelsForProvider("bailian-coding-plan");
assert.ok(models, "Should return models for bailian-coding-plan");
assert.ok(Array.isArray(models), "Should return an array");
assert.equal(models.length, 8, "Should return exactly 8 models");
});
test("getStaticModelsForProvider returns correct model IDs for bailian-coding-plan", () => {
const models = getStaticModelsForProvider("bailian-coding-plan");
if (!models) {
assert.fail("Models should not be undefined");
return;
}
const expectedIds = [
"qwen3.5-plus",
"qwen3-max-2026-01-23",
"qwen3-coder-next",
"qwen3-coder-plus",
"MiniMax-M2.5",
"glm-5",
"glm-4.7",
"kimi-k2.5",
];
const actualIds = models.map((m) => m.id);
for (const expectedId of expectedIds) {
assert.ok(actualIds.includes(expectedId), `Should include model: ${expectedId}`);
}
// Verify no extra models
assert.equal(actualIds.length, expectedIds.length, "Should have exactly the expected models");
});
test("getStaticModelsForProvider returns models with correct structure", () => {
const models = getStaticModelsForProvider("bailian-coding-plan");
if (!models) {
assert.fail("Models should not be undefined");
return;
}
for (const model of models) {
assert.ok(model.id, `Model should have id: ${JSON.stringify(model)}`);
assert.ok(model.name, `Model should have name: ${JSON.stringify(model)}`);
assert.equal(typeof model.id, "string", "Model id should be string");
assert.equal(typeof model.name, "string", "Model name should be string");
}
});
test("getStaticModelsForProvider returns undefined for non-static providers", () => {
// Test with providers that are NOT in STATIC_MODEL_PROVIDERS
const nonStaticProviders = ["openai", "anthropic", "deepseek", "groq", "unknown-provider"];
for (const provider of nonStaticProviders) {
const models = getStaticModelsForProvider(provider);
assert.equal(models, undefined, `Should return undefined for non-static provider: ${provider}`);
}
});
test("getStaticModelsForProvider returns models for other static providers", () => {
// Verify other static providers still work
const staticProviders = ["deepgram", "assemblyai", "nanobanana", "perplexity"];
for (const provider of staticProviders) {
const models = getStaticModelsForProvider(provider);
assert.ok(models, `Should return models for static provider: ${provider}`);
assert.ok(models.length > 0, `Should return non-empty models for: ${provider}`);
}
});
test("getStaticModelsForProvider returns models matching registry for bailian-coding-plan", async () => {
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const models = getStaticModelsForProvider("bailian-coding-plan");
const registryEntry = REGISTRY["bailian-coding-plan"];
assert.ok(models, "Static models should be defined");
assert.ok(registryEntry, "Registry entry should exist");
const registryModels = registryEntry.models;
// Verify counts match
assert.equal(
models.length,
registryModels.length,
`Static model count (${models.length}) should match registry (${registryModels.length})`
);
// Verify all model IDs match
const staticIds = new Set(models.map((m) => m.id));
const registryIds = new Set(registryModels.map((m) => m.id));
assert.equal(staticIds.size, registryIds.size, "Should have same number of unique model IDs");
// Verify each model ID exists in both
for (const model of models) {
assert.ok(registryIds.has(model.id), `Registry should have model: ${model.id}`);
}
});
test("bailian-coding-plan static models have no duplicates", () => {
const models = getStaticModelsForProvider("bailian-coding-plan");
if (!models) {
assert.fail("Models should not be undefined");
return;
}
const ids = models.map((m) => m.id);
const uniqueIds = new Set(ids);
assert.equal(ids.length, uniqueIds.size, "All model IDs should be unique (no duplicates)");
});
test("bailian-coding-plan static models are complete and valid", () => {
const models = getStaticModelsForProvider("bailian-coding-plan");
if (!models) {
assert.fail("Models should not be undefined");
return;
}
// Verify array is not empty
assert.ok(models.length > 0, "Models array should not be empty");
// Verify no null/undefined entries
for (let i = 0; i < models.length; i++) {
assert.ok(models[i], `Model at index ${i} should not be null/undefined`);
}
// Verify no empty model IDs or names
for (const model of models) {
assert.ok(
model.id && model.id.trim().length > 0,
`Model ID should be non-empty: ${JSON.stringify(model)}`
);
assert.ok(
model.name && model.name.trim().length > 0,
`Model name should be non-empty: ${JSON.stringify(model)}`
);
}
});
// ============================================================================
// SCENARIO C TESTS: validateProviderApiKey for bailian-coding-plan
// These test the key validation outcomes with mocked fetch
// ============================================================================
test("validateProviderApiKey returns invalid for 401 response (bailian-coding-plan)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "invalid-key",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(result.valid, false, "Should return invalid for 401");
assert.equal(result.error, "Invalid API key", "Error should be 'Invalid API key'");
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateProviderApiKey returns invalid for 403 response (bailian-coding-plan)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "forbidden-key",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(result.valid, false, "Should return invalid for 403");
assert.equal(result.error, "Invalid API key", "Error should be 'Invalid API key'");
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateProviderApiKey returns valid for 400 response (bailian-coding-plan)", async () => {
const originalFetch = globalThis.fetch;
// 400 means auth passed but request was malformed
// This is a valid auth path for bailian-coding-plan
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "invalid request" }), {
status: 400,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "valid-key",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(
result.valid,
true,
"Should return valid for 400 (auth passed, request malformed)"
);
assert.equal(result.error, null, "Error should be null for valid auth");
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateProviderApiKey returns valid for 200 response (bailian-coding-plan)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ model: "qwen3-coder-plus" }), {
status: 200,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "valid-key",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(result.valid, true, "Should return valid for 200");
assert.equal(result.error, null, "Error should be null for valid auth");
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateProviderApiKey returns invalid for 500 response (bailian-coding-plan)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "upstream unavailable" }), {
status: 500,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "bad-key",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(result.valid, false, "Should return invalid for 500");
assert.equal(result.error, "Validation failed: 500");
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateProviderApiKey avoids double /messages suffix for bailian-coding-plan", async () => {
const originalFetch = globalThis.fetch;
const urls = [];
globalThis.fetch = async (url) => {
urls.push(String(url));
return new Response(JSON.stringify({ error: "invalid request" }), {
status: 400,
headers: { "content-type": "application/json" },
});
};
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "valid-key",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages",
},
});
assert.equal(result.valid, true);
assert.equal(urls.length, 1);
assert.equal(
urls[0],
"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages",
"Should probe exactly one /messages suffix"
);
} finally {
globalThis.fetch = originalFetch;
}
});
// ============================================================================
// SCENARIO A TESTS: POST /api/providers create flow validation
// These test that the schema (used by POST route) accepts valid bailian data
// ============================================================================
test("POST /api/providers validation: bailian-coding-plan with baseUrl passes schema", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-placeholder-key",
name: "Test Bailian Provider",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(validation.success, true, "Schema should accept valid bailian-coding-plan payload");
if (validation.success) {
assert.equal(validation.data.provider, "bailian-coding-plan");
assert.equal(
validation.data.providerSpecificData?.baseUrl,
"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1"
);
}
});
test("POST /api/providers validation: bailian-coding-plan with custom baseUrl passes schema", () => {
const customUrl = "https://custom.dashscope.aliyuncs.com/apps/anthropic/v1";
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-another-placeholder",
name: "Custom Bailian",
providerSpecificData: {
baseUrl: customUrl,
},
});
assert.equal(validation.success, true, "Schema should accept custom baseUrl");
if (validation.success) {
assert.equal(validation.data.providerSpecificData?.baseUrl, customUrl);
}
});
test("POST /api/providers validation rejects non-http(s) baseUrl", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-placeholder-key",
name: "Bad URL Scheme",
providerSpecificData: {
baseUrl: "ftp://example.com/v1",
},
});
assert.equal(validation.success, false, "Schema should reject non-http(s) URL schemes");
});
// ============================================================================
// SCENARIO B TESTS: PUT /api/providers/{id} update flow validation
// These test that the schema (used by PUT route) accepts valid baseUrl updates
// ============================================================================
test("PUT /api/providers/{id} validation: updating baseUrl passes schema", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: "https://updated.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(validation.success, true, "Schema should accept baseUrl update");
if (validation.success) {
assert.equal(
validation.data.providerSpecificData?.baseUrl,
"https://updated.dashscope.aliyuncs.com/apps/anthropic/v1"
);
}
});
test("PUT /api/providers/{id} validation: baseUrl update with other fields passes schema", () => {
const validation = validateBody(updateProviderConnectionSchema, {
name: "Updated Bailian Name",
priority: 5,
providerSpecificData: {
baseUrl: "https://new-url.example.com/v1",
},
});
assert.equal(
validation.success,
true,
"Schema should accept update with baseUrl and other fields"
);
if (validation.success) {
assert.equal(validation.data.name, "Updated Bailian Name");
assert.equal(validation.data.priority, 5);
assert.equal(validation.data.providerSpecificData?.baseUrl, "https://new-url.example.com/v1");
}
});
test("PUT /api/providers/{id} validation rejects non-http(s) baseUrl", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: "file:///etc/passwd",
},
});
assert.equal(validation.success, false, "Schema should reject non-http(s) URL schemes");
});
+75 -4
View File
@@ -7,10 +7,8 @@ import { detectFormat } from "../../open-sse/services/provider.ts";
import { shouldUseNativeCodexPassthrough } from "../../open-sse/handlers/chatCore.ts";
import { translateRequest } from "../../open-sse/translator/index.ts";
import { GithubExecutor } from "../../open-sse/executors/github.ts";
import {
CodexExecutor,
setDefaultFastServiceTierEnabled,
} from "../../open-sse/executors/codex.ts";
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
import { CodexExecutor, setDefaultFastServiceTierEnabled } from "../../open-sse/executors/codex.ts";
import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
import { extractUsageFromResponse } from "../../open-sse/handlers/usageExtractor.ts";
import { parseSSEToResponsesOutput } from "../../open-sse/handlers/sseParser.ts";
@@ -60,6 +58,14 @@ test("GithubExecutor keeps non-codex model on /chat/completions", () => {
assert.match(url, /\/chat\/completions$/);
});
test("DefaultExecutor uses x-api-key for kimi-coding-apikey", () => {
const executor = new DefaultExecutor("kimi-coding-apikey");
const headers = executor.buildHeaders({ apiKey: "sk-kimi-test" }, true);
assert.equal(headers["x-api-key"], "sk-kimi-test");
assert.equal(headers.Authorization, undefined);
});
test("CodexExecutor forces stream=true for upstream compatibility", () => {
const executor = new CodexExecutor();
const transformed = executor.transformRequest(
@@ -108,6 +114,24 @@ test("shouldUseNativeCodexPassthrough only enables responses-native Codex reques
false
);
assert.equal(
shouldUseNativeCodexPassthrough({
provider: "codex",
sourceFormat: FORMATS.OPENAI_RESPONSES,
endpointPath: "/v1/responses/compact",
}),
true
);
assert.equal(
shouldUseNativeCodexPassthrough({
provider: "codex",
sourceFormat: FORMATS.OPENAI_RESPONSES,
endpointPath: "/v1/responses/items/history",
}),
true
);
assert.equal(
shouldUseNativeCodexPassthrough({
provider: "codex",
@@ -140,6 +164,18 @@ test("CodexExecutor always requests SSE accept header", () => {
assert.equal(headers.Accept, "text/event-stream");
});
test("CodexExecutor does not request SSE accept header for compact requests", () => {
const executor = new CodexExecutor();
const headers = executor.buildHeaders(
{
accessToken: "test-token",
requestEndpointPath: "/v1/responses/compact",
},
false
);
assert.equal(headers.Accept, undefined);
});
test("CodexExecutor preserves native responses payloads for Codex passthrough", () => {
const executor = new CodexExecutor();
const transformed = executor.transformRequest(
@@ -167,6 +203,41 @@ test("CodexExecutor preserves native responses payloads for Codex passthrough",
assert.ok(!("_nativeCodexPassthrough" in transformed));
});
test("CodexExecutor strips streaming fields for compact passthrough", () => {
const executor = new CodexExecutor();
const transformed = executor.transformRequest(
"gpt-5.1-codex",
{
model: "gpt-5.1-codex",
input: "compact this session",
stream: false,
stream_options: { include_usage: true },
_nativeCodexPassthrough: true,
},
false,
{
requestEndpointPath: "/v1/responses/compact",
}
);
assert.equal("stream" in transformed, false);
assert.equal("stream_options" in transformed, false);
assert.ok(!("_nativeCodexPassthrough" in transformed));
});
test("CodexExecutor routes responses subpaths to matching upstream paths", () => {
const executor = new CodexExecutor();
const compactUrl = executor.buildUrl("gpt-5.1-codex", true, 0, {
requestEndpointPath: "/v1/responses/compact",
});
assert.match(compactUrl, /\/responses\/compact$/);
const genericSubpathUrl = executor.buildUrl("gpt-5.1-codex", true, 0, {
requestEndpointPath: "/v1/responses/items/history",
});
assert.match(genericSubpathUrl, /\/responses\/items\/history$/);
});
test("translateNonStreamingResponse converts Responses API payload to OpenAI chat.completion", () => {
const responseBody = {
id: "resp_123",
@@ -48,3 +48,110 @@ test("serper validation still rejects unauthorized keys", async () => {
globalThis.fetch = originalFetch;
}
});
test("kimi-coding-apikey validation uses Kimi Coding messages endpoint", async () => {
const originalFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (url, init = {}) => {
calls.push({
url: String(url),
method: init.method || "GET",
headers: init.headers || {},
});
return new Response(JSON.stringify({ ok: true }), {
status: 400,
headers: { "content-type": "application/json" },
});
};
try {
const result = await validateProviderApiKey({
provider: "kimi-coding-apikey",
apiKey: "sk-kimi-test",
});
assert.equal(result.valid, true);
assert.equal(result.error, null);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, "https://api.kimi.com/coding/v1/messages");
assert.equal(calls[0].method, "POST");
assert.equal(calls[0].headers["x-api-key"], "sk-kimi-test");
assert.equal(calls[0].headers["Anthropic-Version"], "2023-06-01");
for (const call of calls) {
assert.equal(call.url.includes("?beta=true/messages"), false);
assert.equal(call.url.includes("?beta=true/models"), false);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("bailian-coding-plan validation accepts 400 as valid auth path", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "invalid request" }), {
status: 400,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "valid-bailian-key",
});
assert.equal(result.valid, true);
assert.equal(result.error, null);
} finally {
globalThis.fetch = originalFetch;
}
});
test("bailian-coding-plan validation rejects 401 as invalid key", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "bad-bailian-key",
});
assert.equal(result.valid, false);
assert.equal(result.error, "Invalid API key");
} finally {
globalThis.fetch = originalFetch;
}
});
test("bailian-coding-plan validation rejects 403 as invalid key", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "bad-bailian-key",
});
assert.equal(result.valid, false);
assert.equal(result.error, "Invalid API key");
} finally {
globalThis.fetch = originalFetch;
}
});