diff --git a/Cargo.lock b/Cargo.lock
index 766a86d..b9ea798 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1622,12 +1622,14 @@ name = "gh-gateway-provisioner"
version = "0.2.0"
dependencies = [
"async-trait",
+ "axum",
"hex",
"reqwest",
"serde",
"serde_json",
"sha2 0.10.9",
"thiserror 1.0.69",
+ "tokio",
"tracing",
"zeroize",
]
diff --git a/apps/dashboard/app/(dashboard)/gateway/gateway-tabs.tsx b/apps/dashboard/app/(dashboard)/gateway/gateway-tabs.tsx
index 057b51c..5f71289 100644
--- a/apps/dashboard/app/(dashboard)/gateway/gateway-tabs.tsx
+++ b/apps/dashboard/app/(dashboard)/gateway/gateway-tabs.tsx
@@ -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;
}) {
@@ -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");
@@ -37,11 +39,13 @@ export function GatewayTabs({
{admin && Overview }
+ {admin && Models }
Key
{admin && Logs }
{admin && {overview} }
+ {admin && {models} }
{keys}
{admin && {logs} }
diff --git a/apps/dashboard/app/(dashboard)/gateway/model-catalog.tsx b/apps/dashboard/app/(dashboard)/gateway/model-catalog.tsx
new file mode 100644
index 0000000..b1fb363
--- /dev/null
+++ b/apps/dashboard/app/(dashboard)/gateway/model-catalog.tsx
@@ -0,0 +1,375 @@
+"use client";
+
+import { useActionState, useEffect, useMemo, useState } from "react";
+import { AlertCircle, Check, Plus, RefreshCw, Search } from "lucide-react";
+import { useRouter } from "next/navigation";
+import {
+ acknowledgeGatewayModel,
+ refreshGatewayModels,
+ saveHarnessGatewayModels,
+ type GatewayModelsActionState,
+} from "@/app/actions";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ canEditGatewayModelAssignments,
+ gatewayModelAssignmentReadOnlyReason,
+ gatewayModelFallbackLabel,
+ modelStateLabel,
+ modelStateWarning,
+ type GatewayModel,
+ type GatewayModelHarness,
+ type GatewayModelsResource,
+ type GatewayModelsTab,
+} from "@/lib/gateway-models";
+
+function displayDate(value?: string | null) {
+ return value ? new Date(value).toLocaleString() : "Never";
+}
+
+function errorAlert(error?: string) {
+ return error ? (
+
+
+ Action failed
+ {error}
+
+ ) : null;
+}
+
+function RefreshCatalogButton() {
+ const router = useRouter();
+ const [state, action, pending] = useActionState(
+ refreshGatewayModels,
+ {},
+ );
+ useEffect(() => {
+ if (state.saved || state.error) router.refresh();
+ }, [state.saved, state.error, router]);
+ return (
+
+
+ {state.error &&
{state.error}
}
+
+ );
+}
+
+function AcknowledgeButton({ model }: { model: GatewayModel }) {
+ const router = useRouter();
+ const [state, action, pending] = useActionState(
+ acknowledgeGatewayModel,
+ {},
+ );
+ useEffect(() => {
+ if (state.saved) router.refresh();
+ }, [state.saved, router]);
+ if (!model.fingerprint) return null;
+ return (
+
+ );
+}
+
+function HarnessEditor({
+ harness,
+ models,
+ revision,
+ onClose,
+}: {
+ harness: GatewayModelHarness;
+ models: GatewayModel[];
+ revision: string;
+ onClose: () => void;
+}) {
+ const router = useRouter();
+ const [assigned, setAssigned] = useState(harness.gateway_models);
+ const [defaultModel, setDefaultModel] = useState(harness.default_model ?? "none");
+ const [manual, setManual] = useState("");
+ const [query, setQuery] = useState("");
+ const [state, action, pending] = useActionState(
+ saveHarnessGatewayModels,
+ {},
+ );
+ const known = useMemo(() => {
+ const byId = new Map(models.map((model) => [model.id, model]));
+ for (const id of assigned) {
+ if (!byId.has(id)) {
+ byId.set(id, { id, metadata: {}, state: "unknown", assigned_to: [harness.key] });
+ }
+ }
+ return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id));
+ }, [models, assigned, harness.key]);
+ const filtered = known.filter((model) =>
+ `${model.id} ${model.display_name ?? ""}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()),
+ );
+
+ useEffect(() => {
+ if (!state.saved) return;
+ router.refresh();
+ onClose();
+ }, [state.saved, router, onClose]);
+
+ function toggle(id: string, checked: boolean) {
+ setAssigned((current) => checked
+ ? current.includes(id) ? current : [...current, id]
+ : current.filter((value) => value !== id));
+ if (!checked && defaultModel === id) setDefaultModel("none");
+ }
+
+ function addManual() {
+ const value = manual.trim();
+ if (!value || assigned.includes(value)) return;
+ setAssigned((current) => [...current, value]);
+ setManual("");
+ }
+
+ return (
+ !open && !pending && onClose()}>
+
+
+
+
+ );
+}
+
+function DiscoveryTab({ resource }: { resource: GatewayModelsResource }) {
+ return (
+
+ {resource.sync.discovery_supported === false && (
+
Discovery unsupported This provisioner does not support model discovery. Manual assignments remain available.
+ )}
+ {resource.sync.latest_error && (
+
Latest refresh failed {resource.sync.latest_error} The last successful catalog was preserved.
+ )}
+ {resource.sync.stale && !resource.sync.latest_error && (
+
Catalog is stale The last successful refresh is older than two refresh intervals.
+ )}
+
+
+
+
Discovery status
+
Last successful refresh: {displayDate(resource.sync.last_successful_refresh_at)}
+
+
+
+
+
+ Gateway type {resource.gateway_type}
+ Refresh interval {resource.refresh_interval_seconds} seconds
+ Last attempted refresh {displayDate(resource.sync.last_refresh_at)}
+ Source revision {resource.sync.source_revision ?? "—"}
+
+
+
+
+ );
+}
+
+function CatalogTab({ resource }: { resource: GatewayModelsResource }) {
+ const [query, setQuery] = useState("");
+ const models = resource.models.filter((model) =>
+ `${model.id} ${model.display_name ?? ""} ${model.assigned_to.join(" ")}`
+ .toLocaleLowerCase()
+ .includes(query.trim().toLocaleLowerCase()),
+ );
+ return (
+
+
+
Model catalog {resource.models.length} known and manually assigned model IDs
+
setQuery(event.target.value)} placeholder="Search models" className="pl-8" />
+
+
+ Model Status Assigned to Last seen Actions
+ {models.length === 0 ? No models found. : models.map((model) => (
+
+ {model.id} {model.display_name && model.display_name !== model.id && {model.display_name} }{modelStateWarning(model.state) && {modelStateWarning(model.state)} }
+ {modelStateLabel(model.state)}
+ {model.assigned_to.join(", ") || "—"}
+ {displayDate(model.last_seen_at)}
+ {model.state === "changed" && }
+
+ ))}
+
+
+ );
+}
+
+function AssignmentsTab({ resource }: { resource: GatewayModelsResource }) {
+ const [editing, setEditing] = useState();
+ return (
+ <>
+
+ Harness assignments Assignments are revisioned governance policy and are never changed by discovery.
+
+ Harness Assigned Default Exposure Actions
+ {resource.harnesses.map((harness) => (
+
+ {harness.label}
+ {harness.gateway_models.length}
+
+ {gatewayModelFallbackLabel(harness)}
+
+ {harness.exposure === "catalog" ? "Full catalog" : "Selected only"}
+
+ {canEditGatewayModelAssignments(harness) ? (
+ setEditing(harness)}>Edit
+ ) : (
+
+ Edit
+ {gatewayModelAssignmentReadOnlyReason(harness)}
+
+ )}
+
+
+ ))}
+
+
+ {editing && setEditing(undefined)} />}
+ >
+ );
+}
+
+export function ModelCatalog({
+ resource,
+ activeTab,
+}: {
+ resource: GatewayModelsResource;
+ activeTab: GatewayModelsTab;
+}) {
+ const [tab, setTab] = useState(activeTab);
+
+ useEffect(() => setTab(activeTab), [activeTab]);
+
+ function selectTab(next: string | number) {
+ if (next !== "discovery" && next !== "catalog" && next !== "assignments") return;
+ setTab(next);
+ const url = new URL(window.location.href);
+ url.searchParams.set("tab", "models");
+ if (next === "discovery") url.searchParams.delete("models_tab");
+ else url.searchParams.set("models_tab", next);
+ window.history.replaceState(window.history.state, "", url);
+ }
+
+ return (
+
+
+
+ Discovery
+ Catalog
+ Assignments
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/app/(dashboard)/gateway/page.tsx b/apps/dashboard/app/(dashboard)/gateway/page.tsx
index 53745d2..1c25a74 100644
--- a/apps/dashboard/app/(dashboard)/gateway/page.tsx
+++ b/apps/dashboard/app/(dashboard)/gateway/page.tsx
@@ -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;
@@ -126,11 +131,12 @@ function resultBadge(result: RequestLog["result"]) {
export default async function GatewayPage({ searchParams }: { searchParams: Promise }) {
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"
@@ -158,6 +164,13 @@ export default async function GatewayPage({ searchParams }: { searchParams: Prom
const status = await api("/gateway/status");
if (!status.enabled) notFound();
+ let gatewayModels: GatewayModelsResource | undefined;
+ let gatewayModelsError: string | undefined;
+ if (me.role === "admin") {
+ try { gatewayModels = await api("/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;
@@ -233,6 +246,9 @@ export default async function GatewayPage({ searchParams }: { searchParams: Prom
)}
+ models={gatewayModels ? : (
+ Model catalog unavailable {gatewayModelsError}
+ )}
keys={(
Managed gateway key
diff --git a/apps/dashboard/app/actions.ts b/apps/dashboard/app/actions.ts
index eb6ee1c..f6f1b7c 100644
--- a/apps/dashboard/app/actions.ts
+++ b/apps/dashboard/app/actions.ts
@@ -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() });
@@ -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 {
+ try {
+ const resource = await api("/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 {
+ 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 {
+ 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,
diff --git a/apps/dashboard/lib/gateway-model-compatibility.test.ts b/apps/dashboard/lib/gateway-model-compatibility.test.ts
new file mode 100644
index 0000000..4969360
--- /dev/null
+++ b/apps/dashboard/lib/gateway-model-compatibility.test.ts
@@ -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");
+});
diff --git a/apps/dashboard/lib/gateway-model-compatibility.ts b/apps/dashboard/lib/gateway-model-compatibility.ts
new file mode 100644
index 0000000..b6c350b
--- /dev/null
+++ b/apps/dashboard/lib/gateway-model-compatibility.ts
@@ -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;
+}
diff --git a/apps/dashboard/lib/gateway-models.test.ts b/apps/dashboard/lib/gateway-models.test.ts
new file mode 100644
index 0000000..8fcd847
--- /dev/null
+++ b/apps/dashboard/lib/gateway-models.test.ts
@@ -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",
+ );
+});
diff --git a/apps/dashboard/lib/gateway-models.ts b/apps/dashboard/lib/gateway-models.ts
new file mode 100644
index 0000000..cd9bf09
--- /dev/null
+++ b/apps/dashboard/lib/gateway-models.ts
@@ -0,0 +1,77 @@
+export type GatewayModelState = "available" | "changed" | "removed" | "unknown";
+export type GatewayModelsTab = "discovery" | "catalog" | "assignments";
+
+export function resolveGatewayModelsTab(value?: string | null): GatewayModelsTab {
+ return value === "catalog" || value === "assignments" ? value : "discovery";
+}
+
+export type GatewayModel = {
+ id: string;
+ display_name?: string | null;
+ metadata: Record;
+ fingerprint?: string | null;
+ state: GatewayModelState;
+ assigned_to: string[];
+ first_seen_at?: string | null;
+ last_seen_at?: string | null;
+ unavailable_since?: string | null;
+};
+
+export type GatewayModelHarness = {
+ key: string;
+ label: string;
+ exposure: "catalog" | "selected_only";
+ gateway_models: string[];
+ default_model?: string | null;
+};
+
+export type GatewayModelsResource = {
+ revision: string;
+ gateway_type: string;
+ refresh_interval_seconds: number;
+ sync: {
+ last_refresh_at?: string | null;
+ last_successful_refresh_at?: string | null;
+ source_revision?: string | null;
+ latest_error?: string | null;
+ latest_error_at?: string | null;
+ discovery_supported?: boolean | null;
+ stale: boolean;
+ };
+ models: GatewayModel[];
+ harnesses: GatewayModelHarness[];
+};
+
+export function canEditGatewayModelAssignments(harness: GatewayModelHarness) {
+ return harness.exposure === "catalog";
+}
+
+export function gatewayModelAssignmentReadOnlyReason(harness: GatewayModelHarness) {
+ return canEditGatewayModelAssignments(harness)
+ ? null
+ : `Full catalog assignment is unavailable for ${harness.label}.`;
+}
+
+export function gatewayModelFallbackLabel(harness: GatewayModelHarness) {
+ if (harness.default_model) return harness.default_model;
+ if (harness.exposure === "catalog") {
+ return `First assigned fallback: ${harness.gateway_models[0] ?? "—"}`;
+ }
+ return "Native fallback";
+}
+
+export function modelStateLabel(state: GatewayModelState) {
+ return {
+ available: "Available",
+ changed: "Changed",
+ removed: "Removed",
+ unknown: "Unknown",
+ }[state];
+}
+
+export function modelStateWarning(state: GatewayModelState) {
+ if (state === "changed") return "Gateway metadata changed since it was acknowledged.";
+ if (state === "removed") return "This model is absent from the latest successful refresh.";
+ if (state === "unknown") return "This manually assigned ID has not been returned by discovery.";
+ return null;
+}
diff --git a/apps/dashboard/lib/harness-metadata.ts b/apps/dashboard/lib/harness-metadata.ts
index 7647c07..0f9da9b 100644
--- a/apps/dashboard/lib/harness-metadata.ts
+++ b/apps/dashboard/lib/harness-metadata.ts
@@ -5,6 +5,7 @@ export type HarnessMetadata = {
description: string;
binary_names: string[];
install_command_template: string;
+ gateway_model_exposure: "catalog" | "selected_only";
capabilities: string[];
component_rules: {
agents_require_plugin: boolean;
diff --git a/apps/dashboard/lib/mapping-compatibility.test.ts b/apps/dashboard/lib/mapping-compatibility.test.ts
index 385b46e..bd07615 100644
--- a/apps/dashboard/lib/mapping-compatibility.test.ts
+++ b/apps/dashboard/lib/mapping-compatibility.test.ts
@@ -25,6 +25,7 @@ const claude: HarnessMetadata = {
description: "",
binary_names: ["claude"],
install_command_template: "",
+ gateway_model_exposure: "catalog",
capabilities: [],
component_rules: openRules,
generations: [
@@ -49,6 +50,19 @@ const claude: HarnessMetadata = {
{
profile: "claude-v2_0_12",
introduced: "2.0.12",
+ before: "2.1.242",
+ verified_before: "2.1.242-0",
+ lifecycle: "supported",
+ capabilities: ["mcp", "packages", "skills", "plugins", "hooks", "helpers", "gateway"],
+ component_rules: {
+ agents_require_plugin: true,
+ hooks_require_plugin: true,
+ hooks_as_plugin_modules: false,
+ },
+ },
+ {
+ profile: "claude-v2_1_242",
+ introduced: "2.1.242",
before: null,
verified_before: "2.1.253-0",
lifecycle: "supported",
diff --git a/apps/docs/next/concepts/harness-version-compatibility.mdx b/apps/docs/next/concepts/harness-version-compatibility.mdx
index a790049..4b6a881 100644
--- a/apps/docs/next/concepts/harness-version-compatibility.mdx
+++ b/apps/docs/next/concepts/harness-version-compatibility.mdx
@@ -185,6 +185,13 @@ Daemon runs, redirected/non-interactive commands, and `blue apply --yes` never i
Each harness has an ordered registry of compiled profiles. Every profile has an inclusive `introduced` bound, an optional exclusive breaking-generation `before` bound, and a mandatory exclusive `verified_before` release ceiling. Dispatch may remain open-ended, but certified support never is: a parseable release at or beyond `verified_before` is blocked unless the organization uses the explicit unverified opt-in above.
+Claude Code `2.1.242` is a compatibility boundary for gateway model controls.
+Releases from `2.0.12` through `2.1.241` use `claude-v2_0_12` and retain
+selected-model-only behavior. Releases from `2.1.242` use
+`claude-v2_1_242`, which replaces Claude's built-in picker choices with Blue's
+ordered assigned catalog and enforces the first assignment as the Default-row
+fallback when no explicit managed model is selected.
+
For example, introducing a profile at `2.0.0` produces these paths:
| Installed version | Selected path |
diff --git a/apps/docs/next/deployment/blue-yaml.mdx b/apps/docs/next/deployment/blue-yaml.mdx
index e350843..71ee217 100644
--- a/apps/docs/next/deployment/blue-yaml.mdx
+++ b/apps/docs/next/deployment/blue-yaml.mdx
@@ -247,6 +247,7 @@ gateway:
provisioner:
type: builtin-litellm
reconcile_ttl_seconds: 86400
+ model_catalog_refresh_seconds: 300
```
| Field | Meaning |
@@ -269,6 +270,7 @@ gateway:
| `provisioner.executable_sha256` | Lowercase SHA-256 pin for the exact custom executable. Required with `executable_path`. |
| `provisioner.policy_revision` | Non-empty policy revision for a custom executable; changing it triggers reconciliation. |
| `provisioner.reconcile_ttl_seconds` | Positive interval before Blue reconciles the managed gateway credential again. |
+| `provisioner.model_catalog_refresh_seconds` | Model discovery interval in seconds; defaults to `300` and must be at least `30`. `HARNESS_GATEWAY_MODEL_CATALOG_REFRESH_SECONDS` overrides it. |
| `provisioner.timeout_seconds` | Per-invocation custom executable deadline; defaults to 15 seconds. |
| `provisioner.max_concurrency` | Configured provisioner concurrency ceiling; defaults to `8` and is further limited by the database pool. |
@@ -302,7 +304,7 @@ The `governance` section is the deployment-authored baseline delivered to client
| Field | Meaning |
| --- | --- |
| `revision` | Required seed value. The Control API replaces it with an immutable stored revision ID whenever it publishes a revision. |
-| `contract_version` | Wire contract version. Current clients support version 3. |
+| `contract_version` | Wire contract version. Current clients support version 4. |
| `required_capabilities` | Features a client must support before accepting policy. |
| `minimum_client_version` | Operator-visible rollout floor; required when using harness version ranges or versioned package adapters. |
| `required_client_version` | Optional canonical exact SemVer recommendation for the Blue CLI. Defaults to the control API deployment version and requires `tenant_client_version_pin`. |
diff --git a/apps/docs/next/deployment/production.mdx b/apps/docs/next/deployment/production.mdx
index 80b697a..9fb4bfd 100644
--- a/apps/docs/next/deployment/production.mdx
+++ b/apps/docs/next/deployment/production.mdx
@@ -151,13 +151,17 @@ control_api:
package_artifacts: { bucket: os.environ/HARNESS_PACKAGE_BUCKET }
governance:
revision: "eks-1" # a label for this baseline; the server detects edits by content, not by this value
- contract_version: 3
- required_capabilities: [adapter_intervals, compiled_harness_registry, versioned_state, transactional_reconcile, gateway_inference_jwt]
+ contract_version: 4
+ required_capabilities: [adapter_intervals, compiled_harness_registry, versioned_state, transactional_reconcile, gateway_inference_jwt, gateway_model_catalog]
minimum_client_version: "0.1.0"
ttl_seconds: 300
required: true
allowed_harnesses: [codex, claude, kimi, opencode]
- harnesses: { claude: { managed_config: { model: claude-opus-4-8 } } }
+ harnesses:
+ codex: { gateway_models: [gpt-5.6-sol], managed_config: { model: gpt-5.6-sol } }
+ claude: { gateway_models: [claude-opus-4-8], managed_config: { model: claude-opus-4-8 } }
+ kimi: { gateway_models: [moonshot-ai/kimi-k2.6], managed_config: { model: moonshot-ai/kimi-k2.6 } }
+ opencode: { gateway_models: [anthropic/claude-opus-4-8], managed_config: { model: anthropic/claude-opus-4-8 } }
# Uploads each finished coding session to the S3 bucket. Transcripts can hold prompts, code
# and secrets, so agree a retention and access policy before turning this on.
session_upload: { presign_url: "https://$BLUE_API_HOST/session-uploads/presign" }
diff --git a/apps/docs/next/development/gateway-adapter-architecture.mdx b/apps/docs/next/development/gateway-adapter-architecture.mdx
index 2754de6..72a6a5e 100644
--- a/apps/docs/next/development/gateway-adapter-architecture.mdx
+++ b/apps/docs/next/development/gateway-adapter-architecture.mdx
@@ -52,7 +52,7 @@ agent request with session-bound inference JWT
| `gh-gateway::GatewayAdapter` | Registry identity, client route validation, upstream path/auth policy, and invalid-credential classification | Network I/O, harness files, or credential bytes |
| `HarnessImplementation` | Version-specific inference-token placement and agent wire protocol | Gateway-type dispatch or upstream-provider behavior |
| Inference proxy | Streaming, limits, credential resolution, request forwarding, and application of adapter decisions | Provider administrator operations |
-| `GatewayProvisioner` | Creating, retaining, rotating, and revoking upstream user credentials | Agent configuration or inference forwarding |
+| `GatewayProvisioner` | Creating, retaining, rotating, and revoking upstream user credentials; asynchronously discovering models | Agent configuration or inference forwarding |
| Control API | Server-selected gateway mode, policy personalization, encrypted credentials, and startup validation | Enabling gateway mode from client input |
`gateway.type` selects the compiled adapter. `gateway.provisioner.type` selects
@@ -83,6 +83,13 @@ dispatcher attaches the harness-owned `AuthPlacement` and `wire_api` afterward,
so a gateway cannot replace those decisions. Route and wiring debug output
redacts the token.
+Model discovery deliberately does not belong to this trait. The Control API
+calls the asynchronous `GatewayProvisioner::list_models` capability, persists
+the last successful snapshot, and distinguishes unsupported or failed refreshes
+from a successful snapshot that removed a model. Custom executable
+provisioners may opt into the `list_models` protocol operation; older ones
+report discovery as unsupported.
+
`UpstreamCredentialPlacement` declares bearer authentication or a named header;
it never contains the credential itself. The proxy strips `Authorization`,
`X-API-Key`, and the adapter-declared credential header before applying exactly
diff --git a/apps/docs/next/development/harness-adapter-architecture.mdx b/apps/docs/next/development/harness-adapter-architecture.mdx
index d8ad80c..9809c9f 100644
--- a/apps/docs/next/development/harness-adapter-architecture.mdx
+++ b/apps/docs/next/development/harness-adapter-architecture.mdx
@@ -222,7 +222,8 @@ adapters:
`BLUE_SESSION_ID` → native-session mapping at start and uploads the transcript
from `blue run`'s post-exit fallback rather than a `SessionEnd` hook. See the
upstream [`SessionStart` hook documentation](https://github.com/openai/codex).
-- Claude: `1.0.38` introduces hooks and `2.0.12` introduces plugin packaging.
+- Claude: `1.0.38` introduces hooks, `2.0.12` introduces plugin packaging, and
+ `2.1.242` introduces the governed model picker and enforced assigned catalog.
The hooks-only interval accepts hook components but rejects plugin packages,
standalone skills, and agents. Automatic upload is conservatively disabled
throughout that interval: `SessionEnd` arrived at `1.0.85`, so it is not
diff --git a/apps/docs/next/reference/governance-config.mdx b/apps/docs/next/reference/governance-config.mdx
index 019449c..1fd6081 100644
--- a/apps/docs/next/reference/governance-config.mdx
+++ b/apps/docs/next/reference/governance-config.mdx
@@ -8,8 +8,8 @@ This is the complete policy delivered to clients. The mounted deployment YAML su
```yaml
revision: "organization-managed"
-contract_version: 3
-required_capabilities: [adapter_intervals, compiled_harness_registry, transactional_reconcile, versioned_state, gateway_inference_jwt]
+contract_version: 4
+required_capabilities: [adapter_intervals, compiled_harness_registry, transactional_reconcile, versioned_state, gateway_inference_jwt, gateway_model_catalog]
ttl_seconds: 300
required: true
allowed_harnesses: [codex, claude, kimi, opencode]
@@ -48,6 +48,7 @@ packages:
harnesses:
codex:
version_requirement: ">=0.149.0, <0.150.0"
+ gateway_models: [gpt-5.6-sol, gpt-5.6-terra]
package_overrides:
organization-toolkit:
enabled: true
@@ -87,7 +88,21 @@ harnesses:
## Harness policy
-`managed_config` contains model and harness-specific settings. `mcp` is additive. `package_overrides` may enable or disable an organization package and provide adapter settings; it cannot replace the source, version, or digest. `minimum_client_version` remains visible to operators, while `required_capabilities` is the enforcement mechanism for contract features.
+`managed_config` contains the selected model and harness-specific settings. In
+gateway mode, `gateway_models` contains the exact upstream IDs assigned to that
+harness and the selected `managed_config.model` must be a member. Kimi,
+OpenCode, and Claude Code 2.1.242 or newer receive an enforced launch catalog
+containing exactly the assigned models. Older Claude releases and Codex support
+only the selected model. For catalog-aware harnesses, the first assigned model
+is the enforced fallback when no explicit managed default is selected. Codex's
+upstream [`model_catalog_json`](https://github.com/openai/codex/blob/main/codex-rs/core/src/config/mod.rs)
+setting replaces model metadata but is not an enforced allowlist, so Blue does
+not present Codex assignments as catalog governance.
+The field is ignored in direct mode, so native model catalogs remain untouched.
+`mcp` is additive. `package_overrides` may enable or disable an organization
+package and provide adapter settings; it cannot replace the source, version, or
+digest. `minimum_client_version` remains visible to operators, while
+`required_capabilities` is the enforcement mechanism for contract features.
Managed Codex launches default to `approval_policy: on-request`, preserving native approval prompts while the configured sandbox remains active. An explicit `approval_policy` remains authoritative. The provider-neutral `auto_approve: true` selects `never` only when no explicit Codex approval policy is present; `auto_approve: false` and an absent value both select `on-request`.
@@ -101,7 +116,20 @@ accepted risk.
The presence of top-level `gateway` enables gateway mode for every allowed coding agent and connects Blue to an upstream gateway operated by your organization. Blue does not bundle that gateway. `litellm` is the first and currently only supported type. The Control API injects `proxy_url` and the authenticated user's session-bound inference `token`; each agent inherits its model from its own `managed_config`, or leaves model selection to the agent when none is managed. `auth_style` remains on the wire, but `bearer` is the only supported client-to-proxy value.
-OpenCode is the exception to leaving model selection to the agent. Its governed runtime config merges over the user's own, so an unpinned model would survive next to the governed provider it no longer resolves against. When OpenCode has no managed model, Blue keeps the agent's native model if the gateway serves it, otherwise pins a governed default, and warns at launch so the gap stays visible. Set `managed_config.available_models` to the list your gateway actually serves to control that catalog.
+Gateway model catalogs are discovered server-side and retained as last-known
+state. A failed discovery does not make models appear removed. Administrator
+mappings are revisioned governance policy and are never silently renamed,
+substituted, or deleted when discovery reports drift. The former OpenCode-only
+`managed_config.available_models` escape hatch is deprecated; migrate those
+values to the typed harness `gateway_models` field.
+
+Administrators manage this from **Gateway → Models**, organized into
+**Discovery**, **Catalog**, and **Assignments** child tabs. Discovery runs
+periodically and can be refreshed on demand. Unknown and removed IDs remain
+assignable with a warning so an unavailable discovery service cannot erase an
+operator-authored mapping. A managed default is optional, but when present it
+must be assigned to the same harness. Material metadata changes keep routing
+active until an administrator acknowledges the new fingerprint.
## Managed packages
diff --git a/apps/docs/openapi/next.yaml b/apps/docs/openapi/next.yaml
index 3062328..61b8857 100644
--- a/apps/docs/openapi/next.yaml
+++ b/apps/docs/openapi/next.yaml
@@ -603,6 +603,92 @@ paths:
type: array
items: { $ref: "#/components/schemas/ManagedPackage" }
+ /admin/gateway/models:
+ get:
+ operationId: listGatewayModels
+ tags: [Administration]
+ summary: Read discovered gateway models and per-harness assignments
+ responses:
+ "200":
+ description: Last-known catalog, discovery status, and revisioned assignments
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/GatewayModelsResource" }
+ "404": { description: Gateway mode is disabled }
+
+ /admin/gateway/models/refresh:
+ post:
+ operationId: refreshGatewayModels
+ tags: [Administration]
+ summary: Refresh the gateway model catalog immediately
+ responses:
+ "200":
+ description: Refreshed model resource
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/GatewayModelsResource" }
+ "409": { description: A refresh is already running }
+ "501": { description: The selected provisioner does not support discovery }
+ "502": { description: Gateway discovery failed; last-known state was retained }
+
+ /admin/gateway/models/acknowledge:
+ post:
+ operationId: acknowledgeGatewayModel
+ tags: [Administration]
+ summary: Acknowledge the current metadata fingerprint for a model
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [model_id, fingerprint]
+ properties:
+ model_id: { type: string }
+ fingerprint: { type: string }
+ responses:
+ "200": { description: Fingerprint acknowledged }
+ "409": { description: Model or fingerprint changed concurrently }
+
+ /admin/gateway/models/harnesses/{harness}:
+ put:
+ operationId: updateHarnessGatewayModels
+ tags: [Administration]
+ summary: Save one harness's assigned models and optional default
+ parameters:
+ - name: harness
+ in: path
+ required: true
+ schema: { type: string }
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [base_revision, gateway_models]
+ properties:
+ base_revision: { type: string }
+ gateway_models:
+ type: array
+ minItems: 1
+ uniqueItems: true
+ items: { type: string, minLength: 1 }
+ default_model: { type: [string, "null"] }
+ responses:
+ "200":
+ description: Saved governance revision
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [revision, harness]
+ properties:
+ revision: { type: string }
+ harness: { type: string }
+ "400": { description: Assignment or default is invalid }
+ "409": { description: Base revision is stale }
+
/admin/package-source/inspect:
post:
operationId: inspectManagedPackageSource
@@ -1549,6 +1635,57 @@ components:
- $ref: "#/components/schemas/GatewayRuntimeChecks"
- type: "null"
+ GatewayModelState:
+ type: string
+ enum: [available, changed, removed, unknown]
+
+ GatewayDiscoveredModel:
+ type: object
+ required: [id, metadata, state, assigned_to]
+ properties:
+ id: { type: string }
+ display_name: { type: [string, "null"] }
+ metadata: { type: object, additionalProperties: true }
+ fingerprint: { type: [string, "null"] }
+ state: { $ref: "#/components/schemas/GatewayModelState" }
+ assigned_to: { type: array, items: { type: string } }
+ first_seen_at: { type: [string, "null"], format: date-time }
+ last_seen_at: { type: [string, "null"], format: date-time }
+ unavailable_since: { type: [string, "null"], format: date-time }
+
+ GatewayHarnessModels:
+ type: object
+ required: [key, label, exposure, gateway_models]
+ properties:
+ key: { type: string }
+ label: { type: string }
+ exposure: { type: string, enum: [catalog, selected_only] }
+ gateway_models: { type: array, items: { type: string } }
+ default_model: { type: [string, "null"] }
+
+ GatewayModelSync:
+ type: object
+ required: [stale]
+ properties:
+ last_refresh_at: { type: [string, "null"], format: date-time }
+ last_successful_refresh_at: { type: [string, "null"], format: date-time }
+ source_revision: { type: [string, "null"] }
+ latest_error: { type: [string, "null"] }
+ latest_error_at: { type: [string, "null"], format: date-time }
+ discovery_supported: { type: [boolean, "null"] }
+ stale: { type: boolean }
+
+ GatewayModelsResource:
+ type: object
+ required: [revision, gateway_type, refresh_interval_seconds, sync, models, harnesses]
+ properties:
+ revision: { type: string }
+ gateway_type: { type: string }
+ refresh_interval_seconds: { type: integer, minimum: 30 }
+ sync: { $ref: "#/components/schemas/GatewayModelSync" }
+ models: { type: array, items: { $ref: "#/components/schemas/GatewayDiscoveredModel" } }
+ harnesses: { type: array, items: { $ref: "#/components/schemas/GatewayHarnessModels" } }
+
GatewayProxyHealthResponse:
type: object
required: [status, checked_at, latency_ms]
@@ -1778,6 +1915,11 @@ components:
properties:
version_requirement: { type: string, description: Semver range required for the locally installed harness }
allow_unverified_versions: { type: boolean, default: false, description: Permit matching releases beyond Blue's certified ceiling; requires version_requirement }
+ gateway_models:
+ type: array
+ uniqueItems: true
+ description: Exact gateway model IDs assigned to this harness; ignored in direct mode.
+ items: { type: string, minLength: 1 }
managed_config: { $ref: "#/components/schemas/ManagedConfig" }
mcp:
type: array
diff --git a/crates/gh-config/src/adapters/claude/mod.rs b/crates/gh-config/src/adapters/claude/mod.rs
index 777485f..1aab37d 100644
--- a/crates/gh-config/src/adapters/claude/mod.rs
+++ b/crates/gh-config/src/adapters/claude/mod.rs
@@ -6,12 +6,22 @@ mod writer;
pub mod v0_0_0;
pub mod v1_0_38;
pub mod v2_0_12;
+pub mod v2_1_242;
pub const VERSION_PROBES: &[VersionProbe] = DEFAULT_VERSION_PROBES;
type HookRenderer = fn(&str) -> Result;
+type WriteOperation = fn(
+ &mut ReconcilePlan,
+ &Path,
+ &HarnessPolicy,
+ Option<&GatewayWiring>,
+ Option,
+ Option,
+ bool,
+) -> Result;
const DISABLE_AUTOUPDATER_ENV: &str = "DISABLE_AUTOUPDATER";
-#[derive(Debug)]
+#[derive(Debug, Clone, Copy)]
pub struct Operations {
plan: fn(&Implementation, &ReconcileInput<'_>, &ResolvedPackages) -> Result,
launch: fn(&Path, crate::HarnessLaunchSpec) -> Result,
@@ -28,6 +38,7 @@ pub struct Operations {
proposed_values: ProposedValuesOperation,
transcript_path: TranscriptOperation,
apply_packages: fn(&mut ReconcilePlan, &Path, &mut HarnessWrite, &PackageComponents) -> Result<(), GhError>,
+ write: WriteOperation,
}
#[derive(Debug)]
@@ -66,6 +77,7 @@ pub static PRE_HOOKS_OPERATIONS: Operations = Operations {
proposed_values,
transcript_path: payload_transcript_path,
apply_packages: apply_base_packages,
+ write: writer::write,
};
pub static HOOKS_ONLY_OPERATIONS: Operations = Operations {
plan,
@@ -83,6 +95,7 @@ pub static HOOKS_ONLY_OPERATIONS: Operations = Operations {
proposed_values,
transcript_path: payload_transcript_path,
apply_packages: apply_hook_packages,
+ write: writer::write,
};
pub static PLUGIN_OPERATIONS: Operations = Operations {
plan,
@@ -100,6 +113,11 @@ pub static PLUGIN_OPERATIONS: Operations = Operations {
proposed_values,
transcript_path: payload_transcript_path,
apply_packages: apply_plugin_packages,
+ write: writer::write,
+};
+pub static CATALOG_PLUGIN_OPERATIONS: Operations = Operations {
+ write: writer::write_catalog,
+ ..PLUGIN_OPERATIONS
};
pub static IMPLEMENTATIONS: &[ImplementationRegistration] = &[
@@ -130,11 +148,22 @@ pub static IMPLEMENTATIONS: &[ImplementationRegistration] = &[
profile: "claude-v2_0_12",
aliases: &["claude-v1"],
introduced: "2.0.12",
+ before: Some("2.1.242"),
+ verified_before: "2.1.242-0",
+ lifecycle: ImplementationLifecycle::Supported,
+ },
+ implementation: &v2_0_12::IMPLEMENTATION,
+ },
+ ImplementationRegistration {
+ interval: VersionInterval {
+ profile: "claude-v2_1_242",
+ aliases: &[],
+ introduced: "2.1.242",
before: None,
verified_before: "2.1.253-0",
lifecycle: ImplementationLifecycle::Supported,
},
- implementation: &v2_0_12::IMPLEMENTATION,
+ implementation: &v2_1_242::IMPLEMENTATION,
},
];
@@ -463,7 +492,7 @@ fn execute(
}
_ => None,
};
- let mut report = writer::write(
+ let mut report = (implementation.spec.operations.write)(
plan,
input.home,
input.policy,
diff --git a/crates/gh-config/src/adapters/claude/v2_1_242/mod.rs b/crates/gh-config/src/adapters/claude/v2_1_242/mod.rs
new file mode 100644
index 0000000..1ca2560
--- /dev/null
+++ b/crates/gh-config/src/adapters/claude/v2_1_242/mod.rs
@@ -0,0 +1,6 @@
+use super::*;
+
+static SUPPORT: GenerationSupport = GenerationSupport { capabilities: &["mcp", "packages", "skills", "plugins", "hooks", "helpers", "gateway", "session_upload", "session_start", "session_resume"], component_rules: ComponentRules { agents_require_plugin: true, hooks_require_plugin: true, hooks_as_plugin_modules: false } };
+pub static SPEC: VersionSpec = VersionSpec { operations: &CATALOG_PLUGIN_OPERATIONS, session_upload: Feature::Supported(session_upload_hooks), session_start: Feature::Supported(session_start_hooks), session_resume: Feature::Supported(()), support: &SUPPORT };
+pub static IMPLEMENTATION: Implementation = Implementation::new(&SPEC);
+pub(crate) use super::{session_start_hooks, session_upload_hooks};
diff --git a/crates/gh-config/src/adapters/claude/writer.rs b/crates/gh-config/src/adapters/claude/writer.rs
index f05d2e9..b7d3a36 100644
--- a/crates/gh-config/src/adapters/claude/writer.rs
+++ b/crates/gh-config/src/adapters/claude/writer.rs
@@ -20,10 +20,44 @@ pub fn write(
session_start_hooks: Option,
_enforced: bool,
) -> Result {
+ write_with_catalog(plan, home, policy, wiring, session_upload_hooks, session_start_hooks, false)
+}
+
+pub fn write_catalog(
+ plan: &mut crate::adapters::ReconcilePlan,
+ home: &Path,
+ policy: &HarnessPolicy,
+ wiring: Option<&GatewayWiring>,
+ session_upload_hooks: Option,
+ session_start_hooks: Option,
+ _enforced: bool,
+) -> Result {
+ write_with_catalog(plan, home, policy, wiring, session_upload_hooks, session_start_hooks, true)
+}
+
+fn write_with_catalog(
+ plan: &mut crate::adapters::ReconcilePlan,
+ home: &Path,
+ policy: &HarnessPolicy,
+ wiring: Option<&GatewayWiring>,
+ session_upload_hooks: Option,
+ session_start_hooks: Option,
+ catalog: bool,
+) -> Result {
+ if wiring.is_some()
+ && (policy.gateway_models.is_empty()
+ || policy.managed_config.model.as_ref().is_some_and(|selected| {
+ !policy.gateway_models.iter().any(|model| model == selected)
+ }))
+ {
+ return Err(GhError::config(
+ "gateway-mode Claude selected model must be present in gateway_models",
+ ));
+ }
migrate_legacy_global_config(plan, home, policy)?;
let runtime = crate::managed_runtime_dir(home).join("claude");
let settings_path = runtime.join("settings.json");
- let settings = build_settings(policy, wiring, session_upload_hooks, session_start_hooks)?;
+ let settings = build_settings(policy, wiring, session_upload_hooks, session_start_hooks, catalog)?;
plan.write(&settings_path, json_pretty(&settings)?)?;
let local = plan.read_json_object(&home.join(".claude.json"))?;
@@ -201,6 +235,7 @@ fn build_settings(
wiring: Option<&GatewayWiring>,
session_upload_hooks: Option,
session_start_hooks: Option,
+ catalog: bool,
) -> Result {
let mut settings = Map::new();
if let Some(model) = &policy.managed_config.model {
@@ -221,6 +256,20 @@ fn build_settings(
"ANTHROPIC_AUTH_TOKEN": w.token,
}),
);
+ if catalog {
+ settings.insert("availableModels".into(), json!(policy.gateway_models));
+ settings.insert("enforceAvailableModels".into(), json!(true));
+ settings.insert(
+ "modelPicker".into(),
+ json!({
+ "options": policy.gateway_models.iter().map(|model| json!({
+ "model": model,
+ "label": model,
+ })).collect::>(),
+ "replaceBuiltInOptions": true,
+ }),
+ );
+ }
}
// Managed SessionEnd (upload) and SessionStart (identity) hooks share the
// single `hooks` object Claude reads from the managed settings.json.
@@ -270,6 +319,99 @@ fn plan_skills(
#[cfg(test)]
mod tests {
use super::*;
+
+ fn rendered_settings(
+ policy: &HarnessPolicy,
+ wiring: Option<&GatewayWiring>,
+ catalog: bool,
+ ) -> Value {
+ build_settings(policy, wiring, None, None, catalog).unwrap()
+ }
+
+ fn gateway() -> GatewayWiring {
+ GatewayWiring {
+ base_url: "https://gateway.example".into(),
+ token: "fixture-token".into(),
+ wire_api: None,
+ auth: gh_gateway::AuthPlacement::InFile,
+ }
+ }
+
+ #[test]
+ fn catalog_settings_preserve_assignment_order_and_exact_labels() {
+ let policy: HarnessPolicy = serde_json::from_value(json!({
+ "gateway_models": ["model-b", "provider/model-a"],
+ "managed_config": { "model": "provider/model-a" }
+ }))
+ .unwrap();
+ let settings = rendered_settings(&policy, Some(&gateway()), true);
+
+ assert_eq!(settings["availableModels"], json!(["model-b", "provider/model-a"]));
+ assert_eq!(settings["enforceAvailableModels"], true);
+ assert_eq!(
+ settings["modelPicker"],
+ json!({
+ "options": [
+ { "model": "model-b", "label": "model-b" },
+ { "model": "provider/model-a", "label": "provider/model-a" }
+ ],
+ "replaceBuiltInOptions": true
+ })
+ );
+ assert_eq!(settings["model"], "provider/model-a");
+ }
+
+ #[test]
+ fn catalog_without_default_still_enforces_the_complete_assignment() {
+ let policy: HarnessPolicy = serde_json::from_value(json!({
+ "gateway_models": ["first", "second"]
+ }))
+ .unwrap();
+ let settings = rendered_settings(&policy, Some(&gateway()), true);
+
+ assert!(settings.get("model").is_none());
+ assert_eq!(settings["availableModels"], json!(["first", "second"]));
+ assert_eq!(settings["modelPicker"]["replaceBuiltInOptions"], true);
+ }
+
+ #[test]
+ fn direct_mode_and_legacy_generation_omit_catalog_controls() {
+ let policy: HarnessPolicy = serde_json::from_value(json!({
+ "gateway_models": ["first", "second"]
+ }))
+ .unwrap();
+ for settings in [
+ rendered_settings(&policy, None, true),
+ rendered_settings(&policy, Some(&gateway()), false),
+ ] {
+ assert!(settings.get("availableModels").is_none());
+ assert!(settings.get("enforceAvailableModels").is_none());
+ assert!(settings.get("modelPicker").is_none());
+ }
+ }
+
+ #[test]
+ fn catalog_writer_rejects_empty_or_unassigned_gateway_selection() {
+ let home = std::env::temp_dir().join(format!(
+ "gh-claude-catalog-validation-{}",
+ std::process::id()
+ ));
+ std::fs::create_dir_all(&home).unwrap();
+ for policy in [
+ serde_json::from_value(json!({ "gateway_models": [] })).unwrap(),
+ serde_json::from_value(json!({
+ "gateway_models": ["assigned"],
+ "managed_config": { "model": "unassigned" }
+ }))
+ .unwrap(),
+ ] {
+ let mut plan = crate::adapters::ReconcilePlan::default();
+ assert!(write_catalog(&mut plan, &home, &policy, Some(&gateway()), None, None, false)
+ .is_err());
+ }
+ let _ = std::fs::remove_dir_all(home);
+ }
+
fn test_write(
home: &Path,
policy: &HarnessPolicy,
diff --git a/crates/gh-config/src/adapters/codex/writer.rs b/crates/gh-config/src/adapters/codex/writer.rs
index e5e30c2..816d513 100644
--- a/crates/gh-config/src/adapters/codex/writer.rs
+++ b/crates/gh-config/src/adapters/codex/writer.rs
@@ -41,6 +41,16 @@ pub fn write(
session_upload_hook: Option,
session_start_hook: Option,
) -> Result {
+ if wiring.is_some()
+ && (policy.gateway_models.is_empty()
+ || policy.managed_config.model.as_ref().is_some_and(|selected| {
+ !policy.gateway_models.iter().any(|model| model == selected)
+ }))
+ {
+ return Err(GhError::config(
+ "gateway-mode Codex selected model must be present in gateway_models",
+ ));
+ }
let base_path = home.join(".codex").join("config.toml");
migrate_legacy_global_config(plan, &base_path, policy)?;
let base = plan.read_toml_table(&base_path)?;
diff --git a/crates/gh-config/src/adapters/kimi/writer.rs b/crates/gh-config/src/adapters/kimi/writer.rs
index 6e0f90d..767460d 100644
--- a/crates/gh-config/src/adapters/kimi/writer.rs
+++ b/crates/gh-config/src/adapters/kimi/writer.rs
@@ -27,6 +27,7 @@ pub fn write(
let runtime = crate::managed_runtime_dir(home).join("kimi");
let path = runtime.join("config.toml");
let mut table = plan.read_toml_table(&source_path)?;
+ let mut gateway_catalog = None;
if let Some(model) = &policy.managed_config.model {
table.insert("default_model".into(), Toml::String(model.clone()));
@@ -40,12 +41,30 @@ pub fn write(
table.remove("default_yolo");
}
if let Some(w) = wiring {
- let effective_model = policy.managed_config.model.clone().or_else(|| {
- table
- .get("default_model")
- .and_then(Toml::as_str)
- .map(str::to_owned)
- });
+ let mut catalog = Vec::new();
+ for model in policy.gateway_models.iter().map(|model| model.trim()) {
+ if !model.is_empty() && !catalog.iter().any(|existing| existing == model) {
+ catalog.push(model.to_owned());
+ }
+ }
+ if catalog.is_empty() {
+ return Err(GhError::config(
+ "gateway-mode Kimi policy requires at least one gateway_models entry",
+ ));
+ }
+ let effective_model = policy
+ .managed_config
+ .model
+ .clone()
+ .or_else(|| catalog.first().cloned());
+ if effective_model
+ .as_ref()
+ .is_some_and(|selected| !catalog.iter().any(|model| model == selected))
+ {
+ return Err(GhError::config(
+ "Kimi selected model must be present in gateway_models",
+ ));
+ }
// kimi-code resolves the wire transport from the provider `type`
// (`openai` vs `openai_responses`). The model-alias `protocol` field
// only accepts the literal "anthropic": kimi-code 0.20.x–0.31.x hard-
@@ -59,8 +78,10 @@ pub fn write(
Some("responses" | "openai_responses") => "openai_responses",
_ => "openai",
};
- // Point the selected model at the governed provider.
- if let Some(model) = effective_model {
+ // Replace the native catalogs so the gateway picker exposes only the
+ // ordered policy assignment, all routed through the governed provider.
+ let mut models = toml::map::Map::new();
+ for model in &catalog {
let mut model_entry = toml::map::Map::new();
model_entry.insert("provider".into(), Toml::String(GOVERNED_PROVIDER.into()));
model_entry.insert("model".into(), Toml::String(model.clone()));
@@ -72,19 +93,26 @@ pub fn write(
.filter(|value| *value > 0)
.unwrap_or(262_144);
model_entry.insert("max_context_size".into(), Toml::Integer(max_context_size));
- upsert_subtable(&mut table, "models", model, Toml::Table(model_entry));
+ models.insert(model.clone(), Toml::Table(model_entry));
}
+ table.insert("models".into(), Toml::Table(models));
+ table.insert(
+ "default_model".into(),
+ Toml::String(effective_model.expect("non-empty catalog has a fallback")),
+ );
let mut provider = toml::map::Map::new();
provider.insert("type".into(), Toml::String(provider_type.into()));
provider.insert("base_url".into(), Toml::String(w.base_url.clone()));
provider.insert("api_key".into(), Toml::String(w.token.clone()));
- upsert_subtable(
- &mut table,
- "providers",
- GOVERNED_PROVIDER.into(),
- Toml::Table(provider),
+ table.insert(
+ "providers".into(),
+ Toml::Table(singleton_table(
+ GOVERNED_PROVIDER.into(),
+ Toml::Table(provider),
+ )),
);
+ gateway_catalog = Some(catalog);
}
let mut hooks = table
@@ -109,8 +137,7 @@ pub fn write(
if !hooks.is_empty() {
table.insert("hooks".into(), Toml::Array(hooks));
}
- let body =
- toml::to_string_pretty(&Toml::Table(table)).map_err(|e| GhError::Serde(e.to_string()))?;
+ let body = serialize_config(table, gateway_catalog.as_deref())?;
plan.write(&path, body)?;
// MCP → the isolated KIMI_CODE_HOME, seeded from the user's native file.
@@ -256,19 +283,51 @@ fn migrate_legacy_hook(
plan.write(path, body)
}
-/// Insert `value` at `parent.key`, creating the parent table if needed.
-fn upsert_subtable(
- table: &mut toml::map::Map,
- parent: &str,
- key: String,
- value: Toml,
-) {
- let entry = table
- .entry(parent.to_string())
- .or_insert_with(|| Toml::Table(toml::map::Map::new()));
- if let Toml::Table(t) = entry {
- t.insert(key, value);
+fn serialize_config(
+ mut table: toml::map::Map,
+ gateway_catalog: Option<&[String]>,
+) -> Result {
+ let Some(catalog) = gateway_catalog else {
+ return toml::to_string_pretty(&Toml::Table(table))
+ .map_err(|error| GhError::Serde(error.to_string()));
+ };
+
+ let mut models = table
+ .remove("models")
+ .and_then(|value| value.as_table().cloned())
+ .unwrap_or_default();
+ let providers = table.remove("providers");
+ let mut body = toml::to_string_pretty(&Toml::Table(table))
+ .map_err(|error| GhError::Serde(error.to_string()))?;
+ for model in catalog {
+ let entry = models
+ .remove(model)
+ .expect("every gateway catalog model has a generated entry");
+ let fragment = Toml::Table(singleton_table(
+ "models".into(),
+ Toml::Table(singleton_table(model.clone(), entry)),
+ ));
+ body.push('\n');
+ body.push_str(
+ &toml::to_string_pretty(&fragment)
+ .map_err(|error| GhError::Serde(error.to_string()))?,
+ );
+ }
+ if let Some(providers) = providers {
+ body.push('\n');
+ let providers = Toml::Table(singleton_table("providers".into(), providers));
+ body.push_str(
+ &toml::to_string_pretty(&providers)
+ .map_err(|error| GhError::Serde(error.to_string()))?,
+ );
}
+ Ok(body)
+}
+
+fn singleton_table(key: String, value: Toml) -> toml::map::Map {
+ let mut table = toml::map::Map::new();
+ table.insert(key, value);
+ table
}
fn plan_skills(
@@ -320,7 +379,7 @@ mod tests {
std::fs::create_dir_all(home.join(".kimi-code")).unwrap();
std::fs::write(
home.join(".kimi-code/config.toml"),
- "personal = true\nhooks = [{ event = \"SessionEnd\", command = \"my-hook\" }, { event = \"SessionEnd\", command = \"harness session-upload kimi\" }]\n[providers.personal]\ntype = \"openai\"\n",
+ "personal = true\nhooks = [{ event = \"SessionEnd\", command = \"my-hook\" }, { event = \"SessionEnd\", command = \"harness session-upload kimi\" }]\n[models.personal]\nprovider = \"personal\"\nmodel = \"personal\"\nmax_context_size = 4096\n[providers.personal]\ntype = \"openai\"\n",
)
.unwrap();
std::fs::write(
@@ -366,6 +425,10 @@ mod tests {
config["providers"]["personal"]["type"].as_str(),
Some("openai")
);
+ assert_eq!(
+ config["models"]["personal"]["provider"].as_str(),
+ Some("personal")
+ );
assert_eq!(config["default_model"].as_str(), Some("kimi-governed"));
assert_eq!(config["default_permission_mode"].as_str(), Some("yolo"));
assert!(config.get("default_yolo").is_none());
@@ -379,13 +442,13 @@ mod tests {
}
#[test]
- fn global_gateway_preserves_an_unmanaged_default_model() {
+ fn gateway_replaces_native_catalog_preserves_assignment_order_and_uses_first_fallback() {
let home =
std::env::temp_dir().join(format!("gh-kimi-global-gateway-{}", std::process::id()));
std::fs::create_dir_all(home.join(".kimi-code")).unwrap();
std::fs::write(
home.join(".kimi-code/config.toml"),
- "default_model = \"personal-default\"\n",
+ "default_model = \"personal-default\"\npersonal = true\n[models.native]\nprovider = \"personal\"\nmodel = \"native\"\nmax_context_size = 1000\n[providers.personal]\ntype = \"openai\"\nbase_url = \"https://native.example\"\napi_key = \"native-secret\"\n",
)
.unwrap();
let wiring = GatewayWiring {
@@ -395,28 +458,49 @@ mod tests {
auth: AuthPlacement::InFile,
};
- test_write(&home, &HarnessPolicy::default(), Some(&wiring), None).unwrap();
- let config = std::fs::read_to_string(home.join(".config/blue/runtime/kimi/config.toml"))
- .unwrap()
- .parse::()
- .unwrap();
- assert_eq!(config["default_model"].as_str(), Some("personal-default"));
+ let policy = HarnessPolicy {
+ gateway_models: vec![
+ "z-policy-first".into(),
+ "a-policy-second".into(),
+ "z-policy-first".into(),
+ ],
+ ..HarnessPolicy::default()
+ };
+ test_write(&home, &policy, Some(&wiring), None).unwrap();
+ let body =
+ std::fs::read_to_string(home.join(".config/blue/runtime/kimi/config.toml")).unwrap();
+ let config = body.parse::().unwrap();
+ assert_eq!(config["default_model"].as_str(), Some("z-policy-first"));
+ assert_eq!(config["personal"].as_bool(), Some(true));
+ assert_eq!(config["models"].as_table().unwrap().len(), 2);
+ assert!(config["models"].get("native").is_none());
+ assert_eq!(config["providers"].as_table().unwrap().len(), 1);
+ assert!(config["providers"].get("personal").is_none());
+ assert_eq!(
+ config["models"]["z-policy-first"]["provider"].as_str(),
+ Some("governed")
+ );
assert_eq!(
- config["models"]["personal-default"]["provider"].as_str(),
+ config["models"]["a-policy-second"]["provider"].as_str(),
Some("governed")
);
+ assert!(
+ body.find("[models.z-policy-first]").unwrap()
+ < body.find("[models.a-policy-second]").unwrap(),
+ "generated model tables must retain policy order"
+ );
// The model alias must NOT carry a `protocol` field: kimi-code's schema
// only accepts `protocol = "anthropic"`, and 0.20.x–0.31.x salvage-drop
// an alias that declares any other value. The wire transport is carried
// by the provider `type` instead (asserted below).
assert!(
- config["models"]["personal-default"]
+ config["models"]["z-policy-first"]
.as_table()
.is_some_and(|entry| !entry.contains_key("protocol")),
"model alias must not declare `protocol` (kimi-code only allows \"anthropic\")"
);
assert_eq!(
- config["models"]["personal-default"]["max_context_size"].as_integer(),
+ config["models"]["z-policy-first"]["max_context_size"].as_integer(),
Some(262_144)
);
assert_eq!(
@@ -429,4 +513,28 @@ mod tests {
);
let _ = std::fs::remove_dir_all(home);
}
+
+ #[test]
+ fn gateway_rejects_empty_catalog_and_unassigned_explicit_default() {
+ let home = std::env::temp_dir().join(format!("gh-kimi-invalid-{}", std::process::id()));
+ std::fs::create_dir_all(home.join(".kimi-code")).unwrap();
+ let wiring = GatewayWiring {
+ base_url: "https://inference.example".into(),
+ token: "test-inference-jwt".into(),
+ wire_api: None,
+ auth: AuthPlacement::InFile,
+ };
+
+ let empty = test_write(&home, &HarnessPolicy::default(), Some(&wiring), None).unwrap_err();
+ assert!(empty.to_string().contains("requires at least one gateway_models"));
+
+ let policy = HarnessPolicy {
+ gateway_models: vec!["assigned".into()],
+ managed_config: serde_json::from_value(json!({ "model": "unassigned" })).unwrap(),
+ ..HarnessPolicy::default()
+ };
+ let unassigned = test_write(&home, &policy, Some(&wiring), None).unwrap_err();
+ assert!(unassigned.to_string().contains("must be present in gateway_models"));
+ let _ = std::fs::remove_dir_all(home);
+ }
}
diff --git a/crates/gh-config/src/adapters/mod.rs b/crates/gh-config/src/adapters/mod.rs
index d7384b0..d97b0d3 100644
--- a/crates/gh-config/src/adapters/mod.rs
+++ b/crates/gh-config/src/adapters/mod.rs
@@ -60,6 +60,13 @@ pub struct GenerationSupport {
pub component_rules: ComponentRules,
}
+#[derive(Debug, Clone, Copy, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum GatewayModelExposure {
+ Catalog,
+ SelectedOnly,
+}
+
#[derive(Debug, serde::Serialize)]
pub struct PublicHarnessMetadata {
pub key: &'static str,
@@ -68,6 +75,7 @@ pub struct PublicHarnessMetadata {
pub description: &'static str,
pub binary_names: &'static [&'static str],
pub install_command_template: &'static str,
+ pub gateway_model_exposure: GatewayModelExposure,
/// Backward-compatible summary derived from every generation.
pub capabilities: Vec<&'static str>,
/// Backward-compatible current-generation rules. Consumers should prefer
@@ -494,6 +502,10 @@ pub fn registry_metadata() -> Vec {
description: metadata.description,
binary_names: metadata.binary_names,
install_command_template: metadata.install_command_template,
+ gateway_model_exposure: match metadata.key {
+ "opencode" | "kimi" | "claude" => GatewayModelExposure::Catalog,
+ _ => GatewayModelExposure::SelectedOnly,
+ },
capabilities,
component_rules: current_support.component_rules,
generations: definition
diff --git a/crates/gh-config/src/adapters/opencode/writer.rs b/crates/gh-config/src/adapters/opencode/writer.rs
index 9f5ecdd..6b64dc8 100644
--- a/crates/gh-config/src/adapters/opencode/writer.rs
+++ b/crates/gh-config/src/adapters/opencode/writer.rs
@@ -13,7 +13,6 @@ use crate::HarnessWrite;
const GOVERNED_PROVIDER: &str = "governed";
const GATEWAY_TOKEN_ENV: &str = "BLUE_OPENCODE_GATEWAY_TOKEN";
-const ADDITIONAL_GOVERNED_MODELS: &[&str] = &["openai/gpt-5.6-sol"];
const SCHEMA: &str = "https://opencode.ai/config.json";
pub fn write(
@@ -32,6 +31,22 @@ pub fn write(
root.insert("$schema".to_string(), json!(SCHEMA));
let catalog = governed_catalog(policy);
+ if wiring.is_some() && catalog.is_empty() {
+ return Err(GhError::config(
+ "gateway-mode OpenCode policy requires at least one gateway_models entry",
+ ));
+ }
+ if wiring.is_some()
+ && policy
+ .managed_config
+ .model
+ .as_ref()
+ .is_some_and(|selected| !catalog.iter().any(|model| model == selected))
+ {
+ return Err(GhError::config(
+ "OpenCode selected model must be present in gateway_models",
+ ));
+ }
let mut warnings = Vec::new();
// In gateway mode the model is always pinned: `OPENCODE_CONFIG_CONTENT`
// merges over the user's global config, so leaving `model` unset lets a
@@ -140,33 +155,19 @@ pub fn write(
})
}
-/// Provider-local model IDs the governed provider advertises: an
-/// admin-supplied `managed_config.available_models` when present (the gateway
-/// is the only thing that knows what it actually serves), else the built-in
-/// default. Strip one optional `governed/` prefix and ignore empty IDs.
-/// Never empty, so the provider block is always renderable.
+/// Exact provider-local IDs assigned by the control plane. Strip one optional
+/// legacy `governed/` prefix and normalize duplicates defensively.
fn governed_catalog(policy: &HarnessPolicy) -> Vec {
- let configured = policy
- .managed_config
- .extra
- .get("available_models")
- .and_then(Value::as_array)
- .map(|models| {
- models
- .iter()
- .filter_map(Value::as_str)
- .map(|model| model.strip_prefix("governed/").unwrap_or(model))
- .filter(|model| !model.is_empty())
- .map(str::to_owned)
- .collect::>()
- })
- .filter(|models| !models.is_empty());
- configured.unwrap_or_else(|| {
- ADDITIONAL_GOVERNED_MODELS
- .iter()
- .map(|model| (*model).to_owned())
- .collect()
- })
+ let mut models = policy
+ .gateway_models
+ .iter()
+ .map(|model| model.strip_prefix("governed/").unwrap_or(model).trim())
+ .filter(|model| !model.is_empty())
+ .map(str::to_owned)
+ .collect::>();
+ let mut seen = std::collections::BTreeSet::new();
+ models.retain(|model| seen.insert(model.clone()));
+ models
}
/// The user's native model, but only when the gateway is known to serve it.
@@ -437,7 +438,8 @@ mod tests {
fn gateway_provider_has_runtime_models_and_native_auth_shape() {
let home = std::env::temp_dir().join(format!("gh-opencode-gateway-{}", std::process::id()));
let policy: HarnessPolicy = serde_json::from_value(json!({
- "managed_config": { "model": "e2e/model" }
+ "managed_config": { "model": "e2e/model" },
+ "gateway_models": ["e2e/model", "openai/gpt-5.6-sol"]
}))
.unwrap();
let wiring = gh_gateway::GatewayWiring {
@@ -517,8 +519,16 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, serde_json::to_vec(&json!({ "model": model })).unwrap()).unwrap();
}
- let policy: HarnessPolicy =
- serde_json::from_value(json!({ "managed_config": managed_config })).unwrap();
+ let mut managed_config = managed_config;
+ let gateway_models = managed_config
+ .as_object_mut()
+ .and_then(|config| config.remove("available_models"))
+ .unwrap_or_else(|| json!(["openai/gpt-5.6-sol"]));
+ let policy: HarnessPolicy = serde_json::from_value(json!({
+ "managed_config": managed_config,
+ "gateway_models": gateway_models
+ }))
+ .unwrap();
let report = test_write(&home, &policy, Some(&gateway_wiring()), None).unwrap();
let managed: Value = serde_json::from_slice(
&std::fs::read(home.join(".config/blue/runtime/opencode/opencode.json")).unwrap(),
@@ -621,11 +631,11 @@ mod tests {
}
#[test]
- fn policy_catalog_discards_empty_and_non_string_entries() {
+ fn policy_catalog_discards_empty_entries() {
let (_, managed) = gateway_write(
"gateway-mixed-catalog",
None,
- json!({ "available_models": ["", "governed/", null, 42, "governed/org/fast", "org/slow"] }),
+ json!({ "available_models": ["", "governed/", "governed/org/fast", "org/slow"] }),
);
assert_eq!(managed["model"], "governed/org/fast");
assert_eq!(
@@ -635,30 +645,17 @@ mod tests {
"org/slow": { "name": "org/slow" }
})
);
-
- let (_, fallback) = gateway_write(
- "gateway-invalid-catalog",
- None,
- json!({ "available_models": ["", "governed/", null, 42] }),
- );
- assert_eq!(fallback["model"], "governed/openai/gpt-5.6-sol");
- assert_eq!(
- fallback["provider"]["governed"]["models"],
- json!({ "openai/gpt-5.6-sol": { "name": "openai/gpt-5.6-sol" } })
- );
}
#[test]
fn policy_catalog_strips_one_prefix_and_preserves_order_and_duplicates() {
let policy: HarnessPolicy = serde_json::from_value(json!({
- "managed_config": {
- "available_models": ["governed/org/first", "governed/governed/legacy", "org/first"]
- }
+ "gateway_models": ["governed/org/first", "governed/governed/legacy", "org/first"]
}))
.unwrap();
assert_eq!(
governed_catalog(&policy),
- ["org/first", "governed/legacy", "org/first"]
+ ["org/first", "governed/legacy"]
);
}
@@ -667,7 +664,7 @@ mod tests {
let (report, managed) = gateway_write(
"gateway-managed",
Some("openai/gpt-5.6-sol"),
- json!({ "model": "org/pinned", "available_models": ["org/fast"] }),
+ json!({ "model": "org/pinned", "available_models": ["org/fast", "org/pinned"] }),
);
assert_eq!(managed["model"], "governed/org/pinned");
diff --git a/crates/gh-config/src/contract_tests.rs b/crates/gh-config/src/contract_tests.rs
index 5345cff..5294887 100644
--- a/crates/gh-config/src/contract_tests.rs
+++ b/crates/gh-config/src/contract_tests.rs
@@ -327,7 +327,9 @@ fn every_production_interval_has_a_pure_golden_plan() {
component.agents.push(agents);
component.hooks.push(hooks);
}
- Harness::Claude if registration.interval.introduced == "2.0.12" => {
+ Harness::Claude
+ if matches!(registration.interval.introduced, "2.0.12" | "2.1.242") =>
+ {
component.plugin = Some(plugin);
}
Harness::Claude if registration.interval.introduced == "1.0.38" => {
@@ -363,6 +365,7 @@ fn every_production_interval_has_a_pure_golden_plan() {
}
let policy: HarnessPolicy = serde_json::from_value(serde_json::json!({
"managed_config": managed_config,
+ "gateway_models": if gateway_enabled { vec![managed_model.unwrap_or("fixture-model")] } else { Vec::<&str>::new() },
"mcp":[{"name":"fixture", "command":"fixture-mcp", "args":["--stdio"]}]
}))
.unwrap();
@@ -556,22 +559,29 @@ fn boundaries_aliases_and_unsupported_packages_are_explicit() {
(Harness::Claude, "1.0.38", "claude-v1_0_38"),
(Harness::Claude, "2.0.11", "claude-v1_0_38"),
(Harness::Claude, "2.0.12", "claude-v2_0_12"),
+ (Harness::Claude, "2.1.241", "claude-v2_0_12"),
+ (Harness::Claude, "2.1.242", "claude-v2_1_242"),
] {
assert_eq!(context(harness, version).profile.id, expected);
}
for harness in Harness::ALL {
let alias = format!("{}-v1", harness.key());
- assert_eq!(
- adapters::implementation_for_profile(harness, &alias)
- .unwrap()
- .interval
- .profile,
+ let expected = if harness == Harness::Claude {
+ "claude-v2_0_12"
+ } else {
adapters::definition(harness)
.implementations
.last()
.unwrap()
.interval
.profile
+ };
+ assert_eq!(
+ adapters::implementation_for_profile(harness, &alias)
+ .unwrap()
+ .interval
+ .profile,
+ expected
);
assert!(adapters::implementation_for_profile(harness, "unknown-profile").is_none());
}
diff --git a/crates/gh-config/tests/golden/claude-v2_1_242-gateway-nomodel.json b/crates/gh-config/tests/golden/claude-v2_1_242-gateway-nomodel.json
new file mode 100644
index 0000000..3240ccc
--- /dev/null
+++ b/crates/gh-config/tests/golden/claude-v2_1_242-gateway-nomodel.json
@@ -0,0 +1,52 @@
+{
+ "args": [
+ "--settings",
+ "$HOME/.config/blue/runtime/claude/settings.json",
+ "--mcp-config",
+ "$HOME/.config/blue/runtime/claude/mcp.json",
+ "--plugin-dir",
+ "$HOME/package/plugin",
+ "--plugin-dir",
+ "$HOME/.config/blue/runtime/claude/standalone-skills-plugin"
+ ],
+ "env": {
+ "DISABLE_AUTOUPDATER": "1",
+ "HARNESS_PACKAGE_COMPONENTS_SETTINGS": "{\"color\":\"blue\"}",
+ "PATH": "$HOME/package/bin:$PATH"
+ },
+ "files": [
+ "$HOME/.config/blue/runtime/claude/mcp.json",
+ "$HOME/.config/blue/runtime/claude/settings.json",
+ "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/.claude-plugin/plugin.json",
+ "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/skills"
+ ],
+ "ownership": [
+ "$HOME/.config/blue/runtime/claude"
+ ],
+ "removals": [
+ "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/skills"
+ ],
+ "warnings": [],
+ "writes": [
+ {
+ "body": "{\n \"mcpServers\": {\n \"fixture\": {\n \"args\": [\n \"--stdio\"\n ],\n \"command\": \"fixture-mcp\"\n }\n }\n}\n",
+ "mode": 384,
+ "path": "$HOME/.config/blue/runtime/claude/mcp.json"
+ },
+ {
+ "body": "{\n \"availableModels\": [\n \"fixture-model\"\n ],\n \"enforceAvailableModels\": true,\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fixture-token\",\n \"ANTHROPIC_BASE_URL\": \"https://gateway.example\"\n },\n \"hooks\": {\n \"SessionEnd\": [\n {\n \"hooks\": [\n {\n \"command\": \"'$BLUE' session-upload claude --profile 'claude-v2_1_242'\",\n \"timeout\": 30,\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"SessionStart\": [\n {\n \"hooks\": [\n {\n \"command\": \"'$BLUE' session-start claude --profile 'claude-v2_1_242'\",\n \"timeout\": 30,\n \"type\": \"command\"\n }\n ]\n }\n ]\n },\n \"modelPicker\": {\n \"options\": [\n {\n \"label\": \"fixture-model\",\n \"model\": \"fixture-model\"\n }\n ],\n \"replaceBuiltInOptions\": true\n }\n}\n",
+ "mode": 384,
+ "path": "$HOME/.config/blue/runtime/claude/settings.json"
+ },
+ {
+ "body": "{\n \"name\": \"blue-managed-standalone-skills\",\n \"version\": \"1.0.0\"\n}\n",
+ "mode": 384,
+ "path": "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/.claude-plugin/plugin.json"
+ },
+ {
+ "body": "# Example\n\nA managed skill.\n",
+ "mode": 384,
+ "path": "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/skills/example/SKILL.md"
+ }
+ ]
+}
diff --git a/crates/gh-config/tests/golden/claude-v2_1_242-gateway.json b/crates/gh-config/tests/golden/claude-v2_1_242-gateway.json
new file mode 100644
index 0000000..385b3de
--- /dev/null
+++ b/crates/gh-config/tests/golden/claude-v2_1_242-gateway.json
@@ -0,0 +1,52 @@
+{
+ "args": [
+ "--settings",
+ "$HOME/.config/blue/runtime/claude/settings.json",
+ "--mcp-config",
+ "$HOME/.config/blue/runtime/claude/mcp.json",
+ "--plugin-dir",
+ "$HOME/package/plugin",
+ "--plugin-dir",
+ "$HOME/.config/blue/runtime/claude/standalone-skills-plugin"
+ ],
+ "env": {
+ "DISABLE_AUTOUPDATER": "1",
+ "HARNESS_PACKAGE_COMPONENTS_SETTINGS": "{\"color\":\"blue\"}",
+ "PATH": "$HOME/package/bin:$PATH"
+ },
+ "files": [
+ "$HOME/.config/blue/runtime/claude/mcp.json",
+ "$HOME/.config/blue/runtime/claude/settings.json",
+ "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/.claude-plugin/plugin.json",
+ "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/skills"
+ ],
+ "ownership": [
+ "$HOME/.config/blue/runtime/claude"
+ ],
+ "removals": [
+ "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/skills"
+ ],
+ "warnings": [],
+ "writes": [
+ {
+ "body": "{\n \"mcpServers\": {\n \"fixture\": {\n \"args\": [\n \"--stdio\"\n ],\n \"command\": \"fixture-mcp\"\n }\n }\n}\n",
+ "mode": 384,
+ "path": "$HOME/.config/blue/runtime/claude/mcp.json"
+ },
+ {
+ "body": "{\n \"availableModels\": [\n \"fixture-model\"\n ],\n \"enforceAvailableModels\": true,\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fixture-token\",\n \"ANTHROPIC_BASE_URL\": \"https://gateway.example\"\n },\n \"hooks\": {\n \"SessionEnd\": [\n {\n \"hooks\": [\n {\n \"command\": \"'$BLUE' session-upload claude --profile 'claude-v2_1_242'\",\n \"timeout\": 30,\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"SessionStart\": [\n {\n \"hooks\": [\n {\n \"command\": \"'$BLUE' session-start claude --profile 'claude-v2_1_242'\",\n \"timeout\": 30,\n \"type\": \"command\"\n }\n ]\n }\n ]\n },\n \"model\": \"fixture-model\",\n \"modelPicker\": {\n \"options\": [\n {\n \"label\": \"fixture-model\",\n \"model\": \"fixture-model\"\n }\n ],\n \"replaceBuiltInOptions\": true\n }\n}\n",
+ "mode": 384,
+ "path": "$HOME/.config/blue/runtime/claude/settings.json"
+ },
+ {
+ "body": "{\n \"name\": \"blue-managed-standalone-skills\",\n \"version\": \"1.0.0\"\n}\n",
+ "mode": 384,
+ "path": "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/.claude-plugin/plugin.json"
+ },
+ {
+ "body": "# Example\n\nA managed skill.\n",
+ "mode": 384,
+ "path": "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/skills/example/SKILL.md"
+ }
+ ]
+}
diff --git a/crates/gh-config/tests/golden/claude-v2_1_242-governance.json b/crates/gh-config/tests/golden/claude-v2_1_242-governance.json
new file mode 100644
index 0000000..9713a32
--- /dev/null
+++ b/crates/gh-config/tests/golden/claude-v2_1_242-governance.json
@@ -0,0 +1,52 @@
+{
+ "args": [
+ "--settings",
+ "$HOME/.config/blue/runtime/claude/settings.json",
+ "--mcp-config",
+ "$HOME/.config/blue/runtime/claude/mcp.json",
+ "--plugin-dir",
+ "$HOME/package/plugin",
+ "--plugin-dir",
+ "$HOME/.config/blue/runtime/claude/standalone-skills-plugin"
+ ],
+ "env": {
+ "DISABLE_AUTOUPDATER": "1",
+ "HARNESS_PACKAGE_COMPONENTS_SETTINGS": "{\"color\":\"blue\"}",
+ "PATH": "$HOME/package/bin:$PATH"
+ },
+ "files": [
+ "$HOME/.config/blue/runtime/claude/mcp.json",
+ "$HOME/.config/blue/runtime/claude/settings.json",
+ "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/.claude-plugin/plugin.json",
+ "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/skills"
+ ],
+ "ownership": [
+ "$HOME/.config/blue/runtime/claude"
+ ],
+ "removals": [
+ "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/skills"
+ ],
+ "warnings": [],
+ "writes": [
+ {
+ "body": "{\n \"mcpServers\": {\n \"fixture\": {\n \"args\": [\n \"--stdio\"\n ],\n \"command\": \"fixture-mcp\"\n }\n }\n}\n",
+ "mode": 384,
+ "path": "$HOME/.config/blue/runtime/claude/mcp.json"
+ },
+ {
+ "body": "{\n \"hooks\": {\n \"SessionEnd\": [\n {\n \"hooks\": [\n {\n \"command\": \"'$BLUE' session-upload claude --profile 'claude-v2_1_242'\",\n \"timeout\": 30,\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"SessionStart\": [\n {\n \"hooks\": [\n {\n \"command\": \"'$BLUE' session-start claude --profile 'claude-v2_1_242'\",\n \"timeout\": 30,\n \"type\": \"command\"\n }\n ]\n }\n ]\n },\n \"model\": \"fixture-model\"\n}\n",
+ "mode": 384,
+ "path": "$HOME/.config/blue/runtime/claude/settings.json"
+ },
+ {
+ "body": "{\n \"name\": \"blue-managed-standalone-skills\",\n \"version\": \"1.0.0\"\n}\n",
+ "mode": 384,
+ "path": "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/.claude-plugin/plugin.json"
+ },
+ {
+ "body": "# Example\n\nA managed skill.\n",
+ "mode": 384,
+ "path": "$HOME/.config/blue/runtime/claude/standalone-skills-plugin/skills/example/SKILL.md"
+ }
+ ]
+}
diff --git a/crates/gh-config/tests/golden/kimi-v0_0_0-gateway-nomodel.json b/crates/gh-config/tests/golden/kimi-v0_0_0-gateway-nomodel.json
index 6496f8b..afdc550 100644
--- a/crates/gh-config/tests/golden/kimi-v0_0_0-gateway-nomodel.json
+++ b/crates/gh-config/tests/golden/kimi-v0_0_0-gateway-nomodel.json
@@ -29,7 +29,7 @@
"path": "$HOME/.config/blue/runtime/kimi/agents/reviewer.md"
},
{
- "body": "default_permission_mode = \"manual\"\nextra_agent_dirs = [\"$HOME/.config/blue/runtime/kimi/agents\"]\nextra_skill_dirs = [\"$HOME/.config/blue/runtime/kimi/skills\"]\n\n[[hooks]]\ncommand = \"'$BLUE' session-upload kimi --profile 'kimi-v0_0_0'\"\nevent = \"SessionEnd\"\ntimeout = 30\n\n[[hooks]]\ncommand = \"'$BLUE' session-upload kimi --profile 'kimi-v0_0_0'\"\nevent = \"Stop\"\ntimeout = 30\n\n[[hooks]]\ncommand = \"'$BLUE' session-start kimi --profile 'kimi-v0_0_0'\"\nevent = \"SessionStart\"\ntimeout = 30\n\n[[hooks]]\ncommand = \"fixture-hook\"\nevent = \"Stop\"\n\n[providers.governed]\napi_key = \"fixture-token\"\nbase_url = \"https://gateway.example\"\ntype = \"openai\"\n",
+ "body": "default_model = \"fixture-model\"\ndefault_permission_mode = \"manual\"\nextra_agent_dirs = [\"$HOME/.config/blue/runtime/kimi/agents\"]\nextra_skill_dirs = [\"$HOME/.config/blue/runtime/kimi/skills\"]\n\n[[hooks]]\ncommand = \"'$BLUE' session-upload kimi --profile 'kimi-v0_0_0'\"\nevent = \"SessionEnd\"\ntimeout = 30\n\n[[hooks]]\ncommand = \"'$BLUE' session-upload kimi --profile 'kimi-v0_0_0'\"\nevent = \"Stop\"\ntimeout = 30\n\n[[hooks]]\ncommand = \"'$BLUE' session-start kimi --profile 'kimi-v0_0_0'\"\nevent = \"SessionStart\"\ntimeout = 30\n\n[[hooks]]\ncommand = \"fixture-hook\"\nevent = \"Stop\"\n\n[models.fixture-model]\nmax_context_size = 262144\nmodel = \"fixture-model\"\nprovider = \"governed\"\n\n[providers.governed]\napi_key = \"fixture-token\"\nbase_url = \"https://gateway.example\"\ntype = \"openai\"\n",
"mode": 384,
"path": "$HOME/.config/blue/runtime/kimi/config.toml"
},
diff --git a/crates/gh-config/tests/golden/opencode-v0_0_0-gateway-nomodel.json b/crates/gh-config/tests/golden/opencode-v0_0_0-gateway-nomodel.json
index d957e22..2a8853f 100644
--- a/crates/gh-config/tests/golden/opencode-v0_0_0-gateway-nomodel.json
+++ b/crates/gh-config/tests/golden/opencode-v0_0_0-gateway-nomodel.json
@@ -3,7 +3,7 @@
"env": {
"BLUE_OPENCODE_GATEWAY_TOKEN": "fixture-token",
"HARNESS_PACKAGE_COMPONENTS_SETTINGS": "{\"color\":\"blue\"}",
- "OPENCODE_CONFIG_CONTENT": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"autoupdate\": false,\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/openai/gpt-5.6-sol\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"openai/gpt-5.6-sol\": {\n \"name\": \"openai/gpt-5.6-sol\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n",
+ "OPENCODE_CONFIG_CONTENT": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"autoupdate\": false,\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/fixture-model\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"fixture-model\": {\n \"name\": \"fixture-model\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n",
"OPENCODE_CONFIG_DIR": "$HOME/.config/blue/runtime/opencode",
"OPENCODE_DISABLE_AUTOUPDATE": "1",
"PATH": "$HOME/package/bin:$PATH"
@@ -30,7 +30,7 @@
"$HOME/.config/blue/runtime/opencode/skills"
],
"warnings": [
- "`harnesses.opencode.managed_config.model` is unset; defaulting to `openai/gpt-5.6-sol`. Set it in your governance config to pin the model your gateway serves."
+ "`harnesses.opencode.managed_config.model` is unset; defaulting to `fixture-model`. Set it in your governance config to pin the model your gateway serves."
],
"writes": [
{
@@ -39,7 +39,7 @@
"path": "$HOME/.config/blue/runtime/opencode/agents/reviewer.md"
},
{
- "body": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/openai/gpt-5.6-sol\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"openai/gpt-5.6-sol\": {\n \"name\": \"openai/gpt-5.6-sol\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n",
+ "body": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/fixture-model\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"fixture-model\": {\n \"name\": \"fixture-model\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n",
"mode": 384,
"path": "$HOME/.config/blue/runtime/opencode/opencode.json"
},
diff --git a/crates/gh-config/tests/golden/opencode-v0_0_0-gateway.json b/crates/gh-config/tests/golden/opencode-v0_0_0-gateway.json
index 76b3651..0cde174 100644
--- a/crates/gh-config/tests/golden/opencode-v0_0_0-gateway.json
+++ b/crates/gh-config/tests/golden/opencode-v0_0_0-gateway.json
@@ -3,7 +3,7 @@
"env": {
"BLUE_OPENCODE_GATEWAY_TOKEN": "fixture-token",
"HARNESS_PACKAGE_COMPONENTS_SETTINGS": "{\"color\":\"blue\"}",
- "OPENCODE_CONFIG_CONTENT": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"autoupdate\": false,\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/fixture-model\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"fixture-model\": {\n \"name\": \"fixture-model\"\n },\n \"openai/gpt-5.6-sol\": {\n \"name\": \"openai/gpt-5.6-sol\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n",
+ "OPENCODE_CONFIG_CONTENT": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"autoupdate\": false,\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/fixture-model\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"fixture-model\": {\n \"name\": \"fixture-model\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n",
"OPENCODE_CONFIG_DIR": "$HOME/.config/blue/runtime/opencode",
"OPENCODE_DISABLE_AUTOUPDATE": "1",
"PATH": "$HOME/package/bin:$PATH"
@@ -37,7 +37,7 @@
"path": "$HOME/.config/blue/runtime/opencode/agents/reviewer.md"
},
{
- "body": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/fixture-model\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"fixture-model\": {\n \"name\": \"fixture-model\"\n },\n \"openai/gpt-5.6-sol\": {\n \"name\": \"openai/gpt-5.6-sol\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n",
+ "body": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/fixture-model\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"fixture-model\": {\n \"name\": \"fixture-model\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n",
"mode": 384,
"path": "$HOME/.config/blue/runtime/opencode/opencode.json"
},
diff --git a/crates/gh-gateway-provisioner/Cargo.toml b/crates/gh-gateway-provisioner/Cargo.toml
index 87a57f5..4066632 100644
--- a/crates/gh-gateway-provisioner/Cargo.toml
+++ b/crates/gh-gateway-provisioner/Cargo.toml
@@ -19,3 +19,7 @@ sha2 = { workspace = true, optional = true }
thiserror = { workspace = true }
tracing = { workspace = true, optional = true }
zeroize = { workspace = true }
+
+[dev-dependencies]
+axum = { workspace = true }
+tokio = { workspace = true }
diff --git a/crates/gh-gateway-provisioner/src/lib.rs b/crates/gh-gateway-provisioner/src/lib.rs
index 7e6a9ad..2bcae23 100644
--- a/crates/gh-gateway-provisioner/src/lib.rs
+++ b/crates/gh-gateway-provisioner/src/lib.rs
@@ -10,6 +10,7 @@ pub mod litellm;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
+use std::collections::BTreeMap;
use thiserror::Error;
use zeroize::{Zeroize, ZeroizeOnDrop};
@@ -99,6 +100,23 @@ pub struct RevokeResponse {
pub revoked: bool,
}
+/// A provider-neutral model returned by gateway discovery.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct DiscoveredModel {
+ pub id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub display_name: Option,
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub metadata: BTreeMap,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct ModelCatalog {
+ pub models: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source_revision: Option,
+}
+
#[derive(Debug, Error)]
pub enum ProvisionerError {
#[error("invalid provisioning configuration: {0}")]
@@ -113,6 +131,12 @@ pub enum ProvisionerError {
Unavailable(String),
#[error("gateway rejected provisioning: {0}")]
Rejected(String),
+ #[error("gateway model discovery is unsupported")]
+ DiscoveryUnsupported,
+ #[error("gateway rejected model discovery authentication: {0}")]
+ DiscoveryAuth(String),
+ #[error("gateway returned an invalid model catalog: {0}")]
+ DiscoveryResponse(String),
}
#[async_trait]
@@ -126,6 +150,13 @@ pub trait GatewayProvisioner: Send + Sync + 'static {
None
}
+ /// Discover the gateway's current model catalog. Provisioners predating
+ /// this capability remain source-compatible and explicitly report that
+ /// discovery is unsupported.
+ async fn list_models(&self) -> Result {
+ Err(ProvisionerError::DiscoveryUnsupported)
+ }
+
async fn ensure(
&self,
request: EnsureRequest,
diff --git a/crates/gh-gateway-provisioner/src/litellm/mod.rs b/crates/gh-gateway-provisioner/src/litellm/mod.rs
index 3bb199c..a591036 100644
--- a/crates/gh-gateway-provisioner/src/litellm/mod.rs
+++ b/crates/gh-gateway-provisioner/src/litellm/mod.rs
@@ -1,6 +1,6 @@
use crate::{
- EnsureRequest, GatewayProvisioner, ProvisionedCredential, ProvisionerError, RevokeRequest,
- RevokeResponse, SecretString,
+ DiscoveredModel, EnsureRequest, GatewayProvisioner, ModelCatalog, ProvisionedCredential,
+ ProvisionerError, RevokeRequest, RevokeResponse, SecretString,
};
use async_trait::async_trait;
use serde_json::{json, Value};
@@ -294,6 +294,66 @@ impl GatewayProvisioner for LiteLlmProvisioner {
"builtin-litellm"
}
+ async fn list_models(&self) -> Result {
+ let url = format!("{}/v1/models", self.base_url.trim_end_matches('/'));
+ let response = self
+ .http
+ .get(url)
+ .bearer_auth(&self.admin_key)
+ .header("accept", "application/json")
+ .send()
+ .await
+ .map_err(|_| ProvisionerError::Unavailable("model discovery request failed".into()))?;
+ let status = response.status();
+ let bytes = response.bytes().await.map_err(|_| {
+ ProvisionerError::DiscoveryResponse("gateway returned an unreadable response".into())
+ })?;
+ if matches!(
+ status,
+ reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
+ ) {
+ return Err(ProvisionerError::DiscoveryAuth(
+ self.rejection_message(status, &bytes),
+ ));
+ }
+ if !status.is_success() {
+ return Err(ProvisionerError::Unavailable(
+ self.rejection_message(status, &bytes),
+ ));
+ }
+ let value: Value = serde_json::from_slice(&bytes).map_err(|_| {
+ ProvisionerError::DiscoveryResponse("gateway returned invalid JSON".into())
+ })?;
+ let entries = value.get("data").and_then(Value::as_array).ok_or_else(|| {
+ ProvisionerError::DiscoveryResponse("response has no data array".into())
+ })?;
+ let mut models = entries
+ .iter()
+ .filter_map(|entry| entry.get("id").and_then(Value::as_str))
+ .map(str::trim)
+ .filter(|id| !id.is_empty())
+ .map(|id| DiscoveredModel {
+ id: id.to_owned(),
+ display_name: None,
+ // LiteLLM's OpenAI-compatible endpoint guarantees `id`; keep
+ // undocumented and potentially volatile fields out of drift.
+ metadata: Default::default(),
+ })
+ .collect::>();
+ models.sort_by(|left, right| left.id.cmp(&right.id));
+ models.dedup_by(|left, right| left.id == right.id);
+ let revision = hex::encode(Sha256::digest(
+ models
+ .iter()
+ .flat_map(|model| model.id.as_bytes().iter().copied().chain([0]))
+ .collect::>(),
+ ));
+ Ok(ModelCatalog {
+ models,
+ source_revision: Some(revision),
+ })
+ }
+
async fn ensure(
&self,
request: EnsureRequest,
@@ -429,3 +489,104 @@ impl GatewayProvisioner for LiteLlmProvisioner {
Ok(RevokeResponse { revoked: true })
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use axum::{
+ extract::Request, http::StatusCode, response::IntoResponse, routing::get, Json, Router,
+ };
+
+ async fn provisioner(router: Router) -> LiteLlmProvisioner {
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ tokio::spawn(async move {
+ axum::serve(listener, router).await.unwrap();
+ });
+ LiteLlmProvisioner {
+ base_url: format!("http://{address}"),
+ admin_key: "sk-test-admin".into(),
+ http: reqwest::Client::new(),
+ }
+ }
+
+ #[tokio::test]
+ async fn model_discovery_authenticates_sorts_deduplicates_and_hashes() {
+ let provisioner = provisioner(Router::new().route(
+ "/v1/models",
+ get(|request: Request| async move {
+ assert_eq!(
+ request.headers().get("authorization").unwrap(),
+ "Bearer sk-test-admin"
+ );
+ Json(json!({
+ "data": [
+ {"id": "model-z"},
+ {"id": " model-a "},
+ {"id": "model-z"},
+ {"missing": "id"},
+ {"id": ""}
+ ]
+ }))
+ }),
+ ))
+ .await;
+
+ let first = provisioner.list_models().await.unwrap();
+ let second = provisioner.list_models().await.unwrap();
+ assert_eq!(
+ first
+ .models
+ .iter()
+ .map(|model| model.id.as_str())
+ .collect::>(),
+ ["model-a", "model-z"]
+ );
+ assert_eq!(first.source_revision, second.source_revision);
+ assert!(first.source_revision.is_some());
+ }
+
+ #[tokio::test]
+ async fn model_discovery_classifies_authentication_failures() {
+ let provisioner = provisioner(Router::new().route(
+ "/v1/models",
+ get(|| async {
+ (
+ StatusCode::UNAUTHORIZED,
+ Json(json!({"error": {"message": "bad sk-test-admin"}})),
+ )
+ .into_response()
+ }),
+ ))
+ .await;
+
+ let error = provisioner.list_models().await.unwrap_err();
+ assert!(matches!(error, ProvisionerError::DiscoveryAuth(_)));
+ assert!(!error.to_string().contains("sk-test-admin"));
+ }
+
+ #[tokio::test]
+ async fn model_discovery_rejects_malformed_catalogs() {
+ let provisioner = provisioner(
+ Router::new().route("/v1/models", get(|| async { Json(json!({"models": []})) })),
+ )
+ .await;
+ assert!(matches!(
+ provisioner.list_models().await.unwrap_err(),
+ ProvisionerError::DiscoveryResponse(_)
+ ));
+ }
+
+ #[tokio::test]
+ async fn model_discovery_classifies_upstream_failures() {
+ let provisioner = provisioner(Router::new().route(
+ "/v1/models",
+ get(|| async { StatusCode::SERVICE_UNAVAILABLE }),
+ ))
+ .await;
+ assert!(matches!(
+ provisioner.list_models().await.unwrap_err(),
+ ProvisionerError::Unavailable(_)
+ ));
+ }
+}
diff --git a/crates/gh-service/src/schema.rs b/crates/gh-service/src/schema.rs
index 7d9c066..4af2760 100644
--- a/crates/gh-service/src/schema.rs
+++ b/crates/gh-service/src/schema.rs
@@ -63,7 +63,7 @@ pub struct GovernanceConfig {
impl GovernanceConfig {
pub const DEFAULT_TTL_SECONDS: u64 = 300;
- pub const CONTRACT_VERSION: u32 = 3;
+ pub const CONTRACT_VERSION: u32 = 4;
pub const CAPABILITIES: &'static [&'static str] = &[
"tenant_client_version_pin",
"adapter_intervals",
@@ -72,6 +72,7 @@ impl GovernanceConfig {
"versioned_state",
"unverified_harness_versions",
"gateway_inference_jwt",
+ "gateway_model_catalog",
];
pub fn ttl_seconds(&self) -> u64 {
@@ -139,6 +140,10 @@ pub struct HarnessPolicy {
/// scoped by an administrator-authored range.
#[serde(default, skip_serializing_if = "is_false")]
pub allow_unverified_versions: bool,
+ /// Exact provider model IDs assigned to this harness in gateway mode.
+ /// Direct-mode adapters intentionally ignore this catalog.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub gateway_models: Vec,
/// Model / approval / permission flags written into the harness's config.
#[serde(default)]
pub managed_config: ManagedConfig,
diff --git a/deploy/blue.gateway.yaml b/deploy/blue.gateway.yaml
index 1a45497..afc96f7 100644
--- a/deploy/blue.gateway.yaml
+++ b/deploy/blue.gateway.yaml
@@ -21,8 +21,9 @@ gateway:
provisioner:
type: local-executable
executable_path: /etc/blue/local-gateway-provisioner.mjs
- executable_sha256: 7f6e3f4d26667418aa8f311b3a00f2eb775e954b073a1f1de947f706b26a8e28
+ executable_sha256: 95f8f55cb695615db465f5184a97933ad2e83811420f4baa979c03c95ba0f965
policy_revision: local-executable-v4
reconcile_ttl_seconds: 86400
+ model_catalog_refresh_seconds: 300
timeout_seconds: 15
max_concurrency: 8
diff --git a/deploy/blue.yaml b/deploy/blue.yaml
index d05570c..6c81209 100644
--- a/deploy/blue.yaml
+++ b/deploy/blue.yaml
@@ -55,16 +55,17 @@ control_api:
# is projected into this policy; runtime settings and secrets never leave the server.
governance:
revision: "2026-09-06.1"
- contract_version: 3
+ contract_version: 4
required_capabilities:
- adapter_intervals
- compiled_harness_registry
- transactional_reconcile
- # Required when any harness enables allow_unverified_versions.
+ # Required when any harness enables allow_unverified_versions.
# - unverified_harness_versions
- versioned_state
# Required when the gateway overlay is active.
- gateway_inference_jwt
+ - gateway_model_catalog
# Required when harness version ranges or versioned package adapters are used.
minimum_client_version: "0.1.0"
# Exact recommended release and major compatibility boundary. Same-major
@@ -82,8 +83,8 @@ governance:
# config format. Also add unverified_harness_versions to
# required_capabilities so older clients fail closed.
# allow_unverified_versions: true
+ gateway_models: [gpt-5.6-sol]
managed_config:
- model: gpt-5.6-sol
reasoning_effort: low
fast_mode: false
sandbox_mode: workspace-write
@@ -92,15 +93,13 @@ governance:
# generation onward. Keep the local stack from selecting an older native
# layout for packages added through the dashboard.
version_requirement: ">=2.0.12, <2.1.253-0"
- managed_config:
- model: claude-opus-4-8
+ gateway_models: [gpt-5.6-sol]
kimi:
+ gateway_models: [gpt-5.6-sol]
managed_config:
- model: moonshot-ai/kimi-k2.6
auto_approve: true
opencode:
- managed_config:
- model: anthropic/claude-opus-4-8
+ gateway_models: [gpt-5.6-sol]
# Curated choices shown by Extensions. Selecting one copies its immutable,
# digest-pinned recipe into an organization governance revision.
diff --git a/deploy/consumer/blue/blue.yaml b/deploy/consumer/blue/blue.yaml
index c822d3d..4b54549 100644
--- a/deploy/consumer/blue/blue.yaml
+++ b/deploy/consumer/blue/blue.yaml
@@ -56,10 +56,11 @@ control_api:
# reconcile_ttl_seconds: 86400
# max_concurrency: 8
# timeout_seconds: 15
+# model_catalog_refresh_seconds: 300
governance:
revision: "2026-08-30.1"
- contract_version: 3
+ contract_version: 4
required_capabilities:
- adapter_intervals
- compiled_harness_registry
@@ -67,6 +68,7 @@ governance:
- versioned_state
# Required when gateway is enabled.
# - gateway_inference_jwt
+ # - gateway_model_catalog
minimum_client_version: "0.1.0"
# Exact recommended release and major compatibility boundary. Same-major
# differences warn and continue; different majors block.
@@ -75,7 +77,7 @@ governance:
required: true
allowed_harnesses: [codex, claude, kimi, opencode]
harnesses:
- codex: { managed_config: {} }
- claude: { managed_config: {} }
- kimi: { managed_config: {} }
- opencode: { managed_config: {} }
+ codex: { gateway_models: [gpt-5.6-sol], managed_config: {} }
+ claude: { gateway_models: [claude-opus-4-8], managed_config: {} }
+ kimi: { gateway_models: [moonshot-ai/kimi-k2.6], managed_config: {} }
+ opencode: { gateway_models: [anthropic/claude-opus-4-8], managed_config: {} }
diff --git a/deploy/consumer/provisioner.sh b/deploy/consumer/provisioner.sh
index d0f034d..3dd4165 100755
--- a/deploy/consumer/provisioner.sh
+++ b/deploy/consumer/provisioner.sh
@@ -2,6 +2,7 @@
set -eu
request=$(cat)
case "$request" in
+ *'"operation":"list_models"'*) printf '%s\n' '{"protocol_version":1,"status":"error","error":{"code":"discovery_unsupported","message":"example provisioner does not implement model discovery"}}'; exit 1 ;;
*'"operation":"ensure"'*) printf '%s\n' '{"protocol_version":1,"status":"success","result":{"credential":"replace-with-created-key","external_id":"example","alias":"example","metadata":{"runtime":"shell"},"expires_at":null}}' ;;
*'"operation":"revoke"'*) printf '%s\n' '{"protocol_version":1,"status":"success","result":{"revoked":true}}' ;;
*) printf '%s\n' '{"protocol_version":1,"status":"error","error":{"code":"invalid_config","message":"unsupported request"}}'; exit 1 ;;
diff --git a/deploy/contract/governance.openapi.yaml b/deploy/contract/governance.openapi.yaml
index 3062328..61b8857 100644
--- a/deploy/contract/governance.openapi.yaml
+++ b/deploy/contract/governance.openapi.yaml
@@ -603,6 +603,92 @@ paths:
type: array
items: { $ref: "#/components/schemas/ManagedPackage" }
+ /admin/gateway/models:
+ get:
+ operationId: listGatewayModels
+ tags: [Administration]
+ summary: Read discovered gateway models and per-harness assignments
+ responses:
+ "200":
+ description: Last-known catalog, discovery status, and revisioned assignments
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/GatewayModelsResource" }
+ "404": { description: Gateway mode is disabled }
+
+ /admin/gateway/models/refresh:
+ post:
+ operationId: refreshGatewayModels
+ tags: [Administration]
+ summary: Refresh the gateway model catalog immediately
+ responses:
+ "200":
+ description: Refreshed model resource
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/GatewayModelsResource" }
+ "409": { description: A refresh is already running }
+ "501": { description: The selected provisioner does not support discovery }
+ "502": { description: Gateway discovery failed; last-known state was retained }
+
+ /admin/gateway/models/acknowledge:
+ post:
+ operationId: acknowledgeGatewayModel
+ tags: [Administration]
+ summary: Acknowledge the current metadata fingerprint for a model
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [model_id, fingerprint]
+ properties:
+ model_id: { type: string }
+ fingerprint: { type: string }
+ responses:
+ "200": { description: Fingerprint acknowledged }
+ "409": { description: Model or fingerprint changed concurrently }
+
+ /admin/gateway/models/harnesses/{harness}:
+ put:
+ operationId: updateHarnessGatewayModels
+ tags: [Administration]
+ summary: Save one harness's assigned models and optional default
+ parameters:
+ - name: harness
+ in: path
+ required: true
+ schema: { type: string }
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [base_revision, gateway_models]
+ properties:
+ base_revision: { type: string }
+ gateway_models:
+ type: array
+ minItems: 1
+ uniqueItems: true
+ items: { type: string, minLength: 1 }
+ default_model: { type: [string, "null"] }
+ responses:
+ "200":
+ description: Saved governance revision
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [revision, harness]
+ properties:
+ revision: { type: string }
+ harness: { type: string }
+ "400": { description: Assignment or default is invalid }
+ "409": { description: Base revision is stale }
+
/admin/package-source/inspect:
post:
operationId: inspectManagedPackageSource
@@ -1549,6 +1635,57 @@ components:
- $ref: "#/components/schemas/GatewayRuntimeChecks"
- type: "null"
+ GatewayModelState:
+ type: string
+ enum: [available, changed, removed, unknown]
+
+ GatewayDiscoveredModel:
+ type: object
+ required: [id, metadata, state, assigned_to]
+ properties:
+ id: { type: string }
+ display_name: { type: [string, "null"] }
+ metadata: { type: object, additionalProperties: true }
+ fingerprint: { type: [string, "null"] }
+ state: { $ref: "#/components/schemas/GatewayModelState" }
+ assigned_to: { type: array, items: { type: string } }
+ first_seen_at: { type: [string, "null"], format: date-time }
+ last_seen_at: { type: [string, "null"], format: date-time }
+ unavailable_since: { type: [string, "null"], format: date-time }
+
+ GatewayHarnessModels:
+ type: object
+ required: [key, label, exposure, gateway_models]
+ properties:
+ key: { type: string }
+ label: { type: string }
+ exposure: { type: string, enum: [catalog, selected_only] }
+ gateway_models: { type: array, items: { type: string } }
+ default_model: { type: [string, "null"] }
+
+ GatewayModelSync:
+ type: object
+ required: [stale]
+ properties:
+ last_refresh_at: { type: [string, "null"], format: date-time }
+ last_successful_refresh_at: { type: [string, "null"], format: date-time }
+ source_revision: { type: [string, "null"] }
+ latest_error: { type: [string, "null"] }
+ latest_error_at: { type: [string, "null"], format: date-time }
+ discovery_supported: { type: [boolean, "null"] }
+ stale: { type: boolean }
+
+ GatewayModelsResource:
+ type: object
+ required: [revision, gateway_type, refresh_interval_seconds, sync, models, harnesses]
+ properties:
+ revision: { type: string }
+ gateway_type: { type: string }
+ refresh_interval_seconds: { type: integer, minimum: 30 }
+ sync: { $ref: "#/components/schemas/GatewayModelSync" }
+ models: { type: array, items: { $ref: "#/components/schemas/GatewayDiscoveredModel" } }
+ harnesses: { type: array, items: { $ref: "#/components/schemas/GatewayHarnessModels" } }
+
GatewayProxyHealthResponse:
type: object
required: [status, checked_at, latency_ms]
@@ -1778,6 +1915,11 @@ components:
properties:
version_requirement: { type: string, description: Semver range required for the locally installed harness }
allow_unverified_versions: { type: boolean, default: false, description: Permit matching releases beyond Blue's certified ceiling; requires version_requirement }
+ gateway_models:
+ type: array
+ uniqueItems: true
+ description: Exact gateway model IDs assigned to this harness; ignored in direct mode.
+ items: { type: string, minLength: 1 }
managed_config: { $ref: "#/components/schemas/ManagedConfig" }
mcp:
type: array
diff --git a/deploy/local-gateway-provisioner.mjs b/deploy/local-gateway-provisioner.mjs
index ba0065e..27060ad 100755
--- a/deploy/local-gateway-provisioner.mjs
+++ b/deploy/local-gateway-provisioner.mjs
@@ -189,13 +189,34 @@ async function revoke(request) {
return { revoked: true };
}
+async function listModels() {
+ const payload = await litellm("GET", "/v1/models");
+ if (!Array.isArray(payload?.data)) {
+ throw new ProvisionerError("discovery_response", "LiteLLM response has no data array");
+ }
+ const ids = [...new Set(
+ payload.data
+ .map((model) => typeof model?.id === "string" ? model.id.trim() : "")
+ .filter(Boolean),
+ )].sort();
+ const sourceRevision = createHash("sha256")
+ .update(ids.map((id) => `${id}\0`).join(""))
+ .digest("hex");
+ return {
+ models: ids.map((id) => ({ id })),
+ source_revision: sourceRevision,
+ };
+}
+
try {
const envelope = await readEnvelope();
if (envelope.protocol_version !== PROTOCOL_VERSION) {
throw new ProvisionerError("invalid_config", "unsupported protocol version");
}
const result =
- envelope.operation === "ensure"
+ envelope.operation === "list_models"
+ ? await listModels()
+ : envelope.operation === "ensure"
? await ensure(envelope.request)
: envelope.operation === "revoke"
? await revoke(envelope.request)
diff --git a/services/control-api/migration-classifications.json b/services/control-api/migration-classifications.json
index 18a1da4..df163f1 100644
--- a/services/control-api/migration-classifications.json
+++ b/services/control-api/migration-classifications.json
@@ -20,6 +20,16 @@
"version": 34,
"classification": "expand",
"raises_compatibility_floor": false
+ },
+ {
+ "version": 35,
+ "classification": "expand",
+ "raises_compatibility_floor": false
+ },
+ {
+ "version": 36,
+ "classification": "expand",
+ "raises_compatibility_floor": false
}
]
}
diff --git a/services/control-api/migrations/0035_gateway_model_catalog.sql b/services/control-api/migrations/0035_gateway_model_catalog.sql
new file mode 100644
index 0000000..f4d9d93
--- /dev/null
+++ b/services/control-api/migrations/0035_gateway_model_catalog.sql
@@ -0,0 +1,24 @@
+CREATE TABLE public.gateway_model_catalog (
+ organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
+ gateway_type text NOT NULL,
+ model_id text NOT NULL CHECK (btrim(model_id) <> ''),
+ display_name text,
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
+ fingerprint text NOT NULL,
+ first_seen_at timestamptz NOT NULL DEFAULT now(),
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
+ unavailable_since timestamptz,
+ PRIMARY KEY (organization_id, gateway_type, model_id)
+);
+
+CREATE TABLE public.gateway_model_catalog_sync_state (
+ organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
+ gateway_type text NOT NULL,
+ last_refresh_at timestamptz,
+ last_successful_refresh_at timestamptz,
+ last_successful_source_revision text,
+ latest_error text,
+ latest_error_at timestamptz,
+ discovery_supported boolean,
+ PRIMARY KEY (organization_id, gateway_type)
+);
diff --git a/services/control-api/migrations/0036_gateway_model_catalog_acknowledgement.sql b/services/control-api/migrations/0036_gateway_model_catalog_acknowledgement.sql
new file mode 100644
index 0000000..8bc638e
--- /dev/null
+++ b/services/control-api/migrations/0036_gateway_model_catalog_acknowledgement.sql
@@ -0,0 +1,15 @@
+ALTER TABLE public.gateway_model_catalog
+ ADD COLUMN acknowledged_fingerprint text,
+ ADD COLUMN acknowledged_at timestamptz,
+ ADD COLUMN acknowledged_by uuid REFERENCES public.users(id) ON DELETE SET NULL;
+
+UPDATE public.gateway_model_catalog
+SET acknowledged_fingerprint = fingerprint,
+ acknowledged_at = first_seen_at;
+
+ALTER TABLE public.gateway_model_catalog
+ ALTER COLUMN acknowledged_fingerprint SET NOT NULL,
+ ALTER COLUMN acknowledged_at SET NOT NULL;
+
+CREATE INDEX gateway_model_catalog_availability_idx
+ ON public.gateway_model_catalog (organization_id, gateway_type, unavailable_since, model_id);
diff --git a/services/control-api/src/executable_provisioner.rs b/services/control-api/src/executable_provisioner.rs
index eac4af2..db812cc 100644
--- a/services/control-api/src/executable_provisioner.rs
+++ b/services/control-api/src/executable_provisioner.rs
@@ -5,8 +5,8 @@ use std::time::Duration;
use async_trait::async_trait;
use gh_gateway_provisioner::{
- EnsureRequest, GatewayProvisioner, ProvisionedCredential, ProvisionerError, RevokeRequest,
- RevokeResponse,
+ EnsureRequest, GatewayProvisioner, ModelCatalog, ProvisionedCredential, ProvisionerError,
+ RevokeRequest, RevokeResponse,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -245,6 +245,9 @@ fn map_error(error: WireError) -> ProvisionerError {
"credential_invalid" => ProvisionerError::CredentialInvalid(error.message),
"unavailable" => ProvisionerError::Unavailable(error.message),
"rejected" => ProvisionerError::Rejected(error.message),
+ "unsupported" | "discovery_unsupported" => ProvisionerError::DiscoveryUnsupported,
+ "discovery_auth" => ProvisionerError::DiscoveryAuth(error.message),
+ "discovery_response" => ProvisionerError::DiscoveryResponse(error.message),
_ => unavailable("provisioner returned an unsupported error code"),
}
}
@@ -275,6 +278,9 @@ impl GatewayProvisioner for ExecutableGatewayProvisioner {
async fn revoke(&self, request: RevokeRequest) -> Result {
self.invoke("revoke", request).await
}
+ async fn list_models(&self) -> Result {
+ self.invoke("list_models", serde_json::json!({})).await
+ }
}
#[cfg(test)]
@@ -359,6 +365,7 @@ mod tests {
policy_revision: Some("v1".into()),
timeout_seconds: 1,
max_concurrency: 8,
+ model_catalog_refresh_seconds: 300,
};
assert!(ExecutableGatewayProvisioner::load(&config).await.is_ok());
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
@@ -454,6 +461,9 @@ mod tests {
assert_eq!(message, "public");
502
}
+ ProvisionerError::DiscoveryUnsupported
+ | ProvisionerError::DiscoveryAuth(_)
+ | ProvisionerError::DiscoveryResponse(_) => 502,
};
assert_eq!(actual, expected_status);
}
diff --git a/services/control-api/src/lib.rs b/services/control-api/src/lib.rs
index 66b5a56..ea9ba46 100644
--- a/services/control-api/src/lib.rs
+++ b/services/control-api/src/lib.rs
@@ -22,7 +22,7 @@ use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use futures_util::StreamExt;
-use gh_gateway_provisioner::{GatewayProvisioner, ProvisionerError};
+use gh_gateway_provisioner::{DiscoveredModel, GatewayProvisioner, ModelCatalog, ProvisionerError};
use jsonwebtoken::jwk::{Jwk, JwkSet};
use jsonwebtoken::{
decode, decode_header, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation,
@@ -42,6 +42,9 @@ use secrets::{Envelope, SecretProtector};
static INTERNAL_TLS_RELOAD_SUCCESSES: AtomicU64 = AtomicU64::new(0);
static INTERNAL_TLS_RELOAD_ERRORS: AtomicU64 = AtomicU64::new(0);
static WORKER_LAST_SUCCESS_UNIX: AtomicU64 = AtomicU64::new(0);
+static MODEL_CATALOG_REFRESH_SUCCESSES: AtomicU64 = AtomicU64::new(0);
+static MODEL_CATALOG_REFRESH_ERRORS: AtomicU64 = AtomicU64::new(0);
+static MODEL_CATALOG_REFRESH_UNSUPPORTED: AtomicU64 = AtomicU64::new(0);
fn record_worker_success() {
let now = std::time::SystemTime::now()
@@ -109,6 +112,8 @@ pub struct GatewayProvisionerConfig {
pub timeout_seconds: u64,
#[serde(default = "default_provisioner_max_concurrency")]
pub max_concurrency: usize,
+ #[serde(default = "default_model_catalog_refresh_seconds")]
+ pub model_catalog_refresh_seconds: u64,
}
fn default_reconcile_ttl() -> i64 {
@@ -120,6 +125,9 @@ fn default_provisioner_timeout() -> u64 {
fn default_provisioner_max_concurrency() -> usize {
8
}
+fn default_model_catalog_refresh_seconds() -> u64 {
+ 300
+}
#[derive(Clone, Debug, Deserialize)]
pub struct GatewayEncryptionConfig {
pub provider: String,
@@ -609,6 +617,11 @@ fn gateway_provisioner(
settings,
&["gateway", "provisioner", "executable_sha256"],
)?;
+ if let Ok(value) = std::env::var("HARNESS_GATEWAY_MODEL_CATALOG_REFRESH_SECONDS") {
+ config.model_catalog_refresh_seconds = value.parse().map_err(|_| {
+ ApiError::internal("HARNESS_GATEWAY_MODEL_CATALOG_REFRESH_SECONDS must be an integer")
+ })?;
+ }
if config.kind.trim().is_empty() {
return Err(ApiError::internal(
"gateway.provisioner.type must not be empty",
@@ -629,6 +642,11 @@ fn gateway_provisioner(
"gateway.provisioner.max_concurrency must be greater than zero",
));
}
+ if config.model_catalog_refresh_seconds < 30 {
+ return Err(ApiError::internal(
+ "gateway.provisioner.model_catalog_refresh_seconds must be at least 30",
+ ));
+ }
Ok(Some(config))
}
@@ -1550,6 +1568,9 @@ async fn build_app_inner(
if state.config.run_background_jobs {
tokio::spawn(cleanup_gateway_request_logs(state.gateway_log_pool.clone()));
tokio::spawn(process_gateway_revocations(state.clone()));
+ if state.gateway_provisioner.is_some() && state.config.gateway_kind.is_some() {
+ tokio::spawn(refresh_gateway_model_catalogs(state.clone()));
+ }
}
let public_routes = Router::new()
@@ -1634,6 +1655,19 @@ async fn build_app_inner(
.route("/admin/blue-config/export", get(export_blue_config))
.route("/admin/branding", axum::routing::put(update_branding))
.route("/admin/package-catalog", get(package_catalog))
+ .route("/admin/gateway/models", get(admin_gateway_models))
+ .route(
+ "/admin/gateway/models/refresh",
+ post(refresh_admin_gateway_models),
+ )
+ .route(
+ "/admin/gateway/models/acknowledge",
+ post(acknowledge_gateway_model),
+ )
+ .route(
+ "/admin/gateway/models/harnesses/:harness",
+ axum::routing::put(update_harness_gateway_models),
+ )
.route(
"/admin/package-source/connections",
get(package_source_connections_api),
@@ -1802,7 +1836,10 @@ async fn control_metrics(State(state): State>) -> String {
"gateway_control_log_pool_idle {}\n",
"gateway_control_kms_permits_available {}\n",
"gateway_control_tls_reload_successes_total {}\n",
- "gateway_control_tls_reload_errors_total {}\n"
+ "gateway_control_tls_reload_errors_total {}\n",
+ "gateway_control_model_catalog_refresh_successes_total {}\n",
+ "gateway_control_model_catalog_refresh_errors_total {}\n",
+ "gateway_control_model_catalog_refresh_unsupported_total {}\n"
),
state.pool.size(),
state.pool.num_idle(),
@@ -1811,6 +1848,9 @@ async fn control_metrics(State(state): State>) -> String {
state.gateway_kms_permits.available_permits(),
INTERNAL_TLS_RELOAD_SUCCESSES.load(Ordering::Relaxed),
INTERNAL_TLS_RELOAD_ERRORS.load(Ordering::Relaxed),
+ MODEL_CATALOG_REFRESH_SUCCESSES.load(Ordering::Relaxed),
+ MODEL_CATALOG_REFRESH_ERRORS.load(Ordering::Relaxed),
+ MODEL_CATALOG_REFRESH_UNSUPPORTED.load(Ordering::Relaxed),
)
}
@@ -2711,6 +2751,12 @@ async fn reconcile_deployment_governance(
.map_err(|error| {
ApiError::internal(format!("decoding merged governance config: {error}"))
})?;
+ // The dashboard may have saved its capability list before the deployment
+ // enabled a newer gateway feature. Reassert the server-owned gateway mode
+ // after the three-way merge so its required capability floor cannot be
+ // lost to an older saved revision.
+ apply_deployment_gateway_policy(&mut merged, config.gateway_kind.as_deref());
+ let merged_value = normalized_governance_value(&merged)?;
validate_complete_governance(&merged).map_err(|error| {
ApiError::internal(format!("merged governance config is invalid: {error}"))
})?;
@@ -3640,6 +3686,542 @@ async fn gateway_status(
}))
}
+#[derive(Debug, FromRow)]
+struct GatewayModelCatalogRow {
+ model_id: String,
+ display_name: Option,
+ metadata: serde_json::Value,
+ fingerprint: String,
+ acknowledged_fingerprint: String,
+ first_seen_at: OffsetDateTime,
+ last_seen_at: OffsetDateTime,
+ unavailable_since: Option,
+}
+
+#[derive(Debug, FromRow)]
+struct GatewayModelSyncRow {
+ last_refresh_at: Option,
+ last_successful_refresh_at: Option,
+ last_successful_source_revision: Option,
+ latest_error: Option,
+ latest_error_at: Option,
+ discovery_supported: Option,
+}
+
+fn timestamp(value: Option) -> Option {
+ value.and_then(|value| value.format(&Rfc3339).ok())
+}
+
+fn discovered_model_fingerprint(model: &DiscoveredModel) -> Result {
+ let canonical = serde_json::to_vec(&json!({
+ "id": model.id,
+ "display_name": model.display_name,
+ "metadata": model.metadata,
+ }))
+ .map_err(|error| ApiError::internal(format!("serializing discovered model: {error}")))?;
+ Ok(hex::encode(Sha256::digest(canonical)))
+}
+
+fn normalize_model_catalog(catalog: ModelCatalog) -> Result {
+ let mut seen = BTreeSet::new();
+ let mut models = Vec::with_capacity(catalog.models.len());
+ for mut model in catalog.models {
+ let trimmed = model.id.trim();
+ if trimmed.is_empty() || trimmed != model.id {
+ return Err(ProvisionerError::DiscoveryResponse(
+ "gateway returned an empty or untrimmed model ID".into(),
+ ));
+ }
+ if !seen.insert(model.id.clone()) {
+ return Err(ProvisionerError::DiscoveryResponse(format!(
+ "gateway returned duplicate model `{}`",
+ model.id
+ )));
+ }
+ model.display_name = model
+ .display_name
+ .take()
+ .map(|name| name.trim().to_owned())
+ .filter(|name| !name.is_empty());
+ models.push(model);
+ }
+ models.sort_by(|left, right| left.id.cmp(&right.id));
+ Ok(ModelCatalog {
+ models,
+ source_revision: catalog.source_revision,
+ })
+}
+
+async fn record_catalog_failure(
+ transaction: &mut sqlx::Transaction<'_, Postgres>,
+ organization_id: Uuid,
+ gateway_type: &str,
+ supported: bool,
+ message: Option<&str>,
+) -> Result<(), ApiError> {
+ sqlx::query(
+ "INSERT INTO public.gateway_model_catalog_sync_state \
+ (organization_id,gateway_type,last_refresh_at,latest_error,latest_error_at,discovery_supported) \
+ VALUES ($1,$2,now(),$3,CASE WHEN $3::text IS NULL THEN NULL ELSE now() END,$4) \
+ ON CONFLICT (organization_id,gateway_type) DO UPDATE SET \
+ last_refresh_at=now(),latest_error=EXCLUDED.latest_error,latest_error_at=EXCLUDED.latest_error_at,discovery_supported=$4",
+ )
+ .bind(organization_id)
+ .bind(gateway_type)
+ .bind(message)
+ .bind(supported)
+ .execute(&mut **transaction)
+ .await?;
+ Ok(())
+}
+
+async fn persist_model_catalog(
+ transaction: &mut sqlx::Transaction<'_, Postgres>,
+ organization_id: Uuid,
+ gateway_type: &str,
+ catalog: ModelCatalog,
+) -> Result {
+ let model_ids = catalog
+ .models
+ .iter()
+ .map(|model| model.id.clone())
+ .collect::>();
+ for model in &catalog.models {
+ let fingerprint = discovered_model_fingerprint(model)?;
+ let metadata = serde_json::to_value(&model.metadata).map_err(|error| {
+ ApiError::internal(format!("serializing discovered model metadata: {error}"))
+ })?;
+ sqlx::query(
+ "INSERT INTO public.gateway_model_catalog \
+ (organization_id,gateway_type,model_id,display_name,metadata,fingerprint,first_seen_at,last_seen_at,unavailable_since,acknowledged_fingerprint,acknowledged_at) \
+ VALUES ($1,$2,$3,$4,$5,$6,now(),now(),NULL,$6,now()) \
+ ON CONFLICT (organization_id,gateway_type,model_id) DO UPDATE SET \
+ display_name=EXCLUDED.display_name,metadata=EXCLUDED.metadata,fingerprint=EXCLUDED.fingerprint,last_seen_at=now(),unavailable_since=NULL",
+ )
+ .bind(organization_id)
+ .bind(gateway_type)
+ .bind(&model.id)
+ .bind(&model.display_name)
+ .bind(metadata)
+ .bind(fingerprint)
+ .execute(&mut **transaction)
+ .await?;
+ }
+ sqlx::query(
+ "UPDATE public.gateway_model_catalog SET unavailable_since=coalesce(unavailable_since,now()) \
+ WHERE organization_id=$1 AND gateway_type=$2 AND NOT(model_id = ANY($3))",
+ )
+ .bind(organization_id)
+ .bind(gateway_type)
+ .bind(&model_ids)
+ .execute(&mut **transaction)
+ .await?;
+ sqlx::query(
+ "INSERT INTO public.gateway_model_catalog_sync_state \
+ (organization_id,gateway_type,last_refresh_at,last_successful_refresh_at,last_successful_source_revision,latest_error,latest_error_at,discovery_supported) \
+ VALUES ($1,$2,now(),now(),$3,NULL,NULL,true) \
+ ON CONFLICT (organization_id,gateway_type) DO UPDATE SET \
+ last_refresh_at=now(),last_successful_refresh_at=now(),last_successful_source_revision=$3,latest_error=NULL,latest_error_at=NULL,discovery_supported=true",
+ )
+ .bind(organization_id)
+ .bind(gateway_type)
+ .bind(catalog.source_revision)
+ .execute(&mut **transaction)
+ .await?;
+ Ok(model_ids.len())
+}
+
+async fn refresh_gateway_model_catalog(
+ state: &AppState,
+ organization_id: Uuid,
+ manual: bool,
+) -> Result<(), ApiError> {
+ let provisioner = state
+ .gateway_provisioner
+ .as_ref()
+ .ok_or_else(|| ApiError::bad_request("gateway model discovery is not configured"))?;
+ let gateway_type = state
+ .config
+ .gateway_kind
+ .as_deref()
+ .ok_or_else(|| ApiError::bad_request("gateway mode is disabled"))?;
+
+ if !manual {
+ let unsupported = sqlx::query_scalar::<_, Option>(
+ "SELECT discovery_supported FROM public.gateway_model_catalog_sync_state WHERE organization_id=$1 AND gateway_type=$2",
+ )
+ .bind(organization_id)
+ .bind(gateway_type)
+ .fetch_optional(&state.pool)
+ .await?
+ .flatten()
+ == Some(false);
+ if unsupported {
+ return Ok(());
+ }
+ }
+
+ let mut transaction = state.pool.begin().await?;
+ let locked = sqlx::query_scalar::<_, bool>(
+ "SELECT pg_try_advisory_xact_lock(hashtextextended($1, 928452))",
+ )
+ .bind(format!("gateway-model-catalog:{organization_id}"))
+ .fetch_one(&mut *transaction)
+ .await?;
+ if !locked {
+ return if manual {
+ Err(ApiError::conflict(
+ "gateway model catalog refresh is already running",
+ ))
+ } else {
+ Ok(())
+ };
+ }
+
+ let started = Instant::now();
+ match provisioner.list_models().await {
+ Ok(catalog) => {
+ let catalog = match normalize_model_catalog(catalog) {
+ Ok(catalog) => catalog,
+ Err(error) => {
+ let message = error.to_string();
+ record_catalog_failure(
+ &mut transaction,
+ organization_id,
+ gateway_type,
+ true,
+ Some(&message),
+ )
+ .await?;
+ transaction.commit().await?;
+ MODEL_CATALOG_REFRESH_ERRORS.fetch_add(1, Ordering::Relaxed);
+ return Err(provisioner_api_error(error));
+ }
+ };
+ let count =
+ persist_model_catalog(&mut transaction, organization_id, gateway_type, catalog)
+ .await?;
+ transaction.commit().await?;
+ MODEL_CATALOG_REFRESH_SUCCESSES.fetch_add(1, Ordering::Relaxed);
+ tracing::info!(
+ organization_id = %organization_id,
+ gateway_type,
+ model_count = count,
+ elapsed_ms = started.elapsed().as_millis(),
+ "refreshed gateway model catalog"
+ );
+ Ok(())
+ }
+ Err(error) => {
+ let unsupported = matches!(error, ProvisionerError::DiscoveryUnsupported);
+ let message = (!unsupported).then(|| error.to_string());
+ record_catalog_failure(
+ &mut transaction,
+ organization_id,
+ gateway_type,
+ !unsupported,
+ message.as_deref(),
+ )
+ .await?;
+ transaction.commit().await?;
+ if unsupported {
+ MODEL_CATALOG_REFRESH_UNSUPPORTED.fetch_add(1, Ordering::Relaxed);
+ } else {
+ MODEL_CATALOG_REFRESH_ERRORS.fetch_add(1, Ordering::Relaxed);
+ }
+ tracing::warn!(
+ organization_id = %organization_id,
+ gateway_type,
+ %error,
+ elapsed_ms = started.elapsed().as_millis(),
+ "gateway model catalog refresh failed"
+ );
+ Err(provisioner_api_error(error))
+ }
+ }
+}
+
+async fn gateway_models_resource(
+ state: &AppState,
+ organization_id: Uuid,
+) -> Result {
+ let gateway_type = state
+ .config
+ .gateway_kind
+ .as_deref()
+ .ok_or_else(|| ApiError::not_found("gateway mode is disabled"))?;
+ let current = current_config(&state.pool, organization_id).await?;
+ let config: gh_service::GovernanceConfig = serde_json::from_value(current.document)
+ .map_err(|error| ApiError::internal(format!("decoding stored config: {error}")))?;
+ let rows = sqlx::query_as::<_, GatewayModelCatalogRow>(
+ "SELECT model_id,display_name,metadata,fingerprint,acknowledged_fingerprint,first_seen_at,last_seen_at,unavailable_since \
+ FROM public.gateway_model_catalog WHERE organization_id=$1 AND gateway_type=$2 ORDER BY model_id",
+ )
+ .bind(organization_id)
+ .bind(gateway_type)
+ .fetch_all(&state.pool)
+ .await?;
+ let sync = sqlx::query_as::<_, GatewayModelSyncRow>(
+ "SELECT last_refresh_at,last_successful_refresh_at,last_successful_source_revision,latest_error,latest_error_at,discovery_supported \
+ FROM public.gateway_model_catalog_sync_state WHERE organization_id=$1 AND gateway_type=$2",
+ )
+ .bind(organization_id)
+ .bind(gateway_type)
+ .fetch_optional(&state.pool)
+ .await?;
+
+ let metadata = gh_config::implementations::registry_metadata();
+ let harnesses = metadata
+ .iter()
+ .filter(|item| config.allowed_harnesses.iter().any(|key| key == item.key))
+ .map(|item| {
+ let policy = config.harnesses.get(item.key);
+ json!({
+ "key": item.key,
+ "label": item.label,
+ "exposure": item.gateway_model_exposure,
+ "gateway_models": policy.map(|policy| policy.gateway_models.clone()).unwrap_or_default(),
+ "default_model": policy.and_then(|policy| policy.managed_config.model.clone()),
+ })
+ })
+ .collect::>();
+ let assignments = config
+ .harnesses
+ .values()
+ .flat_map(|policy| policy.gateway_models.iter().cloned())
+ .collect::>();
+ let by_id = rows
+ .iter()
+ .map(|row| (row.model_id.clone(), row))
+ .collect::>();
+ let all_ids = rows
+ .iter()
+ .map(|row| row.model_id.clone())
+ .chain(assignments)
+ .collect::>();
+ let models = all_ids
+ .into_iter()
+ .map(|model_id| {
+ let row = by_id.get(&model_id).copied();
+ let state = match row {
+ None => "unknown",
+ Some(row) if row.unavailable_since.is_some() => "removed",
+ Some(row) if row.fingerprint != row.acknowledged_fingerprint => "changed",
+ Some(_) => "available",
+ };
+ let assigned_to = config
+ .harnesses
+ .iter()
+ .filter(|(_, policy)| policy.gateway_models.iter().any(|id| id == &model_id))
+ .map(|(harness, _)| harness.clone())
+ .collect::>();
+ json!({
+ "id": model_id,
+ "display_name": row.and_then(|row| row.display_name.clone()),
+ "metadata": row.map(|row| row.metadata.clone()).unwrap_or_else(|| json!({})),
+ "fingerprint": row.map(|row| row.fingerprint.clone()),
+ "state": state,
+ "assigned_to": assigned_to,
+ "first_seen_at": timestamp(row.map(|row| row.first_seen_at)),
+ "last_seen_at": timestamp(row.map(|row| row.last_seen_at)),
+ "unavailable_since": timestamp(row.and_then(|row| row.unavailable_since)),
+ })
+ })
+ .collect::>();
+ let refresh_seconds = state
+ .config
+ .gateway_provisioner
+ .as_ref()
+ .map(|value| value.model_catalog_refresh_seconds)
+ .unwrap_or_else(default_model_catalog_refresh_seconds);
+ let last_success = sync.as_ref().and_then(|row| row.last_successful_refresh_at);
+ let stale = last_success.is_some_and(|last| {
+ OffsetDateTime::now_utc() - last > Duration::seconds((refresh_seconds * 2) as i64)
+ });
+ Ok(json!({
+ "revision": current.revision,
+ "gateway_type": gateway_type,
+ "refresh_interval_seconds": refresh_seconds,
+ "sync": {
+ "last_refresh_at": timestamp(sync.as_ref().and_then(|row| row.last_refresh_at)),
+ "last_successful_refresh_at": timestamp(last_success),
+ "source_revision": sync.as_ref().and_then(|row| row.last_successful_source_revision.clone()),
+ "latest_error": sync.as_ref().and_then(|row| row.latest_error.clone()),
+ "latest_error_at": timestamp(sync.as_ref().and_then(|row| row.latest_error_at)),
+ "discovery_supported": sync.as_ref().and_then(|row| row.discovery_supported),
+ "stale": stale,
+ },
+ "models": models,
+ "harnesses": harnesses,
+ }))
+}
+
+async fn admin_gateway_models(
+ State(state): State>,
+ Extension(who): Extension,
+) -> Result, ApiError> {
+ Ok(Json(
+ gateway_models_resource(&state, who.organization_id).await?,
+ ))
+}
+
+async fn refresh_admin_gateway_models(
+ State(state): State>,
+ Extension(who): Extension,
+) -> Result, ApiError> {
+ refresh_gateway_model_catalog(&state, who.organization_id, true).await?;
+ admin_gateway_models(State(state), Extension(who)).await
+}
+
+#[derive(Deserialize)]
+struct UpdateHarnessGatewayModelsRequest {
+ base_revision: String,
+ gateway_models: Vec,
+ #[serde(default)]
+ default_model: Option,
+}
+
+async fn update_harness_gateway_models(
+ State(state): State>,
+ Extension(who): Extension,
+ Path(harness): Path,
+ Json(input): Json,
+) -> Result, ApiError> {
+ if state.config.gateway_kind.is_none() {
+ return Err(ApiError::not_found("gateway mode is disabled"));
+ }
+ if !supported_harness(&harness) {
+ return Err(ApiError::bad_request(format!(
+ "unsupported gateway-model harness `{harness}`"
+ )));
+ }
+ let mut unique = BTreeSet::new();
+ for model in &input.gateway_models {
+ if model.trim().is_empty() || model.trim() != model {
+ return Err(ApiError::bad_request(
+ "gateway model IDs must be non-empty and trimmed",
+ ));
+ }
+ if !unique.insert(model.clone()) {
+ return Err(ApiError::bad_request(format!(
+ "duplicate gateway model `{model}`"
+ )));
+ }
+ }
+ if input.gateway_models.is_empty() {
+ return Err(ApiError::bad_request(
+ "gateway mode requires at least one model assigned to each harness",
+ ));
+ }
+ let default_model = input.default_model.filter(|value| !value.trim().is_empty());
+ if default_model
+ .as_ref()
+ .is_some_and(|model| !input.gateway_models.contains(model))
+ {
+ return Err(ApiError::bad_request(
+ "default model must be assigned to the harness",
+ ));
+ }
+ let current = current_config(&state.pool, who.organization_id).await?;
+ if current.revision != input.base_revision {
+ return Err(ApiError::conflict(
+ "governance configuration changed; reload before saving",
+ ));
+ }
+ let mut config: gh_service::GovernanceConfig = serde_json::from_value(current.document)
+ .map_err(|error| ApiError::internal(format!("decoding stored config: {error}")))?;
+ if !config
+ .allowed_harnesses
+ .iter()
+ .any(|allowed| allowed == &harness)
+ {
+ return Err(ApiError::bad_request(format!(
+ "harness `{harness}` is not enabled by governance"
+ )));
+ }
+ let policy = config.harnesses.entry(harness.clone()).or_default();
+ policy.gateway_models = input.gateway_models;
+ policy.managed_config.model = default_model;
+ stamp_version_aware_client_floor(&mut config);
+ validate_complete_governance(&config).map_err(ApiError::bad_request)?;
+ let yaml = serde_yaml::to_string(&config)
+ .map_err(|error| ApiError::internal(format!("serializing governance config: {error}")))?;
+ let row = insert_governance_revision(
+ &state.pool,
+ who.organization_id,
+ Some(who.user_id),
+ &yaml,
+ "dashboard",
+ Some(&input.base_revision),
+ None,
+ )
+ .await?;
+ Ok(Json(
+ json!({ "revision": row.revision, "harness": harness }),
+ ))
+}
+
+#[derive(Deserialize)]
+struct AcknowledgeGatewayModelRequest {
+ model_id: String,
+ fingerprint: String,
+}
+
+async fn acknowledge_gateway_model(
+ State(state): State>,
+ Extension(who): Extension,
+ Json(input): Json,
+) -> Result, ApiError> {
+ let gateway_type = state
+ .config
+ .gateway_kind
+ .as_deref()
+ .ok_or_else(|| ApiError::not_found("gateway mode is disabled"))?;
+ let result = sqlx::query(
+ "UPDATE public.gateway_model_catalog SET acknowledged_fingerprint=fingerprint,acknowledged_at=now(),acknowledged_by=$1 \
+ WHERE organization_id=$2 AND gateway_type=$3 AND model_id=$4 AND fingerprint=$5",
+ )
+ .bind(who.user_id)
+ .bind(who.organization_id)
+ .bind(gateway_type)
+ .bind(&input.model_id)
+ .bind(&input.fingerprint)
+ .execute(&state.pool)
+ .await?;
+ if result.rows_affected() != 1 {
+ return Err(ApiError::conflict(
+ "model fingerprint changed; refresh before acknowledging",
+ ));
+ }
+ Ok(Json(json!({ "acknowledged": true })))
+}
+
+async fn refresh_gateway_model_catalogs(state: Arc) {
+ let seconds = state
+ .config
+ .gateway_provisioner
+ .as_ref()
+ .map(|value| value.model_catalog_refresh_seconds)
+ .unwrap_or_else(default_model_catalog_refresh_seconds);
+ let mut interval = tokio::time::interval(std::time::Duration::from_secs(seconds));
+ loop {
+ interval.tick().await;
+ let organizations = match sqlx::query_scalar::<_, Uuid>("SELECT id FROM organizations")
+ .fetch_all(&state.pool)
+ .await
+ {
+ Ok(value) => value,
+ Err(error) => {
+ tracing::warn!(%error, "listing organizations for model catalog refresh failed");
+ continue;
+ }
+ };
+ for organization_id in organizations {
+ let _ = refresh_gateway_model_catalog(&state, organization_id, false).await;
+ }
+ }
+}
+
#[derive(Clone, FromRow)]
struct ManagedGatewayCredentialRow {
gateway_email: String,
@@ -3793,6 +4375,9 @@ fn provisioner_api_error(error: ProvisionerError) -> ApiError {
ProvisionerError::Conflict(_) => StatusCode::CONFLICT,
ProvisionerError::CredentialInvalid(_) => StatusCode::CONFLICT,
ProvisionerError::Unavailable(_) | ProvisionerError::Rejected(_) => StatusCode::BAD_GATEWAY,
+ ProvisionerError::DiscoveryUnsupported => StatusCode::NOT_IMPLEMENTED,
+ ProvisionerError::DiscoveryAuth(_) => StatusCode::UNAUTHORIZED,
+ ProvisionerError::DiscoveryResponse(_) => StatusCode::BAD_GATEWAY,
};
ApiError::new(status, error.to_string())
}
@@ -6635,6 +7220,19 @@ fn validate_complete_governance(config: &gh_service::GovernanceConfig) -> Result
{
return Err("gateway mode requires capability `gateway_inference_jwt`".into());
}
+ let uses_gateway_models = config
+ .harnesses
+ .values()
+ .any(|policy| !policy.gateway_models.is_empty());
+ if config.gateway.is_some()
+ && uses_gateway_models
+ && !config
+ .required_capabilities
+ .iter()
+ .any(|capability| capability == "gateway_model_catalog")
+ {
+ return Err("gateway model mappings require capability `gateway_model_catalog`".into());
+ }
if let Some(gateway) = config.gateway.as_ref() {
if gateway.proxy_url.is_some() || gateway.token.is_some() {
return Err("gateway proxy_url and token are runtime-only fields".into());
@@ -6651,6 +7249,16 @@ fn validate_complete_governance(config: &gh_service::GovernanceConfig) -> Result
if !supported_harness(harness) {
return Err(format!("unsupported allowed harness `{harness}`"));
}
+ if config.gateway.is_some()
+ && config
+ .harnesses
+ .get(harness)
+ .is_none_or(|policy| policy.gateway_models.is_empty())
+ {
+ return Err(format!(
+ "gateway-mode harness `{harness}` requires at least one gateway model"
+ ));
+ }
}
for (harness, policy) in &config.harnesses {
if !supported_harness(harness) {
@@ -6663,6 +7271,32 @@ fn validate_complete_governance(config: &gh_service::GovernanceConfig) -> Result
)
})?;
}
+ let mut unique_models = std::collections::BTreeSet::new();
+ for model in &policy.gateway_models {
+ if model.trim().is_empty() || model != model.trim() {
+ return Err(format!(
+ "harness `{harness}` has an invalid empty or untrimmed gateway model ID"
+ ));
+ }
+ if !unique_models.insert(model) {
+ return Err(format!(
+ "harness `{harness}` has duplicate gateway model `{model}`"
+ ));
+ }
+ }
+ if config.gateway.is_some()
+ && policy
+ .managed_config
+ .model
+ .as_ref()
+ .is_some_and(|selected| {
+ !policy.gateway_models.iter().any(|model| model == selected)
+ })
+ {
+ return Err(format!(
+ "harness `{harness}` selected gateway model is not assigned to that harness"
+ ));
+ }
}
let uses_version_aware_features = config
.harnesses
@@ -8176,6 +8810,19 @@ fn stamp_version_aware_client_floor(config: &mut gh_service::GovernanceConfig) {
.required_capabilities
.push("gateway_inference_jwt".to_owned());
}
+ if config
+ .harnesses
+ .values()
+ .any(|policy| !policy.gateway_models.is_empty())
+ && !config
+ .required_capabilities
+ .iter()
+ .any(|capability| capability == "gateway_model_catalog")
+ {
+ config
+ .required_capabilities
+ .push("gateway_model_catalog".to_owned());
+ }
}
}
@@ -10226,6 +10873,67 @@ mod tests {
);
}
+ #[test]
+ fn model_catalog_normalization_is_deterministic_and_strict() {
+ let catalog = normalize_model_catalog(ModelCatalog {
+ models: vec![
+ DiscoveredModel {
+ id: "vendor/z".into(),
+ display_name: Some(" Zed ".into()),
+ metadata: BTreeMap::new(),
+ },
+ DiscoveredModel {
+ id: "vendor/a".into(),
+ display_name: None,
+ metadata: BTreeMap::new(),
+ },
+ ],
+ source_revision: Some("r1".into()),
+ })
+ .unwrap();
+ assert_eq!(catalog.models[0].id, "vendor/a");
+ assert_eq!(catalog.models[1].display_name.as_deref(), Some("Zed"));
+
+ let duplicate = normalize_model_catalog(ModelCatalog {
+ models: vec![
+ DiscoveredModel {
+ id: "same".into(),
+ display_name: None,
+ metadata: BTreeMap::new(),
+ },
+ DiscoveredModel {
+ id: "same".into(),
+ display_name: None,
+ metadata: BTreeMap::new(),
+ },
+ ],
+ source_revision: None,
+ });
+ assert!(matches!(
+ duplicate,
+ Err(ProvisionerError::DiscoveryResponse(_))
+ ));
+ }
+
+ #[test]
+ fn model_fingerprints_change_only_with_material_model_data() {
+ let first = DiscoveredModel {
+ id: "vendor/model".into(),
+ display_name: Some("Model".into()),
+ metadata: BTreeMap::from([("context".into(), json!(128_000))]),
+ };
+ let mut changed = first.clone();
+ changed.metadata.insert("context".into(), json!(256_000));
+ assert_eq!(
+ discovered_model_fingerprint(&first).unwrap(),
+ discovered_model_fingerprint(&first).unwrap()
+ );
+ assert_ne!(
+ discovered_model_fingerprint(&first).unwrap(),
+ discovered_model_fingerprint(&changed).unwrap()
+ );
+ }
+
#[async_trait::async_trait]
impl GatewayProvisioner for TestProvisioner {
fn kind(&self) -> &'static str {
@@ -10260,6 +10968,7 @@ mod tests {
policy_revision: None,
timeout_seconds: default_provisioner_timeout(),
max_concurrency: default_provisioner_max_concurrency(),
+ model_catalog_refresh_seconds: default_model_catalog_refresh_seconds(),
};
assert!(validate_linked_provisioner(Some(&matching), &linked).is_ok());
@@ -10271,6 +10980,7 @@ mod tests {
policy_revision: None,
timeout_seconds: default_provisioner_timeout(),
max_concurrency: default_provisioner_max_concurrency(),
+ model_catalog_refresh_seconds: default_model_catalog_refresh_seconds(),
};
assert!(validate_linked_provisioner(Some(&mismatched), &linked).is_err());
@@ -10312,6 +11022,7 @@ mod tests {
let provisioner = gateway_provisioner(&Some(selected)).unwrap().unwrap();
assert_eq!(provisioner.kind, "company-litellm");
assert_eq!(provisioner.reconcile_ttl_seconds, 3600);
+ assert_eq!(provisioner.model_catalog_refresh_seconds, 300);
let runtime_module: serde_yaml::Value = serde_yaml::from_str(
"gateway:\n provisioner:\n type: company-runtime\n executable_path: /etc/blue/provisioner\n executable_sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n policy_revision: v1\n timeout_seconds: 7\n",
@@ -10331,6 +11042,12 @@ mod tests {
.unwrap();
assert!(gateway_provisioner(&Some(invalid_ttl)).is_err());
+ let invalid_catalog_ttl: serde_yaml::Value = serde_yaml::from_str(
+ "gateway:\n provisioner:\n type: company-runtime\n model_catalog_refresh_seconds: 29\n",
+ )
+ .unwrap();
+ assert!(gateway_provisioner(&Some(invalid_catalog_ttl)).is_err());
+
let gateway_policy: serde_yaml::Value = serde_yaml::from_str(
"gateway:\n provisioner:\n type: company-litellm\n config:\n max_budget: 100\n",
)
@@ -10803,6 +11520,49 @@ mod tests {
assert!(validate_complete_governance(&governance).is_ok());
}
+ #[test]
+ fn gateway_model_assignment_validation_rejects_empty_duplicate_and_unassigned_defaults() {
+ let document: serde_yaml::Value =
+ serde_yaml::from_str(include_str!("../../../deploy/blue.yaml")).unwrap();
+ let mut baseline: gh_service::GovernanceConfig =
+ serde_yaml::from_value(document["governance"].clone()).unwrap();
+ baseline.gateway = Some(
+ serde_json::from_value(json!({ "type": "litellm", "auth_style": "bearer" })).unwrap(),
+ );
+ for (harness, policy) in &mut baseline.harnesses {
+ policy.gateway_models = vec![format!("{harness}-test")];
+ }
+
+ let mut empty = baseline.clone();
+ empty
+ .harnesses
+ .get_mut("claude")
+ .unwrap()
+ .gateway_models
+ .clear();
+ assert!(validate_complete_governance(&empty)
+ .unwrap_err()
+ .contains("requires at least one gateway model"));
+
+ let mut duplicate = baseline.clone();
+ duplicate
+ .harnesses
+ .get_mut("claude")
+ .unwrap()
+ .gateway_models = vec!["claude-test".into(), "claude-test".into()];
+ assert!(validate_complete_governance(&duplicate)
+ .unwrap_err()
+ .contains("duplicate gateway model"));
+
+ let mut unassigned = baseline;
+ let claude = unassigned.harnesses.get_mut("claude").unwrap();
+ claude.gateway_models = vec!["claude-test".into()];
+ claude.managed_config.model = Some("not-assigned".into());
+ assert!(validate_complete_governance(&unassigned)
+ .unwrap_err()
+ .contains("selected gateway model is not assigned"));
+ }
+
#[test]
fn package_validation_rejects_duplicates_and_mutable_digests() {
let package = gh_service::ManagedPackage {
@@ -10995,8 +11755,7 @@ mod tests {
// A `managed_yaml` round-trip as the dashboard/CLI sends it: the client
// echoes back a document whose stored revision was written without the
// gateway capability (a governance-only control-api reconciled it away).
- let managed_yaml =
- "revision: r1\nallowed_harnesses:\n - codex\ngateway:\n type: litellm\n";
+ let managed_yaml = "revision: r1\nallowed_harnesses:\n - codex\nharnesses:\n codex:\n gateway_models: [gpt-test]\ngateway:\n type: litellm\n";
let mut config =
gh_service::source::parse_config(std::path::Path::new("config.yaml"), managed_yaml)
.unwrap();
diff --git a/tests/e2e-slim/README.md b/tests/e2e-slim/README.md
index 19408b7..41cdd57 100644
--- a/tests/e2e-slim/README.md
+++ b/tests/e2e-slim/README.md
@@ -28,7 +28,7 @@ mutable or retired Docker Hub tags.
| Test | What it exercises | Needs a real agent CLI? | Needs the gateway path? |
| --- | --- | --- | --- |
| `bootstrap_login` | Mint token → control-api verifies it via JWKS (RS256 signature, issuer, audience, expiry) → `blue login` short-circuits → `GET /auth/me` returns the admin identity. | no | no |
-| `claude_config` | `blue apply` writes Claude's governed model, the `e2e-remote` MCP server, and the managed `example` skill into its managed config. | yes (`claude`) | no |
+| `claude_config` | `blue apply` writes Claude's governed model, enforced ordered model catalog and replacement picker, the `e2e-remote` MCP server, and the managed `example` skill into its managed config. | yes (`claude`) | no |
| `all_agents_config` | The **same** model + MCP + skill assertions for `codex` / `kimi` / `opencode`, so all four agents are covered identically. | yes (per agent) | no |
| `session_upload` | `blue session-upload` per harness → detached worker presigns → PUTs to MinIO → completes; the list shows all 4, and one artifact is downloaded and byte-compared to the upload. | no | no |
| `agent_matrix_config` | **Tier A (version matrix).** The same model + MCP + skill assertions as `all_agents_config`, but per `(agent, version)` cell across multiple CLI versions (not just the pin). No inference. Self-skips unless the matrix is enabled. | yes (per cell) | no |
diff --git a/tests/e2e-slim/fixtures/gateway-slim.yaml b/tests/e2e-slim/fixtures/gateway-slim.yaml
index e64353f..a209186 100644
--- a/tests/e2e-slim/fixtures/gateway-slim.yaml
+++ b/tests/e2e-slim/fixtures/gateway-slim.yaml
@@ -72,9 +72,10 @@ gateway:
governance:
revision: "e2e-slim-gateway-1"
- contract_version: 3
+ contract_version: 4
required_capabilities:
- gateway_inference_jwt
+ - gateway_model_catalog
- adapter_intervals
- compiled_harness_registry
- transactional_reconcile
@@ -102,6 +103,7 @@ governance:
presign_url: http://127.0.0.1:8080/session-uploads/presign
harnesses:
codex:
+ gateway_models: [gpt-5.6-terra]
managed_config:
model: gpt-5.6-terra
reasoning_effort: low
@@ -112,17 +114,19 @@ governance:
command: node
args: ["/tmp/blue-e2e/mcp-server.mjs"]
claude:
+ gateway_models: [claude-sonnet-5]
version_requirement: ">=2.0.12, <2.1.253"
managed_config:
model: claude-sonnet-5
auto_approve: true
mcp: *e2e_mcp
kimi:
+ gateway_models: [kimi-k2.6, kimi-catalog-secondary]
managed_config:
- model: kimi-k2.6
auto_approve: true
mcp: *e2e_mcp
opencode:
+ gateway_models: [gpt-5.6-terra]
managed_config:
model: gpt-5.6-terra
mcp: *e2e_mcp
diff --git a/tests/e2e-slim/tests/agent_certification.rs b/tests/e2e-slim/tests/agent_certification.rs
index 40f15ee..7d5dfa0 100644
--- a/tests/e2e-slim/tests/agent_certification.rs
+++ b/tests/e2e-slim/tests/agent_certification.rs
@@ -227,6 +227,15 @@ fn certify_cell(agent: &str, version: &str) {
let home = gateway.bootstrap_gateway_home_pinned(bin_dir);
+ if agent == "kimi" {
+ std::fs::create_dir_all(home.path().join(".kimi-code")).unwrap();
+ std::fs::write(
+ home.path().join(".kimi-code/config.toml"),
+ "default_model = \"native-model\"\n[models.native-model]\nprovider = \"native-provider\"\nmodel = \"native-model\"\nmax_context_size = 4096\n[providers.native-provider]\ntype = \"openai\"\nbase_url = \"https://native.example\"\napi_key = \"native\"\n",
+ )
+ .unwrap();
+ }
+
if home.select_agent(agent) == AgentSelection::NotEligible {
eprintln!("{agent} {version} CLI not installed/eligible; skipping certification");
return;
@@ -236,6 +245,9 @@ fn certify_cell(agent: &str, version: &str) {
home.blue().args(["apply", "--yes"]).assert().success();
common::assert_mcp_registered(&home, agent);
common::assert_example_skill_materialized(&home, agent);
+ if agent == "kimi" {
+ common::assert_kimi_catalog(&home, &["kimi-k2.6", "kimi-catalog-secondary"]);
+ }
// (2) Launch the real agent through the gateway and force the managed tool.
let nonce = format!("BLUE_E2E_INVOCATION_{}", uuid::Uuid::new_v4());
diff --git a/tests/e2e-slim/tests/all_agents_config.rs b/tests/e2e-slim/tests/all_agents_config.rs
index ba8f248..00400d4 100644
--- a/tests/e2e-slim/tests/all_agents_config.rs
+++ b/tests/e2e-slim/tests/all_agents_config.rs
@@ -49,7 +49,30 @@ fn codex_managed_config() {
#[test]
fn kimi_managed_config() {
- assert_managed_config("kimi", "kimi-k2.6");
+ let Some(stack) = e2e_slim::env_or_skip() else {
+ return;
+ };
+ let home = stack.bootstrap_home();
+ std::fs::create_dir_all(home.path().join(".kimi-code")).unwrap();
+ std::fs::write(
+ home.path().join(".kimi-code/config.toml"),
+ "default_model = \"native-model\"\n[models.native-model]\nprovider = \"native-provider\"\nmodel = \"native-model\"\nmax_context_size = 4096\n[providers.native-provider]\ntype = \"openai\"\nbase_url = \"https://native.example\"\napi_key = \"native\"\n",
+ )
+ .unwrap();
+ let Some(home) = (match home.select_agent("kimi") {
+ AgentSelection::Selected => Some(home),
+ AgentSelection::NotEligible => None,
+ }) else {
+ eprintln!("kimi CLI not installed/eligible; skipping");
+ return;
+ };
+ home.blue().args(["apply", "--yes"]).assert().success();
+ assert_governed_model(&home, "kimi", "native-model");
+ let config = std::fs::read_to_string(home.data_path().join("runtime/kimi/config.toml")).unwrap();
+ assert!(config.contains("[models.native-model]"));
+ assert!(config.contains("[providers.native-provider]"));
+ assert_mcp_registered(&home, "kimi");
+ assert_example_skill_materialized(&home, "kimi");
}
#[test]
diff --git a/tests/e2e-slim/tests/claude_config.rs b/tests/e2e-slim/tests/claude_config.rs
index 5745488..b6203af 100644
--- a/tests/e2e-slim/tests/claude_config.rs
+++ b/tests/e2e-slim/tests/claude_config.rs
@@ -9,7 +9,10 @@
mod common;
-use common::{assert_example_skill_materialized, assert_governed_model, assert_mcp_registered};
+use common::{
+ assert_claude_catalog, assert_example_skill_materialized, assert_governed_model,
+ assert_mcp_registered,
+};
use e2e_slim::AgentSelection;
#[test]
@@ -28,6 +31,7 @@ fn claude_managed_config_is_written() {
home.blue().args(["apply", "--yes"]).assert().success();
assert_governed_model(&home, "claude", "claude-sonnet-5");
+ assert_claude_catalog(&home, &["claude-sonnet-5"]);
assert_mcp_registered(&home, "claude");
assert_example_skill_materialized(&home, "claude");
}
diff --git a/tests/e2e-slim/tests/common/mod.rs b/tests/e2e-slim/tests/common/mod.rs
index 96de1c5..e6f9340 100644
--- a/tests/e2e-slim/tests/common/mod.rs
+++ b/tests/e2e-slim/tests/common/mod.rs
@@ -58,6 +58,48 @@ pub fn assert_governed_model(home: &Home, agent: &str, expected: &str) {
);
}
+/// Claude 2.1.242+ must expose exactly Blue's ordered assignment and replace
+/// the native picker options, not merely set the selected model.
+pub fn assert_claude_catalog(home: &Home, expected: &[&str]) {
+ let settings = read_json(&home.data_path().join("runtime/claude/settings.json"));
+ assert_eq!(settings["availableModels"], serde_json::json!(expected));
+ assert_eq!(settings["enforceAvailableModels"], true);
+ assert_eq!(
+ settings["modelPicker"],
+ serde_json::json!({
+ "options": expected.iter().map(|model| serde_json::json!({
+ "model": model,
+ "label": model,
+ })).collect::>(),
+ "replaceBuiltInOptions": true,
+ })
+ );
+}
+
+/// Kimi gateway launches must replace native model/provider definitions with
+/// exactly Blue's ordered assignment and select the first entry by default.
+pub fn assert_kimi_catalog(home: &Home, expected: &[&str]) {
+ let path = home.data_path().join("runtime/kimi/config.toml");
+ let body = std::fs::read_to_string(&path).expect("reading Kimi runtime config");
+ let config = read_toml(&path);
+ assert_eq!(config["default_model"].as_str(), expected.first().copied());
+ let models = config["models"].as_table().expect("Kimi models table");
+ assert_eq!(models.len(), expected.len());
+ for model in expected {
+ assert_eq!(models[*model]["provider"].as_str(), Some("governed"));
+ }
+ assert!(models.get("native-model").is_none());
+ let providers = config["providers"].as_table().expect("Kimi providers table");
+ assert_eq!(providers.len(), 1);
+ assert!(providers.get("governed").is_some());
+ assert!(providers.get("native-provider").is_none());
+ for pair in expected.windows(2) {
+ let first = body.find(&format!("[models.{}]", pair[0])).unwrap();
+ let second = body.find(&format!("[models.{}]", pair[1])).unwrap();
+ assert!(first < second, "Kimi model entries must retain assignment order");
+ }
+}
+
/// Assert the managed `e2e-remote` MCP server was registered in `agent`'s config.
/// The location and shape differ per agent, but every one must reference `node`.
pub fn assert_mcp_registered(home: &Home, agent: &str) {
diff --git a/tests/e2e/agents.lock.json b/tests/e2e/agents.lock.json
index bcda85f..83a0dc9 100644
--- a/tests/e2e/agents.lock.json
+++ b/tests/e2e/agents.lock.json
@@ -13,7 +13,7 @@
"package": "@anthropic-ai/claude-code",
"version": "2.1.252",
"integrity": "sha512-ftoO0eLOZyEDrA3KDd7QZH5qdvToiTcoip3YdGGx8wzH4R9YUwHO+5VG01JDRn8u7MrRcXkf7FvbMYezEt0VyQ==",
- "profile": "claude-v2_0_12",
+ "profile": "claude-v2_1_242",
"arguments": ["-p", "Return BLUE_CERT_OK and do not edit files.", "--output-format", "json"],
"capabilities": ["gateway", "revision", "mcp", "packages", "skills", "plugins", "hooks", "session_upload", "session_start", "session_resume"]
},
diff --git a/tests/e2e/fixtures/blue.yaml b/tests/e2e/fixtures/blue.yaml
index 03e248b..ca195b7 100644
--- a/tests/e2e/fixtures/blue.yaml
+++ b/tests/e2e/fixtures/blue.yaml
@@ -54,9 +54,10 @@ gateway:
policy_revision: e2e-executable-v1
governance:
revision: "e2e-1"
- contract_version: 3
+ contract_version: 4
required_capabilities:
- gateway_inference_jwt
+ - gateway_model_catalog
- adapter_intervals
- compiled_harness_registry
- transactional_reconcile
@@ -90,6 +91,7 @@ governance:
presign_url: http://127.0.0.1:8080/session-uploads/presign
harnesses:
codex:
+ gateway_models: [gpt-e2e]
managed_config:
model: gpt-e2e
reasoning_effort: low
@@ -100,17 +102,20 @@ governance:
command: node
args: ["/work/tests/e2e/fixtures/mcp-server.mjs"]
claude:
+ gateway_models: [claude-e2e]
version_requirement: ">=2.0.12, <2.1.253-0"
managed_config:
model: claude-e2e
auto_approve: true
mcp: *e2e_mcp
kimi:
+ gateway_models: [kimi-e2e]
managed_config:
model: kimi-e2e
auto_approve: true
mcp: *e2e_mcp
opencode:
+ gateway_models: [e2e/model]
managed_config:
model: e2e/model
mcp: *e2e_mcp
diff --git a/tests/e2e/fixtures/custom-provisioner/provisioner.sh b/tests/e2e/fixtures/custom-provisioner/provisioner.sh
index 1436d0d..b9b6923 100755
--- a/tests/e2e/fixtures/custom-provisioner/provisioner.sh
+++ b/tests/e2e/fixtures/custom-provisioner/provisioner.sh
@@ -46,6 +46,9 @@ case "$mode" in
esac
case "$request" in
+ *'"operation":"list_models"'*)
+ printf '%s\n' '{"protocol_version":1,"status":"success","result":{"models":[{"id":"gpt-e2e"},{"id":"claude-e2e"},{"id":"kimi-e2e"},{"id":"e2e/model"},{"id":"e2e/alternate"}],"source_revision":"e2e-models-v1"}}'
+ ;;
*'"operation":"ensure"'*)
printf '%s\n' "{\"protocol_version\":1,\"status\":\"success\",\"result\":{\"credential\":\"${E2E_PLUGIN_KEY:?}\",\"external_id\":\"e2e-executable\",\"alias\":\"e2e\",\"metadata\":{\"custom_provisioner\":true,\"runtime\":\"executable\"},\"expires_at\":null}}"
;;
diff --git a/tests/e2e/fixtures/fake-upstream/server.mjs b/tests/e2e/fixtures/fake-upstream/server.mjs
index cb56236..e63b980 100644
--- a/tests/e2e/fixtures/fake-upstream/server.mjs
+++ b/tests/e2e/fixtures/fake-upstream/server.mjs
@@ -32,6 +32,15 @@ createServer(async (request, response) => {
}
if (request.headers.authorization === `Bearer ${adminKey}`) {
+ if (url.pathname === "/v1/models" && request.method === "GET") {
+ return json(response, 200, { data: [
+ { id: "gpt-e2e" },
+ { id: "claude-e2e" },
+ { id: "kimi-e2e" },
+ { id: "e2e/model" },
+ { id: "e2e/alternate" },
+ ] });
+ }
if (url.pathname === "/user/list") {
const email = url.searchParams.get("user_email")?.toLowerCase();
return json(response, 200, { users: email && users.has(email) ? [users.get(email)] : [] });
diff --git a/tests/e2e/specs/gateway-m2m.spec.ts b/tests/e2e/specs/gateway-m2m.spec.ts
index 63667f8..6aeba12 100644
--- a/tests/e2e/specs/gateway-m2m.spec.ts
+++ b/tests/e2e/specs/gateway-m2m.spec.ts
@@ -12,6 +12,7 @@ import https from "node:https";
import YAML from "yaml";
import { collect, prepareClient, readClientFile, runCli, spawnCli, waitForOutput } from "../support/cli.js";
import { loginAsAdmin } from "../support/dashboard.js";
+import { enableGateway } from "../support/governance.js";
// The inference proxy authenticates to the Control API's internal resolver with
// a short-lived OAuth2 client-credentials (M2M) token that it caches and
@@ -105,9 +106,9 @@ async function mintInferenceToken(
const config = await page.request.get(`${CONTROL}/governance-config`, {
headers: {
authorization: `Bearer ${session.token}`,
- "x-blue-contract-version": "3",
+ "x-blue-contract-version": "4",
"x-blue-capabilities":
- "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,tenant_client_version_pin",
+ "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,gateway_model_catalog,tenant_client_version_pin",
},
});
expect(config.status(), await config.text()).toBe(200);
@@ -220,13 +221,7 @@ test.describe.serial("Gateway M2M auth", () => {
expect(currentResponse.status(), await currentResponse.text()).toBe(200);
const current = await currentResponse.json();
const managedConfig = YAML.parse(String(current.managed_yaml));
- managedConfig.gateway = { type: "litellm" };
- managedConfig.required_capabilities = Array.from(
- new Set([
- ...(managedConfig.required_capabilities ?? []),
- "gateway_inference_jwt",
- ]),
- );
+ enableGateway(managedConfig);
const managedYaml = YAML.stringify(managedConfig);
const updateResponse = await page.request.put(`${CONTROL}/admin/governance-config`, {
data: { base_revision: current.revision, managed_yaml: managedYaml },
@@ -263,9 +258,9 @@ test.describe.serial("Gateway M2M auth", () => {
{
headers: {
authorization: `Bearer ${session.token}`,
- "x-blue-contract-version": "3",
+ "x-blue-contract-version": "4",
"x-blue-capabilities":
- "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,tenant_client_version_pin",
+ "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,gateway_model_catalog,tenant_client_version_pin",
},
},
);
diff --git a/tests/e2e/specs/journey.spec.ts b/tests/e2e/specs/journey.spec.ts
index 1917eba..4f016ba 100644
--- a/tests/e2e/specs/journey.spec.ts
+++ b/tests/e2e/specs/journey.spec.ts
@@ -7,11 +7,12 @@ import { gzipSync } from "node:zlib";
import path from "node:path";
import { collect, prepareClient, prepareEmptyClient, prepareRelocatedClient, readClientFile, relocatedEnv, runBareCliInPty, runCli, runCliWithInput, runCliWithoutHome, spawnCli, spawnCliInPty, stateRoot, waitForOutput } from "../support/cli.js";
import { loginAsAdmin } from "../support/dashboard.js";
+import { enableGateway } from "../support/governance.js";
import YAML from "yaml";
const canonicalProfiles: Record = {
codex: "codex-v0_145_0",
- claude: "claude-v2_0_12",
+ claude: "claude-v2_1_242",
kimi: "kimi-v0_0_0",
opencode: "opencode-v0_0_0",
};
@@ -197,7 +198,7 @@ test.describe.serial("Blue deployment journey", () => {
const original = await originalResponse.json();
const gatewayConfig = YAML.parse(original.managed_yaml);
gatewayConfig.harnesses.codex.managed_config.model = "gpt-e2e";
- gatewayConfig.gateway = { type: "litellm" };
+ enableGateway(gatewayConfig);
const gatewayResponse = await page.request.put(`${control}/admin/governance-config`, {
data: {
base_revision: original.revision,
@@ -1218,23 +1219,39 @@ esac
const completion = collect(child);
try {
const control = process.env.E2E_CONTROL_API_URL ?? "http://127.0.0.1:8080";
- const currentResponse = await page.request.get(`${control}/admin/harnesses/managed-configs`);
+ const currentResponse = await page.request.get(`${control}/admin/gateway/models`);
expect(currentResponse.status()).toBe(200);
const current = await currentResponse.json();
const model = `gpt-daemon-${Date.now()}`;
- const changed = String(current.configurations.claude).replace(/model:\s*[^\n]+/, `model: ${model}`);
- const update = await page.request.put(`${control}/admin/harnesses/claude/managed-config`, {
+ const claude = current.harnesses.find((item: { key: string }) => item.key === "claude");
+ expect(claude.exposure).toBe("catalog");
+ const update = await page.request.put(`${control}/admin/gateway/models/harnesses/claude`, {
data: {
base_revision: current.revision,
- managed_config_yaml: changed,
- version_requirement: current.version_requirements.claude,
+ gateway_models: [...claude.gateway_models, model],
+ default_model: model,
},
});
expect(update.status(), await update.text()).toBe(200);
- await expect.poll(
- () => readClientFile(home, ".config/blue/runtime/claude/settings.json"),
- { timeout: 20_000 },
- ).toContain(model);
+ await expect.poll(async () => {
+ const settings = JSON.parse(
+ await readClientFile(home, ".config/blue/runtime/claude/settings.json"),
+ );
+ return {
+ availableModels: settings.availableModels,
+ enforceAvailableModels: settings.enforceAvailableModels,
+ model: settings.model,
+ modelPicker: settings.modelPicker,
+ };
+ }, { timeout: 20_000 }).toEqual({
+ availableModels: [...claude.gateway_models, model],
+ enforceAvailableModels: true,
+ model,
+ modelPicker: {
+ options: [...claude.gateway_models, model].map((id: string) => ({ model: id, label: id })),
+ replaceBuiltInOptions: true,
+ },
+ });
} finally {
child.kill("SIGTERM");
}
@@ -1252,21 +1269,19 @@ esac
try {
await waitForOutput(child, /Ctrl-\] Control/);
const control = process.env.E2E_CONTROL_API_URL ?? "http://127.0.0.1:8080";
- const currentResponse = await page.request.get(`${control}/admin/harnesses/managed-configs`);
+ const currentResponse = await page.request.get(`${control}/admin/gateway/models`);
expect(currentResponse.status()).toBe(200);
const current = await currentResponse.json();
- const changed = String(current.configurations.codex).replace(
- /model:\s*[^\n]+/,
- `model: gpt-sse-${Date.now()}`,
- );
+ const model = `gpt-sse-${Date.now()}`;
+ const codex = current.harnesses.find((item: { key: string }) => item.key === "codex");
// Register before publishing the revision: the SSE event may arrive
// before the mutation response on a fast local stack.
const revisionNotice = waitForOutput(child, /New Blue policy/);
- const update = await page.request.put(`${control}/admin/harnesses/codex/managed-config`, {
+ const update = await page.request.put(`${control}/admin/gateway/models/harnesses/codex`, {
data: {
base_revision: current.revision,
- managed_config_yaml: changed,
- version_requirement: current.version_requirements.codex,
+ gateway_models: [...codex.gateway_models, model],
+ default_model: model,
},
});
expect(update.status(), await update.text()).toBe(200);
@@ -1339,7 +1354,9 @@ esac
const historyBefore = await (await page.request.get(`${control}/admin/governance-config/revisions`)).json();
const documents = ["conflict-a", "conflict-b"].map((suffix) => {
const document = YAML.parse(original.managed_yaml);
- document.harnesses.codex.managed_config.model = `gpt-${suffix}-${Date.now()}`;
+ const model = `gpt-${suffix}-${Date.now()}`;
+ document.harnesses.codex.managed_config.model = model;
+ document.harnesses.codex.gateway_models = [model];
return YAML.stringify(document);
});
@@ -1399,13 +1416,14 @@ esac
await expect(page).toHaveURL(/\/harnesses$/);
await page.getByRole("button", { name: "Actions for Codex" }).click();
await page.getByRole("menuitem", { name: "Edit", exact: true }).click();
- const model = `gpt-dashboard-${Date.now()}`;
- await page.getByLabel("Managed config YAML").fill(`model: ${model}\nreasoning_effort: low\napproval_policy: never\nsandbox_mode: workspace-write\n`);
+ const model = "gpt-e2e";
+ await page.getByLabel("Managed config YAML").fill(`model: ${model}\nreasoning_effort: high\napproval_policy: never\nsandbox_mode: workspace-write\n`);
await page.getByRole("button", { name: "Save changes" }).click();
await expect(page.getByRole("dialog")).toBeHidden();
const launch = await runCli(home, ["codex", "--dashboard-check"]);
expect(launch.code, launch.stderr).toBe(0);
expect(await readClientFile(home, ".codex/blue.config.toml")).toContain(model);
+ expect(await readClientFile(home, ".codex/blue.config.toml")).toContain('model_reasoning_effort = "high"');
expect(await readClientFile(home, "agent-log/codex.env")).toContain("check_for_update_on_startup=false");
await page.getByRole("button", { name: "Actions for Codex" }).click();
@@ -1438,6 +1456,108 @@ esac
expect(restoreResponse.status(), await restoreResponse.text()).toBe(200);
});
+ test("gateway model catalog assignments enforce editable catalogs and keep Codex read-only", async ({ page }) => {
+ await loginAsAdmin(page, { fresh: true });
+ const control = process.env.E2E_CONTROL_API_URL ?? "http://127.0.0.1:8080";
+ const snapshotResponse = await page.request.get(`${control}/admin/governance-config`);
+ expect(snapshotResponse.status(), await snapshotResponse.text()).toBe(200);
+ const snapshot = await snapshotResponse.json();
+
+ try {
+ const refreshed = await page.request.post(`${control}/admin/gateway/models/refresh`);
+ expect(refreshed.status(), await refreshed.text()).toBe(200);
+ const catalog = await refreshed.json();
+ expect(catalog.models.map((model: { id: string }) => model.id)).toEqual(expect.arrayContaining([
+ "e2e/model",
+ "e2e/alternate",
+ "gpt-e2e",
+ ]));
+ expect(catalog.sync.discovery_supported).toBe(true);
+
+ const kimiSaved = await page.request.put(`${control}/admin/gateway/models/harnesses/kimi`, {
+ data: {
+ base_revision: catalog.revision,
+ gateway_models: ["kimi-e2e", "kimi-e2e-secondary"],
+ default_model: null,
+ },
+ });
+ const kimiSavedBody = await kimiSaved.text();
+ expect(kimiSaved.status(), kimiSavedBody).toBe(200);
+ const kimiRevision = JSON.parse(kimiSavedBody).revision;
+
+ const saved = await page.request.put(`${control}/admin/gateway/models/harnesses/opencode`, {
+ data: {
+ base_revision: kimiRevision,
+ gateway_models: ["e2e/model", "e2e/alternate"],
+ default_model: "e2e/model",
+ },
+ });
+ expect(saved.status(), await saved.text()).toBe(200);
+
+ await page.goto("/gateway?tab=models");
+ await expect(page.getByRole("tab", { name: "Models" })).toBeVisible();
+ await expect(page.getByRole("tab", { name: "Discovery" })).toHaveAttribute("data-active", "");
+ await expect(page.getByText("Discovery status", { exact: true })).toBeVisible();
+
+ await page.getByRole("tab", { name: "Catalog" }).click();
+ await expect(page).toHaveURL(/tab=models.*models_tab=catalog/);
+ await expect(page.getByText("e2e/alternate", { exact: true }).first()).toBeVisible();
+
+ await page.getByRole("tab", { name: "Assignments" }).click();
+ await expect(page).toHaveURL(/tab=models.*models_tab=assignments/);
+ await page.setViewportSize({ width: 1280, height: 600 });
+ const codex = catalog.harnesses.find((item: { key: string }) => item.key === "codex");
+ const codexRow = page.getByRole("row").filter({ hasText: "Codex" });
+ await expect(codexRow).toContainText(String(codex.gateway_models.length));
+ await expect(codexRow).toContainText(codex.default_model ?? "Native fallback");
+ await expect(codexRow).toContainText("Selected only");
+ await expect(codexRow).toContainText("Full catalog assignment is unavailable for Codex.");
+ await expect(codexRow.getByRole("button", { name: "Edit Codex models" })).toBeDisabled();
+ for (const harness of ["Claude", "Kimi", "OpenCode"]) {
+ await expect(page.getByRole("button", { name: `Edit ${harness} models` })).toBeEnabled();
+ }
+ await page.getByRole("button", { name: "Edit OpenCode models" }).click();
+ await expect(page.getByRole("dialog")).toContainText("This agent exposes every assigned model");
+ await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Save assignments" })).toBeVisible();
+ const modelList = page.getByLabel("Available gateway models");
+ await expect(modelList).toHaveCSS("overflow-y", "auto");
+ expect(await modelList.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
+ await page.getByRole("button", { name: "Cancel" }).click();
+
+ const launch = await runCli(home, ["opencode", "--catalog-check"]);
+ expect(launch.code, launch.stderr).toBe(0);
+ const opencode = JSON.parse(await readClientFile(home, ".config/blue/runtime/opencode/opencode.json"));
+ expect(Object.keys(opencode.provider.governed.models)).toEqual(expect.arrayContaining([
+ "e2e/model",
+ "e2e/alternate",
+ ]));
+
+ await mkdir(path.join(home, ".kimi-code"), { recursive: true });
+ await writeFile(
+ path.join(home, ".kimi-code", "config.toml"),
+ 'default_model = "native-model"\n[models.native-model]\nprovider = "native-provider"\nmodel = "native-model"\nmax_context_size = 4096\n[providers.native-provider]\ntype = "openai"\nbase_url = "https://native.example"\napi_key = "native"\n',
+ );
+ const kimiLaunch = await runCli(home, ["kimi", "--catalog-check"]);
+ expect(kimiLaunch.code, kimiLaunch.stderr).toBe(0);
+ const kimi = await readClientFile(home, ".config/blue/runtime/kimi/config.toml");
+ expect(kimi).toContain('default_model = "kimi-e2e"');
+ expect(kimi).not.toContain("native-model");
+ expect(kimi).not.toContain("native-provider");
+ expect(kimi.match(/^\[models\./gm)).toHaveLength(2);
+ expect(kimi.match(/^\[providers\./gm)).toHaveLength(1);
+ expect(kimi.indexOf("[models.kimi-e2e]")).toBeLessThan(
+ kimi.indexOf("[models.kimi-e2e-secondary]"),
+ );
+ } finally {
+ const current = await (await page.request.get(`${control}/admin/governance-config`)).json();
+ const restored = await page.request.put(`${control}/admin/governance-config`, {
+ data: { base_revision: current.revision, managed_yaml: snapshot.managed_yaml },
+ });
+ expect(restored.status(), await restored.text()).toBe(200);
+ }
+ });
+
test("administrator API supports branding and invitation lifecycle", async ({ page }) => {
await loginAsAdmin(page);
const control = process.env.E2E_CONTROL_API_URL ?? "http://127.0.0.1:8080";
@@ -1651,8 +1771,8 @@ esac
const memberConfig = await memberContext.request.get(`${control}/governance-config`, {
headers: {
authorization: `Bearer ${memberOauth.token}`,
- "x-blue-contract-version": "3",
- "x-blue-capabilities": "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,unverified_harness_versions,tenant_client_version_pin",
+ "x-blue-contract-version": "4",
+ "x-blue-capabilities": "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,gateway_model_catalog,unverified_harness_versions,tenant_client_version_pin",
},
});
expect(memberConfig.status(), await memberConfig.text()).toBe(200);
@@ -1791,7 +1911,7 @@ esac
const originalDocument = YAML.parse(original.managed_yaml);
const enabledGatewayForTest = !originalDocument.gateway;
if (enabledGatewayForTest) {
- originalDocument.gateway = { type: "litellm" };
+ enableGateway(originalDocument);
const enabled = await page.request.put(`${control}/admin/governance-config`, {
data: { base_revision: original.revision, managed_yaml: YAML.stringify(originalDocument) },
});
diff --git a/tests/e2e/specs/zz-dashboard-filtering.spec.ts b/tests/e2e/specs/zz-dashboard-filtering.spec.ts
index 1fb4c62..d5f82b2 100644
--- a/tests/e2e/specs/zz-dashboard-filtering.spec.ts
+++ b/tests/e2e/specs/zz-dashboard-filtering.spec.ts
@@ -4,6 +4,7 @@ import { createHash, randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import https from "node:https";
import { loginAsAdmin } from "../support/dashboard.js";
+import { enableGateway } from "../support/governance.js";
const dashboard = process.env.E2E_DASHBOARD_URL ?? "http://127.0.0.1:3000";
const control = process.env.E2E_CONTROL_API_URL ?? "http://127.0.0.1:8080";
@@ -278,13 +279,7 @@ test.describe.serial("dashboard table filtering", () => {
const config = await configResponse.json() as { revision: string; managed_yaml: string };
if (!/^gateway:\s*$/m.test(config.managed_yaml)) {
const managedConfig = YAML.parse(config.managed_yaml);
- managedConfig.gateway = { type: "litellm" };
- managedConfig.required_capabilities = Array.from(
- new Set([
- ...(managedConfig.required_capabilities ?? []),
- "gateway_inference_jwt",
- ]),
- );
+ enableGateway(managedConfig);
const managedYaml = YAML.stringify(managedConfig);
const updateResponse = await adminPage.request.put(`${control}/admin/governance-config`, {
data: { base_revision: config.revision, managed_yaml: managedYaml },
diff --git a/tests/e2e/support/cli.ts b/tests/e2e/support/cli.ts
index f1634a2..07dd02d 100644
--- a/tests/e2e/support/cli.ts
+++ b/tests/e2e/support/cli.ts
@@ -89,7 +89,7 @@ export function cliEnv(home: string, extra: NodeJS.ProcessEnv = {}): NodeJS.Proc
NO_COLOR: "1",
E2E_AGENT_LOG_DIR: path.join(home, "agent-log"),
E2E_CODEX_VERSION: "0.145.0",
- E2E_CLAUDE_VERSION: "2.0.12",
+ E2E_CLAUDE_VERSION: "2.1.252",
E2E_KIMI_VERSION: "0.0.0",
E2E_OPENCODE_VERSION: "0.0.0",
...extra,
diff --git a/tests/e2e/support/governance.ts b/tests/e2e/support/governance.ts
new file mode 100644
index 0000000..d574446
--- /dev/null
+++ b/tests/e2e/support/governance.ts
@@ -0,0 +1,24 @@
+const fallbackGatewayModels: Record = {
+ codex: "gpt-e2e",
+ claude: "claude-e2e",
+ kimi: "kimi-e2e",
+ opencode: "e2e/model",
+};
+
+export function enableGateway(document: any) {
+ document.gateway = { type: "litellm" };
+ document.required_capabilities = Array.from(new Set([
+ ...(document.required_capabilities ?? []),
+ "gateway_inference_jwt",
+ "gateway_model_catalog",
+ ]));
+ document.harnesses ??= {};
+ for (const harness of document.allowed_harnesses ?? []) {
+ const policy = document.harnesses[harness] ??= {};
+ if (!Array.isArray(policy.gateway_models) || policy.gateway_models.length === 0) {
+ policy.gateway_models = [
+ policy.managed_config?.model ?? fallbackGatewayModels[harness] ?? "gpt-e2e",
+ ];
+ }
+ }
+}