refactor(api): consolidate auth routing and provider config handling

Centralize optional API key capability checks so dashboard forms and
provider schemas share the same rules, including keyless Pollinations
support and cloud-agent batch testing mode.

Also align auto-combo config on `routerStrategy`, keep legacy combo
reads compatible, preserve MCP routes as management APIs, narrow
`require-login` public access to readonly/bootstrap flows, and make
cloud-agent CORS reflect the request origin for credentialed requests.
This commit is contained in:
diegosouzapw
2026-05-13 00:59:25 -03:00
parent c89663d774
commit 46e5aa5522
19 changed files with 126 additions and 107 deletions

View File

@@ -428,7 +428,7 @@ export async function handleSetRoutingStrategy(args: {
...currentConfig,
auto: {
...currentAutoConfig,
routingStrategy: args.autoRoutingStrategy,
routerStrategy: args.autoRoutingStrategy,
},
};
}
@@ -447,7 +447,7 @@ export async function handleSetRoutingStrategy(args: {
const updatedConfig = toRecord(updatedCombo.config);
const resolvedAutoStrategy =
toString(toRecord(updatedConfig.auto).routingStrategy) ||
toString(toRecord(updatedConfig.auto).routerStrategy) ||
(normalizedStrategy === "auto" ? (args.autoRoutingStrategy ?? "rules") : "");
const result = {

View File

@@ -14,8 +14,6 @@ interface VirtualFactoryConn extends ConnectionFields {
defaultModel?: string;
expiresAt?: number | string | null;
tokenExpiresAt?: number | string | null;
oauthToken?: string | null;
oauthExpiresAt?: number | string | null; // legacy timestamp or ISO string
}
export interface VirtualAutoComboCandidate {
@@ -41,14 +39,14 @@ type VirtualAutoCombo = AutoComboConfig & {
candidatePool: string[];
weights: ScoringWeights;
explorationRate: number;
routingStrategy: string;
routerStrategy: string;
};
config: {
auto: {
candidatePool: string[];
weights: ScoringWeights;
explorationRate: number;
routingStrategy: string;
routerStrategy: string;
};
};
};
@@ -64,7 +62,7 @@ function toExpiryMs(value: unknown): number | null {
: Number.NaN;
if (Number.isFinite(parsed) && parsed > 0) {
return parsed < 1_000_000_000_000 ? parsed * 1000 : parsed;
return parsed < 10_000_000_000 ? parsed * 1000 : parsed;
}
if (typeof value === "string") {
@@ -76,19 +74,9 @@ function toExpiryMs(value: unknown): number | null {
}
function hasUsableOAuthToken(conn: VirtualFactoryConn): boolean {
const token =
typeof conn.accessToken === "string" && conn.accessToken.trim().length > 0
? conn.accessToken
: typeof conn.oauthToken === "string" && conn.oauthToken.trim().length > 0
? conn.oauthToken
: null;
if (typeof conn.accessToken !== "string" || conn.accessToken.trim().length === 0) return false;
if (!token) return false;
const expiryMs =
toExpiryMs(conn.tokenExpiresAt) ??
toExpiryMs(conn.expiresAt) ??
toExpiryMs(conn.oauthExpiresAt);
const expiryMs = toExpiryMs(conn.tokenExpiresAt) ?? toExpiryMs(conn.expiresAt);
return expiryMs === null || expiryMs > Date.now();
}
@@ -114,7 +102,7 @@ export async function createVirtualAutoCombo(
candidatePool: emptyPool,
weights: { ...DEFAULT_WEIGHTS },
explorationRate: 0.05,
routingStrategy: "lkgp",
routerStrategy: "lkgp",
};
return {
id: `virtual-auto-${variant || "default"}`,
@@ -125,7 +113,7 @@ export async function createVirtualAutoCombo(
candidatePool: emptyPool,
weights: autoConfig.weights,
explorationRate: autoConfig.explorationRate,
routerStrategy: autoConfig.routingStrategy,
routerStrategy: autoConfig.routerStrategy,
autoConfig,
config: { auto: autoConfig },
};
@@ -196,7 +184,7 @@ export async function createVirtualAutoCombo(
candidatePool: providerPool,
weights,
explorationRate,
routingStrategy: routerStrategy,
routerStrategy,
};
return {

View File

@@ -1713,11 +1713,13 @@ export async function handleComboChat({
const autoConfigSource = combo?.autoConfig || combo?.config?.auto || combo?.config || {};
const routingStrategy =
typeof autoConfigSource.routingStrategy === "string"
? autoConfigSource.routingStrategy
: typeof autoConfigSource.strategyName === "string"
? autoConfigSource.strategyName
: "rules";
typeof autoConfigSource.routerStrategy === "string"
? autoConfigSource.routerStrategy
: typeof autoConfigSource.routingStrategy === "string"
? autoConfigSource.routingStrategy
: typeof autoConfigSource.strategyName === "string"
? autoConfigSource.strategyName
: "rules";
const candidatePool = Array.isArray(autoConfigSource.candidatePool)
? autoConfigSource.candidatePool

View File

@@ -27,6 +27,7 @@ import {
isAnthropicCompatibleProvider,
isClaudeCodeCompatibleProvider,
isSelfHostedChatProvider,
providerAllowsOptionalApiKey,
supportsApiKeyOnFreeProvider,
} from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
@@ -6125,15 +6126,13 @@ function AddApiKeyModal({
const isCloudflare = provider === "cloudflare-ai";
const localProviderMetadata = getLocalProviderMetadata(provider);
const isLocalSelfHostedProvider = !!localProviderMetadata;
const isSearxng = provider === "searxng-search";
const isGooglePse = provider === "google-pse-search";
const isGrokWeb = provider === "grok-web";
const isPerplexityWeb = provider === "perplexity-web";
const isBlackboxWeb = provider === "blackbox-web";
const isMuseSparkWeb = provider === "muse-spark-web";
const isWebSessionProvider = isGrokWeb || isPerplexityWeb || isBlackboxWeb || isMuseSparkWeb;
const isPetals = provider === "petals";
const apiKeyOptional = isSearxng || isPetals || isLocalSelfHostedProvider;
const apiKeyOptional = providerAllowsOptionalApiKey(provider);
const commandCodeAuthPhaseLabel = commandCodeAuthState
? {
idle: "Ready",
@@ -6206,7 +6205,7 @@ function AddApiKeyModal({
? t("localProviderApiKeyOptionalHint", {
provider: localProviderMetadata?.name || providerName || provider || "",
})
: isSearxng || isPetals
: apiKeyOptional
? t("apiKeyOptionalHint")
: undefined;
@@ -6737,17 +6736,15 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
const isGeminiCli = connection?.provider === "gemini-cli";
const localProviderMetadata = getLocalProviderMetadata(connection?.provider);
const isLocalSelfHostedProvider = !!localProviderMetadata;
const isSearxng = connection?.provider === "searxng-search";
const isGooglePse = connection?.provider === "google-pse-search";
const isPetals = connection?.provider === "petals";
const apiKeyOptional = isSearxng || isPetals || isLocalSelfHostedProvider;
const apiKeyOptional = providerAllowsOptionalApiKey(connection?.provider);
const isCcCompatible = isClaudeCodeCompatibleProvider(connection?.provider);
const defaultRegion = "us-central1";
const apiCredentialHint = isLocalSelfHostedProvider
? t("localProviderApiKeyOptionalHint", {
provider: localProviderMetadata?.name || connection?.provider || "",
})
: isSearxng || isPetals
: apiKeyOptional
? t("apiKeyOptionalHint")
: t("leaveBlankKeepCurrentApiKey");

View File

@@ -9,6 +9,7 @@ import {
WEB_COOKIE_PROVIDERS,
SEARCH_PROVIDERS,
AUDIO_ONLY_PROVIDERS,
CLOUD_AGENT_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
@@ -26,6 +27,7 @@ function getAuthGroup(providerId) {
if (AUDIO_ONLY_PROVIDERS[providerId]) return "audio";
if (LOCAL_PROVIDERS[providerId]) return "local";
if (UPSTREAM_PROXY_PROVIDERS[providerId]) return "upstream-proxy";
if (CLOUD_AGENT_PROVIDERS[providerId]) return "cloud-agent";
if (APIKEY_PROVIDERS[providerId]) return "apikey";
if (
typeof providerId === "string" &&
@@ -99,6 +101,8 @@ export async function POST(request) {
connectionsToTest = allConnections.filter(
(c) => getAuthGroup(c.provider) === "upstream-proxy"
);
} else if (mode === "cloud-agent") {
connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider) === "cloud-agent");
} else if (mode === "compatible") {
connectionsToTest = allConnections.filter((c) => isCompatibleProvider(c.provider));
} else if (mode === "all") {
@@ -107,7 +111,7 @@ export async function POST(request) {
return NextResponse.json(
{
error:
"Invalid mode. Use: provider, oauth, free, apikey, compatible, all, web-cookie, search, audio, local, upstream-proxy",
"Invalid mode. Use: provider, oauth, free, apikey, compatible, all, web-cookie, search, audio, local, upstream-proxy, cloud-agent",
},
{ status: 400 }
);

View File

@@ -17,8 +17,10 @@ import pino from "pino";
const logger = pino({ name: "cloud-agents-api" });
export async function OPTIONS() {
return new NextResponse(null, { headers: getCloudAgentCorsHeaders() });
createCloudAgentTaskTable();
export async function OPTIONS(request: NextRequest) {
return new NextResponse(null, { headers: getCloudAgentCorsHeaders(request) });
}
const ApproveSchema = z.object({
@@ -40,10 +42,10 @@ const TaskActionSchema = z.discriminatedUnion("action", [
CancelSchema,
]);
function cloudAgentCredentialsRequiredResponse(providerId: string) {
function cloudAgentCredentialsRequiredResponse(providerId: string, request: NextRequest) {
return NextResponse.json(
{ error: `No active credentials configured for cloud agent provider: ${providerId}` },
{ status: 400, headers: getCloudAgentCorsHeaders() }
{ status: 400, headers: getCloudAgentCorsHeaders(request) }
);
}
@@ -52,15 +54,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const authError = await requireCloudAgentManagementAuth(request);
if (authError) return authError;
createCloudAgentTaskTable();
const { id } = await params;
const task = getCloudAgentTaskById(id);
if (!task) {
return NextResponse.json(
{ error: "Task not found" },
{ status: 404, headers: getCloudAgentCorsHeaders() }
{ status: 404, headers: getCloudAgentCorsHeaders(request) }
);
}
@@ -83,7 +83,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
});
}
} catch (err) {
console.error("Failed to sync task status:", err);
logger.error({ err }, "Failed to sync task status");
}
}
@@ -93,12 +93,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
{
data: serializeCloudAgentTask(updatedTask!),
},
{ headers: getCloudAgentCorsHeaders() }
{ headers: getCloudAgentCorsHeaders(request) }
);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCloudAgentCorsHeaders() }
{ status: 500, headers: getCloudAgentCorsHeaders(request) }
);
}
}
@@ -114,17 +114,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
if (!validation.success) {
return NextResponse.json(
{ error: "Validation failed", details: validation.error.issues },
{ status: 400, headers: getCloudAgentCorsHeaders() }
{ status: 400, headers: getCloudAgentCorsHeaders(request) }
);
}
createCloudAgentTaskTable();
const task = getCloudAgentTaskById(id);
if (!task) {
return NextResponse.json(
{ error: "Task not found" },
{ status: 404, headers: getCloudAgentCorsHeaders() }
{ status: 404, headers: getCloudAgentCorsHeaders(request) }
);
}
@@ -134,7 +132,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
if (!agent) {
return NextResponse.json(
{ error: "Agent not found" },
{ status: 500, headers: getCloudAgentCorsHeaders() }
{ status: 500, headers: getCloudAgentCorsHeaders(request) }
);
}
@@ -142,22 +140,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
if (!task.external_id) {
return NextResponse.json(
{ error: "No external task to approve" },
{ status: 400, headers: getCloudAgentCorsHeaders() }
{ status: 400, headers: getCloudAgentCorsHeaders(request) }
);
}
const credentials = await getCloudAgentCredentials(task.provider_id);
if (!credentials) return cloudAgentCredentialsRequiredResponse(task.provider_id);
if (!credentials) return cloudAgentCredentialsRequiredResponse(task.provider_id, request);
await agent.approvePlan(task.external_id, credentials);
updateCloudAgentTask(id, { status: "running" });
} else if (validated.action === "message") {
if (!task.external_id) {
return NextResponse.json(
{ error: "No external task to message" },
{ status: 400, headers: getCloudAgentCorsHeaders() }
{ status: 400, headers: getCloudAgentCorsHeaders(request) }
);
}
const credentials = await getCloudAgentCredentials(task.provider_id);
if (!credentials) return cloudAgentCredentialsRequiredResponse(task.provider_id);
if (!credentials) return cloudAgentCredentialsRequiredResponse(task.provider_id, request);
const activity = await agent.sendMessage(task.external_id, validated.message, credentials);
const activities: unknown[] = serializeCloudAgentTask(task).activities;
activities.push(activity);
@@ -169,13 +167,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const updatedTask = getCloudAgentTaskById(id);
return NextResponse.json(
{ success: true, data: updatedTask ? serializeCloudAgentTask(updatedTask) : null },
{ headers: getCloudAgentCorsHeaders() }
{ headers: getCloudAgentCorsHeaders(request) }
);
} catch (error) {
logger.error({ err: error }, "Failed to process task action");
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCloudAgentCorsHeaders() }
{ status: 500, headers: getCloudAgentCorsHeaders(request) }
);
}
}
@@ -188,23 +186,21 @@ export async function DELETE(
const authError = await requireCloudAgentManagementAuth(request);
if (authError) return authError;
createCloudAgentTaskTable();
const { id } = await params;
const task = getCloudAgentTaskById(id);
if (!task) {
return NextResponse.json(
{ error: "Task not found" },
{ status: 404, headers: getCloudAgentCorsHeaders() }
{ status: 404, headers: getCloudAgentCorsHeaders(request) }
);
}
deleteCloudAgentTask(id);
return NextResponse.json({ success: true }, { headers: getCloudAgentCorsHeaders() });
return NextResponse.json({ success: true }, { headers: getCloudAgentCorsHeaders(request) });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCloudAgentCorsHeaders() }
{ status: 500, headers: getCloudAgentCorsHeaders(request) }
);
}
}

View File

@@ -19,14 +19,16 @@ import pino from "pino";
const logger = pino({ name: "cloud-agents-api" });
createCloudAgentTaskTable();
function getLimit(value: string | null): number {
const parsed = Number.parseInt(value || "50", 10);
if (!Number.isFinite(parsed)) return 50;
return Math.max(1, Math.min(parsed, 500));
}
export async function OPTIONS() {
return new NextResponse(null, { headers: getCloudAgentCorsHeaders() });
export async function OPTIONS(request: NextRequest) {
return new NextResponse(null, { headers: getCloudAgentCorsHeaders(request) });
}
export async function GET(request: NextRequest) {
@@ -34,8 +36,6 @@ export async function GET(request: NextRequest) {
const authError = await requireCloudAgentManagementAuth(request);
if (authError) return authError;
createCloudAgentTaskTable();
const { searchParams } = new URL(request.url);
const providerId = searchParams.get("provider");
const status = searchParams.get("status");
@@ -54,12 +54,12 @@ export async function GET(request: NextRequest) {
{
data: tasks.map(serializeCloudAgentTask),
},
{ headers: getCloudAgentCorsHeaders() }
{ headers: getCloudAgentCorsHeaders(request) }
);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCloudAgentCorsHeaders() }
{ status: 500, headers: getCloudAgentCorsHeaders(request) }
);
}
}
@@ -74,7 +74,7 @@ export async function POST(request: NextRequest) {
if (!validation.success) {
return NextResponse.json(
{ error: "Validation failed", details: validation.error.issues },
{ status: 400, headers: getCloudAgentCorsHeaders() }
{ status: 400, headers: getCloudAgentCorsHeaders(request) }
);
}
@@ -84,7 +84,7 @@ export async function POST(request: NextRequest) {
if (!agent) {
return NextResponse.json(
{ error: `Unknown provider: ${validated.providerId}` },
{ status: 400, headers: getCloudAgentCorsHeaders() }
{ status: 400, headers: getCloudAgentCorsHeaders(request) }
);
}
@@ -94,7 +94,7 @@ export async function POST(request: NextRequest) {
{
error: `No active credentials configured for cloud agent provider: ${validated.providerId}`,
},
{ status: 400, headers: getCloudAgentCorsHeaders() }
{ status: 400, headers: getCloudAgentCorsHeaders(request) }
);
}
@@ -107,7 +107,6 @@ export async function POST(request: NextRequest) {
credentials
);
createCloudAgentTaskTable();
insertCloudAgentTask({
id: task.id,
provider_id: task.providerId,
@@ -137,13 +136,13 @@ export async function POST(request: NextRequest) {
createdAt: task.createdAt,
},
},
{ status: 201, headers: getCloudAgentCorsHeaders() }
{ status: 201, headers: getCloudAgentCorsHeaders(request) }
);
} catch (error) {
logger.error({ err: error }, "Failed to create cloud agent task");
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCloudAgentCorsHeaders() }
{ status: 500, headers: getCloudAgentCorsHeaders(request) }
);
}
}
@@ -153,25 +152,23 @@ export async function DELETE(request: NextRequest) {
const authError = await requireCloudAgentManagementAuth(request);
if (authError) return authError;
createCloudAgentTaskTable();
const { searchParams } = new URL(request.url);
const taskId = searchParams.get("id");
if (!taskId) {
return NextResponse.json(
{ error: "Task ID required" },
{ status: 400, headers: getCloudAgentCorsHeaders() }
{ status: 400, headers: getCloudAgentCorsHeaders(request) }
);
}
deleteCloudAgentTask(taskId);
return NextResponse.json({ success: true }, { headers: getCloudAgentCorsHeaders() });
return NextResponse.json({ success: true }, { headers: getCloudAgentCorsHeaders(request) });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCloudAgentCorsHeaders() }
{ status: 500, headers: getCloudAgentCorsHeaders(request) }
);
}
}

