mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
27
tests/unit/provider-route-schemas.test.ts
Normal file
27
tests/unit/provider-route-schemas.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user