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:
m4
2026-07-21 19:02:17 +08:00
parent 862cd4922e
commit 9f702e230d
16 changed files with 1566 additions and 4941 deletions
-34
View File
@@ -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);
}
-12
View File
@@ -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,
});
}
-12
View File
@@ -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,
});
}
-20
View File
@@ -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);
}
+23 -9
View File
@@ -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
+6 -15
View File
@@ -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>
);
}
-227
View File
@@ -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;
}
-371
View File
@@ -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;
}
+156
View File
@@ -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)
);
}
-175
View File
@@ -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);
}
}