Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe5c20a04e | |||
| 246fd05fae |
@@ -55,6 +55,8 @@ logs/*
|
||||
# analysis directories (generated, not tracked)
|
||||
.analysis/
|
||||
antigravity-manager-analysis/
|
||||
.sisyphus/
|
||||
.plans/
|
||||
|
||||
# docs (allow specific tracked files)
|
||||
docs/*
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.7.10
|
||||
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,
|
||||
|
||||
@@ -537,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",
|
||||
|
||||
@@ -54,6 +54,7 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
break;
|
||||
case "glm":
|
||||
case "kimi-coding":
|
||||
case "bailian-coding-plan":
|
||||
case "kimi-coding-apikey":
|
||||
case "minimax":
|
||||
case "minimax-cn":
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.7.10",
|
||||
"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": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -37,7 +37,7 @@ const KIMI_CODING_MODELS_CONFIG: ProviderModelsConfigEntry = {
|
||||
};
|
||||
|
||||
// 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)" },
|
||||
@@ -61,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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]) => [
|
||||
|
||||
@@ -33,6 +33,7 @@ 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",
|
||||
|
||||
@@ -74,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",
|
||||
|
||||
@@ -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 ────
|
||||
@@ -945,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");
|
||||
});
|
||||
@@ -89,3 +89,69 @@ test("kimi-coding-apikey validation uses Kimi Coding messages endpoint", async (
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user