feat: adapter-spec-driven Registry Editor replaces legacy provider editors
- New RegistryEditor renders providers, models, runtime parameters and capabilities from the backend's AdapterParameterSpec contract, with ModelAvailability badges, write-once credential staging, provider tests showing effective_request_options, revision CAS on save, and 422 detail paths surfaced verbatim (design doc sections 9, 11.8) - ConfigDialog now hosts the Registry Editor (admin-only; non-admins see a notice from the 403 path) - AccountDialog shows the session role and embeds an admin user-management section (list/create/promote/demote/password reset/delete, last-admin guard enforced server-side) - Deleted legacy ProviderProfiles/BuiltinProviders/RegistryBuiltinProviders editors, providerProfiles/legacyLlmConfig libs, the admin-token proxy and the /api/provider-profiles, /api/provider-actions, /api/default-model and /api/config BFF routes (design doc section 10 legacy removals) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isAuthenticationEnabled } from "@/lib/auth";
|
||||
import { proxyEvoScientistAdminRequest } from "@/lib/server/evoscientistAdminProxy";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function proxy(request: NextRequest) {
|
||||
if (!isAuthenticationEnabled()) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Authentication required. Enable WebUI authentication to manage config.yaml.",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
return proxyEvoScientistAdminRequest(request, {
|
||||
upstreamPath: "/api/config",
|
||||
requestLabel: "Built-in model configuration",
|
||||
maxBodyBytes: 512_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxy(request);
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
return proxy(request);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return proxy(request);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyEvoScientistAdminRequest } from "@/lib/server/evoscientistAdminProxy";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return proxyEvoScientistAdminRequest(request, {
|
||||
upstreamPath: "/api/default-model",
|
||||
requestLabel: "Default model configuration",
|
||||
maxBodyBytes: 8_192,
|
||||
});
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyEvoScientistAdminRequest } from "@/lib/server/evoscientistAdminProxy";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return proxyEvoScientistAdminRequest(request, {
|
||||
upstreamPath: "/api/provider-actions",
|
||||
requestLabel: "Provider action",
|
||||
maxBodyBytes: 32_768,
|
||||
});
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyEvoScientistAdminRequest } from "@/lib/server/evoscientistAdminProxy";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function proxy(request: NextRequest) {
|
||||
return proxyEvoScientistAdminRequest(request, {
|
||||
upstreamPath: "/api/provider-profiles",
|
||||
requestLabel: "Provider configuration",
|
||||
maxBodyBytes: 512_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxy(request);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return proxy(request);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UserManagementSection } from "@/app/components/UserManagementSection";
|
||||
|
||||
interface AccountDialogProps {
|
||||
open: boolean;
|
||||
@@ -17,13 +18,16 @@ interface AccountDialogProps {
|
||||
}
|
||||
|
||||
export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [account, setAccount] = useState<{
|
||||
username: string;
|
||||
role: "admin" | "user";
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUsername(null);
|
||||
setAccount(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
@@ -36,6 +40,7 @@ export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
.then(async (response) => {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
username?: unknown;
|
||||
role?: unknown;
|
||||
error?: unknown;
|
||||
} | null;
|
||||
if (!response.ok || typeof body?.username !== "string") {
|
||||
@@ -45,10 +50,13 @@ export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
: "Account information is unavailable."
|
||||
);
|
||||
}
|
||||
return body.username;
|
||||
return {
|
||||
username: body.username,
|
||||
role: body.role === "user" ? ("user" as const) : ("admin" as const),
|
||||
};
|
||||
})
|
||||
.then((value) => {
|
||||
if (!cancelled) setUsername(value);
|
||||
if (!cancelled) setAccount(value);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) {
|
||||
@@ -73,7 +81,7 @@ export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>User information</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -85,7 +93,7 @@ export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
className="rounded-md border border-border px-4 py-3"
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground">Username</p>
|
||||
{loading || (!error && username === null) ? (
|
||||
{loading || (!error && account === null) ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Loading account...
|
||||
</p>
|
||||
@@ -96,12 +104,18 @@ export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
) : account ? (
|
||||
<p className="mt-1 break-words text-sm font-medium text-foreground">
|
||||
{username}
|
||||
{account.username}
|
||||
<span className="ml-2 rounded bg-accent px-1.5 py-0.5 text-xs font-normal text-muted-foreground">
|
||||
{account.role}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</section>
|
||||
{account?.role === "admin" && (
|
||||
<UserManagementSection self={account.username} />
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useCollapseAgentActions } from "@/lib/uiSettings";
|
||||
import { ModelProvidersEditor } from "@/app/components/ModelProvidersEditor";
|
||||
import { RegistryEditor } from "@/app/components/RegistryEditor";
|
||||
|
||||
interface ConfigDialogProps {
|
||||
open: boolean;
|
||||
@@ -22,10 +22,6 @@ export function ConfigDialog({ open, onOpenChange }: ConfigDialogProps) {
|
||||
const { value: collapseAgentActions, setValue: setCollapseAgentActions } =
|
||||
useCollapseAgentActions();
|
||||
|
||||
const handleSave = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -33,12 +29,13 @@ export function ConfigDialog({ open, onOpenChange }: ConfigDialogProps) {
|
||||
>
|
||||
<DialogContent className="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Model configuration</DialogTitle>
|
||||
<DialogTitle>Model registry</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage custom model providers available to EvoScientist.
|
||||
Manage providers, models, credentials and defaults. Admin only;
|
||||
every save is validated by the backend.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ModelProvidersEditor />
|
||||
{open && <RegistryEditor />}
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
id="collapseAgentActions"
|
||||
@@ -56,13 +53,7 @@ export function ConfigDialog({ open, onOpenChange }: ConfigDialogProps) {
|
||||
</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ProviderProfilesEditor } from "@/app/components/ProviderProfilesEditor";
|
||||
|
||||
export function ModelProvidersEditor() {
|
||||
return <ProviderProfilesEditor />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ManagedUser {
|
||||
username: string;
|
||||
role: "admin" | "user";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
async function readError(response: Response, fallback: string): Promise<Error> {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
message?: unknown;
|
||||
error?: unknown;
|
||||
} | null;
|
||||
const message =
|
||||
typeof body?.message === "string"
|
||||
? body.message
|
||||
: typeof body?.error === "string"
|
||||
? body.error
|
||||
: fallback;
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
/** Admin-only user management (design doc 7.2): list, create, change role,
|
||||
* reset password, delete. The backend enforces the last-admin guard. */
|
||||
export function UserManagementSection({ self }: { self: string }) {
|
||||
const [users, setUsers] = useState<ManagedUser[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newRole, setNewRole] = useState<"admin" | "user">("user");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/users", { cache: "no-store" });
|
||||
if (!response.ok) throw await readError(response, "Failed to load users.");
|
||||
const body = (await response.json()) as { users?: ManagedUser[] };
|
||||
setUsers(Array.isArray(body.users) ? body.users : []);
|
||||
setError(null);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Failed to load users.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const run = useCallback(
|
||||
async (action: () => Promise<Response>, success: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await action();
|
||||
if (!response.ok) throw await readError(response, "Request failed.");
|
||||
toast.success(success);
|
||||
await refresh();
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : "Request failed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const createUser = () =>
|
||||
run(
|
||||
() =>
|
||||
fetch("/api/auth/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: newUsername.trim(),
|
||||
password: newPassword,
|
||||
role: newRole,
|
||||
}),
|
||||
}),
|
||||
`User ${newUsername.trim()} created.`
|
||||
).then(() => {
|
||||
setNewUsername("");
|
||||
setNewPassword("");
|
||||
setNewRole("user");
|
||||
});
|
||||
|
||||
const toggleRole = (user: ManagedUser) =>
|
||||
run(
|
||||
() =>
|
||||
fetch(`/api/auth/users/${encodeURIComponent(user.username)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
role: user.role === "admin" ? "user" : "admin",
|
||||
}),
|
||||
}),
|
||||
`Role updated for ${user.username}.`
|
||||
);
|
||||
|
||||
const resetPassword = (user: ManagedUser) => {
|
||||
const password = window.prompt(
|
||||
`New password for ${user.username} (8-256 characters):`
|
||||
);
|
||||
if (!password) return;
|
||||
return run(
|
||||
() =>
|
||||
fetch(`/api/auth/users/${encodeURIComponent(user.username)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
`Password updated for ${user.username}.`
|
||||
);
|
||||
};
|
||||
|
||||
const removeUser = (user: ManagedUser) => {
|
||||
if (!window.confirm(`Delete user ${user.username}?`)) return;
|
||||
return run(
|
||||
() =>
|
||||
fetch(`/api/auth/users/${encodeURIComponent(user.username)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
`User ${user.username} deleted.`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="User management"
|
||||
className="rounded-md border border-border px-4 py-3"
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
User management (admin)
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ul className="mt-2 space-y-2">
|
||||
{(users ?? []).map((user) => (
|
||||
<li
|
||||
key={user.username}
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{user.username}
|
||||
{user.username === self && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">(you)</span>
|
||||
)}
|
||||
<span className="ml-2 rounded bg-accent px-1.5 py-0.5 text-xs font-normal text-muted-foreground">
|
||||
{user.role}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void toggleRole(user)}
|
||||
>
|
||||
{user.role === "admin" ? "Demote" : "Promote"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void resetPassword(user)}
|
||||
>
|
||||
Password
|
||||
</Button>
|
||||
{user.username !== self && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void removeUser(user)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{users && users.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground">No users yet.</li>
|
||||
)}
|
||||
{users === null && !error && (
|
||||
<li className="text-sm text-muted-foreground">Loading users…</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="mt-3 space-y-2 border-t border-border pt-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">Add user</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(event) => setNewUsername(event.target.value)}
|
||||
placeholder="Username"
|
||||
autoComplete="off"
|
||||
className="w-36 rounded-md border border-border bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
placeholder="Password (8+ chars)"
|
||||
autoComplete="new-password"
|
||||
className="w-40 rounded-md border border-border bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={(event) =>
|
||||
setNewRole(event.target.value === "admin" ? "admin" : "user")
|
||||
}
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || !newUsername.trim() || newPassword.length < 8}
|
||||
onClick={() => void createUser()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
export const LEGACY_LLM_VALUE_FIELDS = [
|
||||
"provider",
|
||||
"model",
|
||||
"model_fallbacks",
|
||||
"auxiliary_provider",
|
||||
"auxiliary_model",
|
||||
"anthropic_base_url",
|
||||
"anthropic_auth_mode",
|
||||
"openai_auth_mode",
|
||||
"minimax_base_url",
|
||||
"custom_openai_base_url",
|
||||
"custom_anthropic_base_url",
|
||||
"ollama_base_url",
|
||||
] as const;
|
||||
|
||||
export const LEGACY_LLM_SECRET_FIELDS = [
|
||||
"anthropic_api_key",
|
||||
"openai_api_key",
|
||||
"nvidia_api_key",
|
||||
"google_api_key",
|
||||
"minimax_api_key",
|
||||
"siliconflow_api_key",
|
||||
"openrouter_api_key",
|
||||
"deepseek_api_key",
|
||||
"zhipu_api_key",
|
||||
"volcengine_api_key",
|
||||
"dashscope_api_key",
|
||||
"moonshot_api_key",
|
||||
"kimi_api_key",
|
||||
"custom_openai_api_key",
|
||||
"custom_anthropic_api_key",
|
||||
] as const;
|
||||
|
||||
export type LegacyLlmValueField = (typeof LEGACY_LLM_VALUE_FIELDS)[number];
|
||||
export type LegacyLlmSecretField = (typeof LEGACY_LLM_SECRET_FIELDS)[number];
|
||||
|
||||
export type LegacyLlmValues = Record<LegacyLlmValueField, string>;
|
||||
|
||||
export interface BuiltinModelConfig {
|
||||
provider: string;
|
||||
id: string;
|
||||
name: string;
|
||||
model_id: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface BuiltinProviderModelConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
model_id: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface BuiltinProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
adapter: string;
|
||||
base_url: string;
|
||||
auth_mode: "api_key" | "oauth";
|
||||
enabled: boolean;
|
||||
managed: boolean;
|
||||
api_key_configured: boolean;
|
||||
api_key_hint: string | null;
|
||||
api_key?: string;
|
||||
clear_api_key?: boolean;
|
||||
models: BuiltinProviderModelConfig[];
|
||||
}
|
||||
|
||||
export interface DiscoveredBuiltinModel {
|
||||
model_id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BuiltinModelTestResponse {
|
||||
ok: true;
|
||||
latency_ms: number;
|
||||
response: string;
|
||||
}
|
||||
|
||||
export interface LegacySecretStatus {
|
||||
configured: boolean;
|
||||
hint: string | null;
|
||||
}
|
||||
|
||||
export interface LegacyLlmConfigResponse {
|
||||
revision: string;
|
||||
values: LegacyLlmValues;
|
||||
secrets: Record<LegacyLlmSecretField, LegacySecretStatus>;
|
||||
env_overrides: Partial<
|
||||
Record<LegacyLlmValueField | LegacyLlmSecretField, string>
|
||||
>;
|
||||
model_catalog: BuiltinModelConfig[] | null;
|
||||
model_catalog_error: string | null;
|
||||
builtin_providers: BuiltinProviderConfig[];
|
||||
builtin_model_candidates: BuiltinModelConfig[];
|
||||
changed_fields: string[];
|
||||
restart_required: boolean;
|
||||
}
|
||||
|
||||
export interface LegacyLlmConfigUpdate {
|
||||
revision: string;
|
||||
values: LegacyLlmValues;
|
||||
secrets: Partial<Record<LegacyLlmSecretField, string>>;
|
||||
clear_secrets: LegacyLlmSecretField[];
|
||||
model_catalog?: BuiltinModelConfig[] | null;
|
||||
builtin_providers?: BuiltinProviderConfig[];
|
||||
}
|
||||
|
||||
export interface BuiltinProviderDraft {
|
||||
id: string;
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
clear_api_key?: boolean;
|
||||
}
|
||||
|
||||
function endpoint(): string {
|
||||
return "/api/config";
|
||||
}
|
||||
|
||||
async function parseResponse(
|
||||
response: Response
|
||||
): Promise<LegacyLlmConfigResponse> {
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
error?: unknown;
|
||||
} & Partial<LegacyLlmConfigResponse>;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof body.error === "string"
|
||||
? body.error
|
||||
: `Built-in model configuration request failed (HTTP ${response.status}).`
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof body.revision !== "string" ||
|
||||
!body.values ||
|
||||
!body.secrets ||
|
||||
!body.env_overrides ||
|
||||
!(body.model_catalog === null || Array.isArray(body.model_catalog)) ||
|
||||
!Array.isArray(body.builtin_providers) ||
|
||||
!Array.isArray(body.builtin_model_candidates)
|
||||
) {
|
||||
throw new Error("Built-in model configuration response is invalid.");
|
||||
}
|
||||
return body as LegacyLlmConfigResponse;
|
||||
}
|
||||
|
||||
export async function fetchLegacyLlmConfig(): Promise<LegacyLlmConfigResponse> {
|
||||
return parseResponse(
|
||||
await fetch(endpoint(), {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveLegacyLlmConfig(
|
||||
update: LegacyLlmConfigUpdate
|
||||
): Promise<LegacyLlmConfigResponse> {
|
||||
return parseResponse(
|
||||
await fetch(endpoint(), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify(update),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function parseActionResponse<T>(response: Response): Promise<T> {
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
error?: unknown;
|
||||
} & T;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof body.error === "string"
|
||||
? body.error
|
||||
: `Built-in provider action failed (HTTP ${response.status}).`
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function discoverBuiltinProviderModels(
|
||||
provider: BuiltinProviderDraft
|
||||
): Promise<DiscoveredBuiltinModel[]> {
|
||||
const response = await parseActionResponse<{ models?: unknown }>(
|
||||
await fetch(endpoint(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ action: "list_models", provider }),
|
||||
})
|
||||
);
|
||||
if (!Array.isArray(response.models)) {
|
||||
throw new Error("Provider returned an invalid model list.");
|
||||
}
|
||||
return response.models.filter((model): model is DiscoveredBuiltinModel =>
|
||||
Boolean(
|
||||
model &&
|
||||
typeof model === "object" &&
|
||||
typeof (model as DiscoveredBuiltinModel).model_id === "string" &&
|
||||
typeof (model as DiscoveredBuiltinModel).name === "string"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function testBuiltinProviderModel(
|
||||
provider: BuiltinProviderDraft,
|
||||
model: BuiltinProviderModelConfig
|
||||
): Promise<BuiltinModelTestResponse> {
|
||||
const response = await parseActionResponse<Partial<BuiltinModelTestResponse>>(
|
||||
await fetch(endpoint(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ action: "test_model", provider, model }),
|
||||
})
|
||||
);
|
||||
if (
|
||||
response.ok !== true ||
|
||||
typeof response.latency_ms !== "number" ||
|
||||
typeof response.response !== "string"
|
||||
) {
|
||||
throw new Error("Provider returned an invalid model test response.");
|
||||
}
|
||||
return response as BuiltinModelTestResponse;
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
export const PROVIDER_ADAPTERS = [
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "anthropic", label: "Anthropic" },
|
||||
{ value: "google-genai", label: "Google GenAI" },
|
||||
{ value: "grok", label: "Grok / xAI" },
|
||||
{ value: "openrouter", label: "OpenRouter" },
|
||||
{ value: "nvidia", label: "NVIDIA" },
|
||||
{
|
||||
value: "antigravity",
|
||||
label: "Antigravity proxy (OpenAI compatible)",
|
||||
},
|
||||
{ value: "openai-compatible", label: "OpenAI compatible" },
|
||||
{ value: "anthropic-compatible", label: "Anthropic compatible" },
|
||||
{ value: "ollama", label: "Ollama" },
|
||||
] as const;
|
||||
|
||||
// Fallback for WebUIs connected to a backend that predates the capability
|
||||
// field. Current backends return the authoritative list with every profile
|
||||
// response.
|
||||
export const BUILT_IN_PROVIDER_IDS = [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"google-genai",
|
||||
"minimax",
|
||||
"nvidia",
|
||||
"siliconflow",
|
||||
"openrouter",
|
||||
"zhipu",
|
||||
"zhipu-code",
|
||||
"volcengine",
|
||||
"dashscope",
|
||||
"dashscope-code",
|
||||
"deepseek",
|
||||
"moonshot",
|
||||
"kimi-coding",
|
||||
"custom-openai",
|
||||
"custom-anthropic",
|
||||
"ollama",
|
||||
] as const;
|
||||
|
||||
export type ProviderAdapter = (typeof PROVIDER_ADAPTERS)[number]["value"];
|
||||
|
||||
export interface ProviderRuntimeConfig {
|
||||
timeout_seconds: number;
|
||||
max_retries: number;
|
||||
default_temperature: number | null;
|
||||
default_top_p: number | null;
|
||||
default_reasoning_effort: "auto" | "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
export interface ModelRuntimeConfig {
|
||||
limit_mode: "combined" | "input_only" | null;
|
||||
context_window_tokens: number | null;
|
||||
max_input_tokens: number | null;
|
||||
max_output_tokens: number;
|
||||
min_effective_input_tokens: number;
|
||||
limits_status: "confirmed" | "needs_confirmation";
|
||||
limits_source: "catalog" | "provider" | "user";
|
||||
temperature: number | null;
|
||||
top_p: number | null;
|
||||
reasoning_effort: "auto" | "low" | "medium" | "high";
|
||||
capabilities: {
|
||||
tools: boolean | "auto";
|
||||
vision: boolean | "auto";
|
||||
structured_output: boolean | "auto";
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_PROVIDER_RUNTIME: ProviderRuntimeConfig = {
|
||||
timeout_seconds: 120,
|
||||
max_retries: 2,
|
||||
default_temperature: null,
|
||||
default_top_p: null,
|
||||
default_reasoning_effort: "auto",
|
||||
};
|
||||
|
||||
export const DEFAULT_MODEL_RUNTIME: ModelRuntimeConfig = {
|
||||
limit_mode: null,
|
||||
context_window_tokens: null,
|
||||
max_input_tokens: null,
|
||||
max_output_tokens: 4096,
|
||||
min_effective_input_tokens: 4096,
|
||||
limits_status: "needs_confirmation",
|
||||
limits_source: "user",
|
||||
temperature: null,
|
||||
top_p: null,
|
||||
reasoning_effort: "auto",
|
||||
capabilities: {
|
||||
tools: "auto",
|
||||
vision: "auto",
|
||||
structured_output: "auto",
|
||||
},
|
||||
};
|
||||
|
||||
export interface ProviderModelConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
model_id: string;
|
||||
enabled: boolean;
|
||||
runtime: ModelRuntimeConfig;
|
||||
}
|
||||
|
||||
export interface ProviderProfileConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
adapter: ProviderAdapter;
|
||||
base_url: string;
|
||||
enabled: boolean;
|
||||
api_key_configured: boolean;
|
||||
api_key_hint: string | null;
|
||||
runtime: ProviderRuntimeConfig;
|
||||
api_key?: string;
|
||||
clear_api_key?: boolean;
|
||||
models: ProviderModelConfig[];
|
||||
}
|
||||
|
||||
export interface ProviderProfilesResponse {
|
||||
version: number;
|
||||
revision: string;
|
||||
reserved_provider_ids: string[];
|
||||
providers: ProviderProfileConfig[];
|
||||
}
|
||||
|
||||
export interface DiscoveredProviderModel {
|
||||
model_id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProviderModelTestResponse {
|
||||
ok: true;
|
||||
latency_ms: number;
|
||||
response: string;
|
||||
}
|
||||
|
||||
async function parseResponse(
|
||||
response: Response
|
||||
): Promise<ProviderProfilesResponse> {
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
error?: unknown;
|
||||
} & Partial<ProviderProfilesResponse>;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof body.error === "string"
|
||||
? body.error
|
||||
: `Provider configuration request failed (HTTP ${response.status}).`
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(body.providers)) {
|
||||
throw new Error("Provider configuration response is invalid.");
|
||||
}
|
||||
const reservedProviderIds = Array.isArray(body.reserved_provider_ids)
|
||||
? body.reserved_provider_ids.filter(
|
||||
(providerId): providerId is string => typeof providerId === "string"
|
||||
)
|
||||
: [...BUILT_IN_PROVIDER_IDS];
|
||||
return {
|
||||
...body,
|
||||
providers: body.providers.map(normalizeProviderProfile),
|
||||
reserved_provider_ids: reservedProviderIds,
|
||||
} as ProviderProfilesResponse;
|
||||
}
|
||||
|
||||
function endpoint(): string {
|
||||
return "/api/provider-profiles";
|
||||
}
|
||||
|
||||
function actionEndpoint(): string {
|
||||
return "/api/provider-actions";
|
||||
}
|
||||
|
||||
function providerDraft(provider: ProviderProfileConfig) {
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
adapter: provider.adapter,
|
||||
base_url: provider.base_url,
|
||||
enabled: provider.enabled,
|
||||
api_key: provider.api_key ?? "",
|
||||
clear_api_key: provider.clear_api_key ?? false,
|
||||
runtime: provider.runtime,
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProviderRuntime(value: unknown): ProviderRuntimeConfig {
|
||||
if (!value || typeof value !== "object")
|
||||
return { ...DEFAULT_PROVIDER_RUNTIME };
|
||||
const raw = value as Partial<ProviderRuntimeConfig>;
|
||||
return {
|
||||
timeout_seconds:
|
||||
typeof raw.timeout_seconds === "number"
|
||||
? raw.timeout_seconds
|
||||
: DEFAULT_PROVIDER_RUNTIME.timeout_seconds,
|
||||
max_retries:
|
||||
typeof raw.max_retries === "number"
|
||||
? raw.max_retries
|
||||
: DEFAULT_PROVIDER_RUNTIME.max_retries,
|
||||
default_temperature:
|
||||
typeof raw.default_temperature === "number"
|
||||
? raw.default_temperature
|
||||
: null,
|
||||
default_top_p:
|
||||
typeof raw.default_top_p === "number" ? raw.default_top_p : null,
|
||||
default_reasoning_effort:
|
||||
raw.default_reasoning_effort === "low" ||
|
||||
raw.default_reasoning_effort === "medium" ||
|
||||
raw.default_reasoning_effort === "high"
|
||||
? raw.default_reasoning_effort
|
||||
: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModelRuntime(value: unknown): ModelRuntimeConfig {
|
||||
if (!value || typeof value !== "object") return { ...DEFAULT_MODEL_RUNTIME };
|
||||
const raw = value as Partial<ModelRuntimeConfig>;
|
||||
const capabilities = raw.capabilities;
|
||||
const capabilityValue = (key: keyof ModelRuntimeConfig["capabilities"]) => {
|
||||
const candidate = capabilities?.[key];
|
||||
return candidate === true || candidate === false ? candidate : "auto";
|
||||
};
|
||||
return {
|
||||
limit_mode:
|
||||
raw.limit_mode === "combined" || raw.limit_mode === "input_only"
|
||||
? raw.limit_mode
|
||||
: null,
|
||||
context_window_tokens:
|
||||
typeof raw.context_window_tokens === "number"
|
||||
? raw.context_window_tokens
|
||||
: null,
|
||||
max_input_tokens:
|
||||
typeof raw.max_input_tokens === "number" ? raw.max_input_tokens : null,
|
||||
max_output_tokens:
|
||||
typeof raw.max_output_tokens === "number"
|
||||
? raw.max_output_tokens
|
||||
: DEFAULT_MODEL_RUNTIME.max_output_tokens,
|
||||
min_effective_input_tokens:
|
||||
typeof raw.min_effective_input_tokens === "number"
|
||||
? raw.min_effective_input_tokens
|
||||
: DEFAULT_MODEL_RUNTIME.min_effective_input_tokens,
|
||||
limits_status:
|
||||
raw.limits_status === "confirmed" ? "confirmed" : "needs_confirmation",
|
||||
limits_source:
|
||||
raw.limits_source === "catalog" || raw.limits_source === "provider"
|
||||
? raw.limits_source
|
||||
: "user",
|
||||
temperature: typeof raw.temperature === "number" ? raw.temperature : null,
|
||||
top_p: typeof raw.top_p === "number" ? raw.top_p : null,
|
||||
reasoning_effort:
|
||||
raw.reasoning_effort === "low" ||
|
||||
raw.reasoning_effort === "medium" ||
|
||||
raw.reasoning_effort === "high"
|
||||
? raw.reasoning_effort
|
||||
: "auto",
|
||||
capabilities: {
|
||||
tools: capabilityValue("tools"),
|
||||
vision: capabilityValue("vision"),
|
||||
structured_output: capabilityValue("structured_output"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProviderProfile(
|
||||
value: ProviderProfileConfig
|
||||
): ProviderProfileConfig {
|
||||
return {
|
||||
...value,
|
||||
runtime: normalizeProviderRuntime(value.runtime),
|
||||
models: value.models.map((model) => ({
|
||||
...model,
|
||||
runtime: normalizeModelRuntime(model.runtime),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function parseActionResponse<T>(response: Response): Promise<T> {
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
error?: unknown;
|
||||
} & T;
|
||||
if (!response.ok) {
|
||||
if (response.status === 404 && typeof body.error !== "string") {
|
||||
throw new Error(
|
||||
"Provider actions are unavailable on the current EvoScientist backend. Restart or upgrade the backend, then try again."
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
typeof body.error === "string"
|
||||
? body.error
|
||||
: `Provider action failed (HTTP ${response.status}).`
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function fetchProviderProfiles(): Promise<ProviderProfilesResponse> {
|
||||
return parseResponse(
|
||||
await fetch(endpoint(), {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveProviderProfiles(
|
||||
providers: ProviderProfileConfig[]
|
||||
): Promise<ProviderProfilesResponse> {
|
||||
return parseResponse(
|
||||
await fetch(endpoint(), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ providers }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function discoverProviderModels(
|
||||
provider: ProviderProfileConfig
|
||||
): Promise<DiscoveredProviderModel[]> {
|
||||
const response = await parseActionResponse<{
|
||||
models?: unknown;
|
||||
}>(
|
||||
await fetch(actionEndpoint(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
action: "list_models",
|
||||
provider: providerDraft(provider),
|
||||
}),
|
||||
})
|
||||
);
|
||||
if (!Array.isArray(response.models)) {
|
||||
throw new Error("Provider returned an invalid model list.");
|
||||
}
|
||||
return response.models.filter((model): model is DiscoveredProviderModel =>
|
||||
Boolean(
|
||||
model &&
|
||||
typeof model === "object" &&
|
||||
typeof (model as DiscoveredProviderModel).model_id === "string" &&
|
||||
typeof (model as DiscoveredProviderModel).name === "string"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function testProviderModel(
|
||||
provider: ProviderProfileConfig,
|
||||
model: ProviderModelConfig
|
||||
): Promise<ProviderModelTestResponse> {
|
||||
const response = await parseActionResponse<
|
||||
Partial<ProviderModelTestResponse>
|
||||
>(
|
||||
await fetch(actionEndpoint(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
action: "test_model",
|
||||
provider: providerDraft(provider),
|
||||
model,
|
||||
}),
|
||||
})
|
||||
);
|
||||
if (
|
||||
response.ok !== true ||
|
||||
typeof response.latency_ms !== "number" ||
|
||||
typeof response.response !== "string"
|
||||
) {
|
||||
throw new Error("Provider returned an invalid model test response.");
|
||||
}
|
||||
return response as ProviderModelTestResponse;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type {
|
||||
AdapterParameterSpec,
|
||||
AuthMode,
|
||||
GetModelRegistryResponse,
|
||||
ModelAvailability,
|
||||
ModelConfig,
|
||||
ModelRef,
|
||||
ProviderConfig,
|
||||
RegistryV4,
|
||||
} from "@/lib/modelRegistry";
|
||||
|
||||
export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
|
||||
export function adapterSpecFor(
|
||||
specs: AdapterParameterSpec[],
|
||||
adapterId: string
|
||||
): AdapterParameterSpec | null {
|
||||
// Model-specific contracts (model_selector !== "*") win over the generic
|
||||
// contract, mirroring the backend resolver.
|
||||
return (
|
||||
specs.find(
|
||||
(spec) => spec.adapter_id === adapterId && spec.model_selector !== "*"
|
||||
) ??
|
||||
specs.find(
|
||||
(spec) => spec.adapter_id === adapterId && spec.model_selector === "*"
|
||||
) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function supportedAuthModes(spec: AdapterParameterSpec | null): AuthMode[] {
|
||||
if (!spec) return ["none"];
|
||||
const modes = (Object.keys(spec.auth_specs) as AuthMode[]).filter(
|
||||
(mode) => spec.auth_specs[mode]
|
||||
);
|
||||
return modes.length > 0 ? modes : ["none"];
|
||||
}
|
||||
|
||||
export function modelRefKey(ref: ModelRef): string {
|
||||
return `${ref.provider_id}/${ref.model_key}`;
|
||||
}
|
||||
|
||||
export function availabilityFor(
|
||||
data: GetModelRegistryResponse,
|
||||
ref: ModelRef
|
||||
): ModelAvailability | null {
|
||||
return (
|
||||
data.model_status.find(
|
||||
(status) =>
|
||||
status.model_ref.provider_id === ref.provider_id &&
|
||||
status.model_ref.model_key === ref.model_key
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function credentialStatusFor(
|
||||
data: GetModelRegistryResponse,
|
||||
credentialId: string | null
|
||||
) {
|
||||
if (!credentialId) return null;
|
||||
return (
|
||||
data.credential_status.find(
|
||||
(status) => status.credential_id === credentialId
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function enabledModelRefs(registry: RegistryV4): Array<{
|
||||
ref: ModelRef;
|
||||
label: string;
|
||||
}> {
|
||||
const out: Array<{ ref: ModelRef; label: string }> = [];
|
||||
for (const provider of registry.providers) {
|
||||
if (!provider.enabled) continue;
|
||||
for (const model of provider.models) {
|
||||
if (!model.enabled) continue;
|
||||
const ref = { provider_id: provider.id, model_key: model.key };
|
||||
out.push({ ref, label: `${model.name} (${modelRefKey(ref)})` });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function newProviderDraft(
|
||||
specs: AdapterParameterSpec[],
|
||||
existingIds: Set<string>
|
||||
): ProviderConfig {
|
||||
let index = existingIds.size + 1;
|
||||
let id = `provider-${index}`;
|
||||
while (existingIds.has(id)) {
|
||||
index += 1;
|
||||
id = `provider-${index}`;
|
||||
}
|
||||
const adapter = specs[0]?.adapter_id ?? "openai-compatible";
|
||||
const spec = adapterSpecFor(specs, adapter);
|
||||
const authMode = supportedAuthModes(spec)[0] ?? "none";
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
adapter,
|
||||
base_url: "",
|
||||
auth: {
|
||||
mode: authMode,
|
||||
credential_id: authMode === "none" ? null : `${id}-credential`,
|
||||
},
|
||||
enabled: true,
|
||||
runtime: {
|
||||
timeout_seconds: 120,
|
||||
max_retries: 2,
|
||||
default_temperature: null,
|
||||
default_top_p: null,
|
||||
default_reasoning_effort: "auto",
|
||||
},
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function newModelDraft(key: string): ModelConfig {
|
||||
return {
|
||||
key,
|
||||
name: key,
|
||||
upstream_model_id: key,
|
||||
enabled: false,
|
||||
runtime: {
|
||||
limit_mode: "combined",
|
||||
context_window_tokens: 128000,
|
||||
max_input_tokens: null,
|
||||
max_output_tokens: 8192,
|
||||
min_effective_input_tokens: 8192,
|
||||
fixed_system_reserve_tokens: 2048,
|
||||
fixed_tools_reserve_tokens: 4096,
|
||||
fixed_attachments_reserve_tokens: 8192,
|
||||
limits_status: "needs_confirmation",
|
||||
limits_source: "user",
|
||||
temperature: null,
|
||||
top_p: null,
|
||||
reasoning_effort: "auto",
|
||||
declared_capabilities: {
|
||||
tools: true,
|
||||
vision: false,
|
||||
structured_output: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registryDirty(
|
||||
draft: RegistryV4,
|
||||
saved: RegistryV4,
|
||||
credentialWrites: ReadonlyMap<string, string>
|
||||
): boolean {
|
||||
return (
|
||||
credentialWrites.size > 0 ||
|
||||
JSON.stringify(draft) !== JSON.stringify(saved)
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { isAuthenticationEnabled } from "@/lib/auth";
|
||||
import { getActiveDeployment } from "@/lib/server/activeDeployment";
|
||||
import { isCrossOrigin } from "@/lib/server/workspace";
|
||||
|
||||
interface AdminProxyOptions {
|
||||
upstreamPath: string;
|
||||
requestLabel: string;
|
||||
maxBodyBytes: number;
|
||||
}
|
||||
|
||||
let cachedLocalToken: { path: string; token: string } | null = null;
|
||||
|
||||
function isLoopback(hostname: string): boolean {
|
||||
const normalized = hostname.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "localhost" ||
|
||||
normalized === "::1" ||
|
||||
normalized === "127.0.0.1" ||
|
||||
normalized.startsWith("127.")
|
||||
);
|
||||
}
|
||||
|
||||
function fail(error: unknown, requestLabel: string, status = 400) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
typeof error === "string"
|
||||
? error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: `${requestLabel} request failed.`,
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveBackendUrl(): Promise<string> {
|
||||
return (await getActiveDeployment()).langgraphApiUrl.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function localAdminTokenPath(): string {
|
||||
const configRoot = process.env.XDG_CONFIG_HOME?.trim();
|
||||
return configRoot
|
||||
? join(configRoot, "evoscientist", "provider-admin-token")
|
||||
: join(homedir(), ".config", "evoscientist", "provider-admin-token");
|
||||
}
|
||||
|
||||
function readTokenFile(path: string): string {
|
||||
try {
|
||||
return readFileSync(path, "utf8").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAdminToken(): string {
|
||||
const explicit = process.env.EVOSCIENTIST_PROVIDER_ADMIN_TOKEN?.trim();
|
||||
if (explicit) return explicit;
|
||||
|
||||
const path = localAdminTokenPath();
|
||||
if (cachedLocalToken?.path === path) return cachedLocalToken.token;
|
||||
|
||||
let token = readTokenFile(path);
|
||||
if (!token) {
|
||||
token = randomBytes(32).toString("base64url");
|
||||
try {
|
||||
const configDirectory = dirname(path);
|
||||
mkdirSync(configDirectory, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
chmodSync(configDirectory, 0o700);
|
||||
} catch {
|
||||
// Best effort on filesystems without POSIX permission support.
|
||||
}
|
||||
const descriptor = openSync(path, "wx", 0o600);
|
||||
try {
|
||||
writeFileSync(descriptor, `${token}\n`, "utf8");
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
} catch {
|
||||
const existing = readTokenFile(path);
|
||||
if (!existing) return "";
|
||||
token = existing;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
chmodSync(path, 0o600);
|
||||
} catch {
|
||||
// Best effort on filesystems without POSIX permission support.
|
||||
}
|
||||
cachedLocalToken = { path, token };
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Return the local backend-admin token for server-side BFF calls only. */
|
||||
export function getEvoScientistAdminToken(): string {
|
||||
return resolveAdminToken();
|
||||
}
|
||||
|
||||
export async function proxyEvoScientistAdminRequest(
|
||||
request: NextRequest,
|
||||
options: AdminProxyOptions
|
||||
) {
|
||||
try {
|
||||
if (isCrossOrigin(request)) {
|
||||
return fail(
|
||||
"Cross-origin model configuration is not allowed.",
|
||||
options.requestLabel,
|
||||
403
|
||||
);
|
||||
}
|
||||
if (!isAuthenticationEnabled() && !isLoopback(request.nextUrl.hostname)) {
|
||||
return fail(
|
||||
"Model configuration on a non-local WebUI requires authentication.",
|
||||
options.requestLabel,
|
||||
403
|
||||
);
|
||||
}
|
||||
const token = resolveAdminToken();
|
||||
if (!token) {
|
||||
return fail(
|
||||
"Model configuration is not enabled for this WebUI server.",
|
||||
options.requestLabel,
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"X-EvoScientist-Admin-Token": token,
|
||||
};
|
||||
let body: string | undefined;
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = await request.text();
|
||||
if (body.length > options.maxBodyBytes) {
|
||||
return fail(
|
||||
`${options.requestLabel} is too large.`,
|
||||
options.requestLabel,
|
||||
413
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const backendUrl = await resolveBackendUrl();
|
||||
const upstream = await fetch(`${backendUrl}${options.upstreamPath}`, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
const responseBody = await upstream.text();
|
||||
return new NextResponse(responseBody, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
upstream.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(error, options.requestLabel);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user