Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions apps/dashboard/app/(dashboard)/gateway/gateway-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@
import { useEffect, useState, type ReactNode } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

export type GatewayTab = "overview" | "keys" | "logs";
export type GatewayTab = "overview" | "models" | "keys" | "logs";

export function GatewayTabs({
admin,
activeTab,
overview,
models,
keys,
logs,
}: {
admin: boolean;
activeTab: GatewayTab;
overview: ReactNode;
models: ReactNode;
keys: ReactNode;
logs: ReactNode;
}) {
Expand All @@ -24,7 +26,7 @@ export function GatewayTabs({

function selectTab(next: string | number) {
if (!admin && next !== "keys") return;
if (next !== "overview" && next !== "keys" && next !== "logs") return;
if (next !== "overview" && next !== "models" && next !== "keys" && next !== "logs") return;
setTab(next);
const url = new URL(window.location.href);
if (next === "overview") url.searchParams.delete("tab");
Expand All @@ -37,11 +39,13 @@ export function GatewayTabs({
<div className="overflow-x-auto border-b">
<TabsList variant="line" className="h-10 min-w-max gap-5 px-1">
{admin && <TabsTrigger value="overview" className="flex-none px-2">Overview</TabsTrigger>}
{admin && <TabsTrigger value="models" className="flex-none px-2">Models</TabsTrigger>}
<TabsTrigger value="keys" className="flex-none px-2">Key</TabsTrigger>
{admin && <TabsTrigger value="logs" className="flex-none px-2">Logs</TabsTrigger>}
</TabsList>
</div>
{admin && <TabsContent value="overview">{overview}</TabsContent>}
{admin && <TabsContent value="models">{models}</TabsContent>}
<TabsContent value="keys">{keys}</TabsContent>
{admin && <TabsContent value="logs">{logs}</TabsContent>}
</Tabs>
Expand Down
375 changes: 375 additions & 0 deletions apps/dashboard/app/(dashboard)/gateway/model-catalog.tsx

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion apps/dashboard/app/(dashboard)/gateway/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import { RefreshButton } from "./refresh-button";
import { GatewayTabs, type GatewayTab } from "./gateway-tabs";
import { ProxyHealthRow } from "./proxy-health-row";
import { GatewayKeyForm } from "./gateway-key-form";
import { ModelCatalog } from "./model-catalog";
import {
resolveGatewayModelsTab,
type GatewayModelsResource,
} from "@/lib/gateway-models";

type GatewayStatus = {
enabled: boolean;
Expand Down Expand Up @@ -126,11 +131,12 @@ function resultBadge(result: RequestLog["result"]) {
export default async function GatewayPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const [raw, me] = await Promise.all([searchParams, requireIdentity()]);
const requestedTab = value(raw, "tab");
const activeModelsTab = resolveGatewayModelsTab(value(raw, "models_tab"));
const hasLogQuery = ["q", "user_id", "profile_id", "model", "harness", "result", "occurred_from", "occurred_to", "sort", "page", "per_page"].some((key) => Boolean(value(raw, key)));
if (me.role !== "admin" && ((requestedTab && requestedTab !== "keys") || hasLogQuery)) notFound();
const activeTab: GatewayTab = me.role !== "admin"
? "keys"
: requestedTab === "keys" || requestedTab === "logs"
: requestedTab === "models" || requestedTab === "keys" || requestedTab === "logs"
? requestedTab
: hasLogQuery
? "logs"
Expand Down Expand Up @@ -158,6 +164,13 @@ export default async function GatewayPage({ searchParams }: { searchParams: Prom
const status = await api<GatewayStatus>("/gateway/status");
if (!status.enabled) notFound();

let gatewayModels: GatewayModelsResource | undefined;
let gatewayModelsError: string | undefined;
if (me.role === "admin") {
try { gatewayModels = await api<GatewayModelsResource>("/admin/gateway/models"); }
catch (error) { gatewayModelsError = errorMessage(error, "Gateway models could not be loaded."); }
}

let access: GatewayAccess | undefined;
let accessError: string | undefined;
let validationError: string | undefined;
Expand Down Expand Up @@ -233,6 +246,9 @@ export default async function GatewayPage({ searchParams }: { searchParams: Prom
</Table>
</section>
)}
models={gatewayModels ? <ModelCatalog resource={gatewayModels} activeTab={activeModelsTab} /> : (
<Alert variant="destructive"><AlertCircle /><AlertTitle>Model catalog unavailable</AlertTitle><AlertDescription>{gatewayModelsError}</AlertDescription></Alert>
)}
keys={(
<section aria-labelledby="gateway-keys" className="overflow-hidden rounded-xl border">
<h2 id="gateway-keys" className="sr-only">Managed gateway key</h2>
Expand Down
78 changes: 78 additions & 0 deletions apps/dashboard/app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { auth, authPool } from "../lib/auth";
import { randomUUID } from "crypto";
import { identityConfig } from "../lib/identity-config";
import type { Branding } from "../lib/branding";
import type { GatewayModelsResource } from "../lib/gateway-models";

export async function logout() {
await auth.api.signOut({ headers: await headers() });
Expand Down Expand Up @@ -190,6 +191,83 @@ export async function checkGatewayProxyHealth(
}
}

export type GatewayModelsActionState = {
saved?: boolean;
error?: string;
revision?: string;
resource?: GatewayModelsResource;
};

function actionError(error: unknown, fallback: string) {
if (!(error instanceof Error)) return fallback;
const detail = error.message.replace(/^\d{3}:\s*/, "");
try {
const parsed = JSON.parse(detail) as { error?: string };
return parsed.error || fallback;
} catch {
return detail || fallback;
}
}

export async function refreshGatewayModels(
_: GatewayModelsActionState,
): Promise<GatewayModelsActionState> {
try {
const resource = await api<GatewayModelsResource>("/admin/gateway/models/refresh", {
method: "POST",
});
revalidatePath("/gateway");
return { saved: true, resource, revision: resource.revision };
} catch (error) {
return { error: actionError(error, "Gateway models could not be refreshed.") };
}
}

export async function saveHarnessGatewayModels(
_: GatewayModelsActionState,
form: FormData,
): Promise<GatewayModelsActionState> {
const harness = String(form.get("harness") ?? "");
try {
const saved = await api<{ revision: string }>(
`/admin/gateway/models/harnesses/${encodeURIComponent(harness)}`,
{
method: "PUT",
body: JSON.stringify({
base_revision: String(form.get("revision") ?? ""),
gateway_models: JSON.parse(String(form.get("gateway_models") ?? "[]")),
default_model: String(form.get("default_model") ?? "") || null,
}),
},
);
revalidatePath("/gateway");
revalidatePath("/harnesses");
revalidatePath("/", "layout");
return { saved: true, revision: saved.revision };
} catch (error) {
return { error: actionError(error, "Gateway model assignments could not be saved.") };
}
}

export async function acknowledgeGatewayModel(
_: GatewayModelsActionState,
form: FormData,
): Promise<GatewayModelsActionState> {
try {
await api("/admin/gateway/models/acknowledge", {
method: "POST",
body: JSON.stringify({
model_id: String(form.get("model_id") ?? ""),
fingerprint: String(form.get("fingerprint") ?? ""),
}),
});
revalidatePath("/gateway");
return { saved: true };
} catch (error) {
return { error: actionError(error, "Model change could not be acknowledged.") };
}
}

export async function saveExtensions(
_: ConfigState,
form: FormData,
Expand Down
26 changes: 26 additions & 0 deletions apps/dashboard/lib/gateway-model-compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { gatewayModelIssue, type GatewayModelCompatibilityInput } from "./gateway-model-compatibility.ts";

const base: GatewayModelCompatibilityInput = {
state: "available",
discoverySupported: true,
exposure: "catalog",
selected: false,
selectedAssigned: true,
};

test("available full catalogs have no issue", () => assert.equal(gatewayModelIssue(base), null));
test("classifies drift and selected-model failures", () => {
assert.equal(gatewayModelIssue({ ...base, state: "changed" })?.code, "model_changed");
assert.equal(gatewayModelIssue({ ...base, state: "removed" })?.code, "model_removed");
assert.equal(gatewayModelIssue({ ...base, selected: true, selectedAssigned: false })?.code, "invalid_selected_model");
});
test("outages remain unknown instead of removed", () => {
assert.equal(gatewayModelIssue({ ...base, discoverySupported: false })?.code, "discovery_unsupported");
assert.equal(gatewayModelIssue({ ...base, refreshError: "timeout" })?.code, "refresh_failed");
assert.equal(gatewayModelIssue({ ...base, stale: true })?.code, "catalog_stale");
});
test("reports selected-only harness exposure", () => {
assert.equal(gatewayModelIssue({ ...base, exposure: "selected_only" })?.code, "selected_only");
});
51 changes: 51 additions & 0 deletions apps/dashboard/lib/gateway-model-compatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export type GatewayModelState = "available" | "changed" | "removed" | "unknown";

export type GatewayModelCompatibilityInput = {
state: GatewayModelState;
discoverySupported: boolean | null;
refreshError?: string | null;
stale?: boolean;
exposure: "catalog" | "selected_only";
selected: boolean;
selectedAssigned: boolean;
};

export type GatewayModelIssue = {
severity: "warning" | "error";
code:
| "discovery_unsupported"
| "refresh_failed"
| "catalog_stale"
| "model_changed"
| "model_removed"
| "selected_only"
| "invalid_selected_model";
message: string;
};

export function gatewayModelIssue(
input: GatewayModelCompatibilityInput,
): GatewayModelIssue | null {
if (input.selected && !input.selectedAssigned) {
return { severity: "error", code: "invalid_selected_model", message: "The selected model is not assigned to this harness." };
}
if (input.discoverySupported === false) {
return { severity: "warning", code: "discovery_unsupported", message: "This gateway provisioner does not support model discovery." };
}
if (input.refreshError) {
return { severity: "warning", code: "refresh_failed", message: "The latest refresh failed; showing the last-known catalog." };
}
if (input.stale) {
return { severity: "warning", code: "catalog_stale", message: "The gateway model catalog is stale." };
}
if (input.state === "changed") {
return { severity: input.selected ? "error" : "warning", code: "model_changed", message: "The gateway reports materially changed model metadata." };
}
if (input.state === "removed") {
return { severity: input.selected ? "error" : "warning", code: "model_removed", message: "The model is absent from the latest successful refresh." };
}
if (input.exposure === "selected_only") {
return { severity: "warning", code: "selected_only", message: "This harness exposes only its selected gateway model." };
}
return null;
}
61 changes: 61 additions & 0 deletions apps/dashboard/lib/gateway-models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
canEditGatewayModelAssignments,
gatewayModelAssignmentReadOnlyReason,
gatewayModelFallbackLabel,
modelStateLabel,
modelStateWarning,
resolveGatewayModelsTab,
} from "./gateway-models.ts";

const harness = {
key: "codex",
label: "Codex",
exposure: "selected_only" as const,
gateway_models: ["gpt-first", "gpt-second"],
default_model: "gpt-second",
};

test("gateway models child tabs default safely", () => {
assert.equal(resolveGatewayModelsTab(), "discovery");
assert.equal(resolveGatewayModelsTab("discovery"), "discovery");
assert.equal(resolveGatewayModelsTab("catalog"), "catalog");
assert.equal(resolveGatewayModelsTab("assignments"), "assignments");
assert.equal(resolveGatewayModelsTab("unknown"), "discovery");
});

test("gateway model states have stable labels", () => {
assert.equal(modelStateLabel("available"), "Available");
assert.equal(modelStateLabel("changed"), "Changed");
assert.equal(modelStateLabel("removed"), "Removed");
assert.equal(modelStateLabel("unknown"), "Unknown");
});

test("only actionable model states produce warnings", () => {
assert.equal(modelStateWarning("available"), null);
assert.match(modelStateWarning("changed") ?? "", /metadata changed/);
assert.match(modelStateWarning("removed") ?? "", /absent/);
assert.match(modelStateWarning("unknown") ?? "", /not been returned/);
});

test("selected-only harness assignments are read-only while catalog harnesses are editable", () => {
assert.equal(canEditGatewayModelAssignments(harness), false);
assert.equal(
gatewayModelAssignmentReadOnlyReason(harness),
"Full catalog assignment is unavailable for Codex.",
);
for (const key of ["claude", "kimi", "opencode"]) {
const catalogHarness = { ...harness, key, label: key, exposure: "catalog" as const };
assert.equal(canEditGatewayModelAssignments(catalogHarness), true);
assert.equal(gatewayModelAssignmentReadOnlyReason(catalogHarness), null);
}
});

test("assignment rows retain selected defaults and catalog fallback labels", () => {
assert.equal(gatewayModelFallbackLabel(harness), "gpt-second");
assert.equal(
gatewayModelFallbackLabel({ ...harness, key: "kimi", exposure: "catalog", default_model: null }),
"First assigned fallback: gpt-first",
);
});
Loading
Loading