View File

@@ -5,17 +5,19 @@ import type { CloudAgentTaskRow } from "./db.ts";
type JsonRecord = Record<string, unknown>;
export function getCloudAgentCorsHeaders() {
export function getCloudAgentCorsHeaders(request?: Request) {
const origin = request?.headers.get("origin");
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": origin || "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Credentials": "true",
};
}
export function withCloudAgentCors(response: Response): Response {
export function withCloudAgentCors(response: Response, request?: Request): Response {
const headers = new Headers(response.headers);
for (const [key, value] of Object.entries(getCloudAgentCorsHeaders())) {
for (const [key, value] of Object.entries(getCloudAgentCorsHeaders(request))) {
headers.set(key, value);
}
@@ -28,7 +30,7 @@ export function withCloudAgentCors(response: Response): Response {
export async function requireCloudAgentManagementAuth(request: Request): Promise<Response | null> {
const authError = await requireManagementAuth(request);
return authError ? withCloudAgentCors(authError) : null;
return authError ? withCloudAgentCors(authError, request) : null;
}
function parseJson<T>(value: string | null | undefined, fallback: T): T {

View File

@@ -69,16 +69,10 @@ export function classifyRoute(rawPath: string, method: string = "GET"): RouteCla
};
}
if (
normalizedPath === "/api/v1" ||
normalizedPath.startsWith("/api/v1/") ||
normalizedPath.startsWith("/api/mcp/")
) {
if (normalizedPath === "/api/v1" || normalizedPath.startsWith("/api/v1/")) {
return {
routeClass: "CLIENT_API",
reason:
aliasReason ??
(normalizedPath.startsWith("/api/mcp/") ? "client_api_mcp" : "client_api_v1"),
reason: aliasReason ?? "client_api_v1",
normalizedPath,
};
}

View File

@@ -1962,6 +1962,8 @@ export function providerAllowsOptionalApiKey(providerId: unknown): boolean {
return (
providerId === "searxng-search" ||
providerId === "petals" ||
providerId === "pollinations" ||
isLocalProvider(providerId) ||
isSelfHostedChatProvider(providerId) ||
isOpenAICompatibleProvider(providerId) ||
isAnthropicCompatibleProvider(providerId)

View File

@@ -3,14 +3,16 @@ const PUBLIC_API_ROUTE_PREFIXES = [
"/api/auth/logout",
"/api/auth/status",
"/api/init",
"/api/settings/require-login",
"/api/v1/",
"/api/cloud/",
"/api/sync/bundle",
"/api/oauth/",
];
const PUBLIC_READONLY_API_ROUTE_PREFIXES = ["/api/monitoring/health"];
const PUBLIC_READONLY_API_ROUTE_PREFIXES = [
"/api/monitoring/health",
"/api/settings/require-login",
];
const PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);

View File

@@ -58,6 +58,10 @@ function isOnboardingBootstrapPath(pathname: string | null): boolean {
return pathname === "/dashboard/onboarding";
}
function isRequireLoginBootstrapWritePath(pathname: string | null, method: string): boolean {
return pathname === "/api/settings/require-login" && method.toUpperCase() === "POST";
}
function getRequestMethod(request: RequestLike | Request | null | undefined): string {
if (
request &&
@@ -277,11 +281,16 @@ export async function isAuthRequired(
if (!request) return false;
const pathname = getRequestPathname(request);
const method = getRequestMethod(request);
if (isOnboardingBootstrapPath(pathname)) {
return false;
}
if (pathname && isPublicApiRoute(pathname, getRequestMethod(request))) {
if (pathname && isPublicApiRoute(pathname, method)) {
return false;
}
if (isRequireLoginBootstrapWritePath(pathname, method)) {
return false;
}

View File

@@ -6,7 +6,7 @@ import {
import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints";
import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize";
import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
import { isLocalProvider } from "@/shared/constants/providers";
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders";
@@ -259,10 +259,7 @@ export const createProviderSchema = z
})
.superRefine((data, ctx) => {
const apiKey = typeof data.apiKey === "string" ? data.apiKey.trim() : "";
const apiKeyOptional =
data.provider === "searxng-search" ||
data.provider === "petals" ||
isLocalProvider(data.provider);
const apiKeyOptional = providerAllowsOptionalApiKey(data.provider);
if (!apiKeyOptional && apiKey.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -1625,6 +1622,7 @@ export const providersBatchTestSchema = z
"audio",
"local",
"upstream-proxy",
"cloud-agent",
]),
// Frontend may send null when mode != 'provider' — accept and treat as missing
providerId: z.string().trim().min(1).nullable().optional(),

View File

@@ -35,6 +35,7 @@ const cases: Case[] = [
{ name: "/api/v1/files", path: "/api/v1/files", expectedClass: "CLIENT_API" },
{ name: "/api/v1/batches", path: "/api/v1/batches", expectedClass: "CLIENT_API" },
{ name: "/api/v1/ws", path: "/api/v1/ws", expectedClass: "CLIENT_API" },
{ name: "/api/mcp/* stays management", path: "/api/mcp/status", expectedClass: "MANAGEMENT" },
{
name: "/v1 alias",

View File

@@ -98,7 +98,7 @@ test("runAuthzPipeline allows onboarding when login is required but no password
);
assert.equal(response.status, 200);
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
assert.equal(response.headers.get("x-omniroute-route-class"), "PUBLIC");
});
test("runAuthzPipeline allows first password writes when login is required but no password exists", async () => {
@@ -115,7 +115,7 @@ test("runAuthzPipeline allows first password writes when login is required but n
);
assert.equal(response.status, 200);
assert.equal(response.headers.get("x-omniroute-route-class"), "PUBLIC");
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
});
test("runAuthzPipeline keeps management API rejections as JSON", async () => {

View File

@@ -1503,7 +1503,7 @@ test("handleComboChat auto strategy honors LKGP after filtering to tool-capable
name: "auto-lkgp",
strategy: "auto",
models: ["openai/gpt-oss-120b", "openai/gpt-4o-mini", "claude/claude-sonnet-4-6"],
autoConfig: { routingStrategy: "lkgp" },
autoConfig: { routerStrategy: "lkgp" },
},
handleSingleModel: async (_body: any, modelStr: any) => {
calls.push(modelStr);
@@ -1621,7 +1621,7 @@ test("handleComboChat auto strategy falls back to the full pool when tool filter
name: "auto-cost-fallback",
strategy: "auto",
models: ["openai/gpt-oss-120b", "deepseek/reasoner"],
autoConfig: { routingStrategy: "cost" },
autoConfig: { routerStrategy: "cost" },
},
handleSingleModel: async (_body: any, modelStr: any) => {
calls.push(modelStr);
@@ -1658,7 +1658,7 @@ test("handleComboChat auto strategy falls back to rules when a custom router str
name: "auto-throwing-strategy",
strategy: "auto",
models: ["openai/gpt-4o-mini"],
autoConfig: { routingStrategy: "throwing-test" },
autoConfig: { routerStrategy: "throwing-test" },
},
handleSingleModel: async (_body: any, modelStr: any) => {
calls.push(modelStr);

View File

@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
const { createProviderSchema, providersBatchTestSchema } =
await import("../../src/shared/validation/schemas.ts");
const { providerAllowsOptionalApiKey } = await import("../../src/shared/constants/providers.ts");
test("Pollinations is treated as a keyless-capable provider", () => {
assert.equal(providerAllowsOptionalApiKey("pollinations"), true);
});
test("createProviderSchema allows Pollinations without apiKey", () => {
const result = createProviderSchema.safeParse({
provider: "pollinations",
name: "Pollinations",
});
assert.equal(result.success, true);
});
test("providersBatchTestSchema accepts cloud-agent batch mode", () => {
const result = providersBatchTestSchema.safeParse({
mode: "cloud-agent",
});
assert.equal(result.success, true);
});

View File

@@ -18,7 +18,7 @@ test("isPublicApiRoute allows readonly health and require-login bootstrap routes
assert.equal(isPublicApiRoute("/api/settings/require-login", "GET"), true);
assert.equal(isPublicApiRoute("/api/settings/require-login", "HEAD"), true);
assert.equal(isPublicApiRoute("/api/settings/require-login", "OPTIONS"), true);
assert.equal(isPublicApiRoute("/api/settings/require-login", "POST"), true);
assert.equal(isPublicApiRoute("/api/settings/require-login", "POST"), false);
});
test("isPublicApiRoute rejects non-public management routes", () => {

View File

@@ -52,7 +52,7 @@ test("createVirtualAutoCombo returns an executable auto combo for API-key connec
assert.equal(combo.models[0].kind, "model");
assert.equal(combo.models[0].model, "openai/gpt-4o-mini");
assert.equal(combo.models[0].providerId, "openai");
assert.equal(combo.autoConfig.routingStrategy, "lkgp");
assert.equal(combo.autoConfig.routerStrategy, "lkgp");
assert.deepEqual(combo.autoConfig.candidatePool, ["openai"]);
});