From fbe8b50a16d71486f26d56c4edbb4c62606cac67 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Wed, 20 May 2026 09:05:00 -0400 Subject: [PATCH] feat(server): add GET /api/v1/providers and /settings/models page (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Operators had no UI surface to see which LLM providers their Fabro server has configured — provider state was only inferable indirectly via the per-model `configured` flag on `GET /api/v1/models`. This adds a dedicated **Models** settings tab backed by a new providers endpoint. - **`fabro_model::Provider`** — a public projection of the internal `CatalogProvider` that *structurally* excludes credential-bearing fields (`auth`, `extra_headers`, `billing_policy`, `agent_profile`). Reused by the generated API client via progenitor `with_replacement`, mirroring the existing `Model` pattern — no parallel API DTO. - **`GET /api/v1/providers`** — lists catalog providers with effective config and a `configured` status stamped per request from `ready_llm_provider_ids()`. Sorted by the catalog's existing `provider_order`. No write endpoints. - **`/settings/models` web page** — new route + nav entry (`CpuChipIcon`, between Integrations and Security) rendering each provider with model count, default model, configured status, and a "Get API key" link for unconfigured providers. ## Key decisions - Provider sort: reuse catalog `provider_order` (priority desc, id asc) — zero extra code. - `adapter` is hidden in the UI row (noisy for first-party providers); the OpenAPI `adapter` field is pinned to an enum matching the closed `AdapterKind` type. - `configured` reflects credential resolution **at the time of the response**, not a frozen startup snapshot — doc/spec wording corrected to match. ## Testing - `fabro-model`: `From<&CatalogProvider>` + serde `skip_serializing_if` unit tests. - `fabro-api`: `Provider` type-identity + JSON-parity tests, including the required/optional field split. - `fabro-server`: handler tests for configured vs unconfigured providers, exact `model_count`/`default_model` against catalog truth, and credential-omission (asserts internal field names *and* the injected credential value never reach the wire). - OpenAPI route conformance test covers `GET /api/v1/providers`. - `cargo build --workspace`, `fmt --check`, `clippy -D warnings` clean; 935 Rust tests pass; web `tsc` typecheck passes. - Reviewed via a 10-persona `ce:review` (autofix) — no P0/P1 in shipped code; 8 safe fixes applied. Not done: manual UI screenshots — the `apps/fabro-web` build is blocked in this environment by an unrelated missing `@assistant-ui/react` dependency. Run `bun install` in `apps/fabro-web` to verify `/settings/models` manually. ## Post-Deploy Monitoring & Validation - **What to watch:** request logs for `GET /api/v1/providers` — expect `200`s for authenticated users, `401` for unauthenticated. The handler resolves LLM credentials per request via `ready_llm_provider_ids()` (the same path the existing `list_models` handler already uses). - **Healthy signals:** `/settings/models` renders the provider list; `configured` matches each provider's actual credential state; no credential strings appear in any response body or log line. - **Failure signals / rollback trigger:** any provider object in the response containing `auth`, `extra_headers`, or a raw key/token value → roll back immediately (the projection type makes this structurally impossible, but treat any occurrence as P0). 5xx spikes on the new route. - **Validation window / owner:** first 24h after deploy, owned by the deploying engineer. Pre-existing note (not introduced here): credential resolution can refresh OAuth tokens and write the vault as a side effect of this read — shared with `list_models`; flagged for a future caching pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../app/components/settings-panel.tsx | 4 +- apps/fabro-web/app/lib/api-client.ts | 6 + apps/fabro-web/app/lib/queries.ts | 10 ++ apps/fabro-web/app/lib/query-keys.ts | 3 + apps/fabro-web/app/router.tsx | 2 + apps/fabro-web/app/routes/settings-models.tsx | 103 +++++++++++++++ apps/fabro-web/app/routes/settings.tsx | 7 ++ docs/public/api-reference/fabro-api.yaml | 77 ++++++++++++ lib/crates/fabro-api/build.rs | 1 + lib/crates/fabro-api/src/lib.rs | 2 +- .../fabro-api/tests/provider_id_round_trip.rs | 68 ++++++++++ .../fabro-api/tests/provider_round_trip.rs | 117 +++++++++++------- lib/crates/fabro-model/src/catalog.rs | 32 +++++ lib/crates/fabro-model/src/lib.rs | 2 + lib/crates/fabro-model/src/provider.rs | 84 +++++++++++++ lib/crates/fabro-server/src/server.rs | 19 +-- .../fabro-server/src/server/handler/models.rs | 17 ++- lib/crates/fabro-server/src/server/tests.rs | 110 ++++++++++++++++ .../src/.openapi-generator/FILES | 2 + .../fabro-api-client/src/api/models-api.ts | 69 +++++++++++ .../fabro-api-client/src/models/index.ts | 2 + .../src/models/provider-list.ts | 26 ++++ .../fabro-api-client/src/models/provider.ts | 72 +++++++++++ 23 files changed, 775 insertions(+), 60 deletions(-) create mode 100644 apps/fabro-web/app/routes/settings-models.tsx create mode 100644 lib/crates/fabro-api/tests/provider_id_round_trip.rs create mode 100644 lib/crates/fabro-model/src/provider.rs create mode 100644 lib/packages/fabro-api-client/src/models/provider-list.ts create mode 100644 lib/packages/fabro-api-client/src/models/provider.ts diff --git a/apps/fabro-web/app/components/settings-panel.tsx b/apps/fabro-web/app/components/settings-panel.tsx index 1fa8bcb42..e6106a329 100644 --- a/apps/fabro-web/app/components/settings-panel.tsx +++ b/apps/fabro-web/app/components/settings-panel.tsx @@ -34,8 +34,8 @@ export function Row({ help, children, }: { - title: string; - help?: string; + title: ReactNode; + help?: ReactNode; children: ReactNode; }) { return ( diff --git a/apps/fabro-web/app/lib/api-client.ts b/apps/fabro-web/app/lib/api-client.ts index 934019e4c..8827b6cce 100644 --- a/apps/fabro-web/app/lib/api-client.ts +++ b/apps/fabro-web/app/lib/api-client.ts @@ -10,6 +10,7 @@ import { HumanInTheLoopApi, InsightsApi, InstallApi, + ModelsApi, RunInternalsApi, RunInternalsApiAxiosParamCreator, RunOutputsApi, @@ -87,6 +88,11 @@ export const installApi = new InstallApi( "", generatedAxios, ); +export const modelsApi = new ModelsApi( + generatedApiConfiguration, + "", + generatedAxios, +); export const runInternalsApi = new RunInternalsApi( generatedApiConfiguration, "", diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index 8f7bce147..eca7ae931 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -12,6 +12,7 @@ import type { PaginatedRunList, PaginatedRunStageList, PaginatedWorkflowListResponse, + ProviderList, RunArtifactListResponse, RunBilling, RunProjection, @@ -36,6 +37,7 @@ import { generatedAxios, humanInTheLoopApi, insightsApi, + modelsApi, runInternalsApi, runOutputsApi, runsApi, @@ -365,3 +367,11 @@ export function useServerSettings() { immutableOptions, ); } + +export function useProviders() { + return useSWR( + queryKeys.providers.list(), + () => apiData(() => modelsApi.listProviders()), + immutableOptions, + ); +} diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index eaf2b952c..5f36b5341 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -92,4 +92,7 @@ export const queryKeys = { settings: { server: () => ["settings", "server"] as const, }, + providers: { + list: () => ["providers", "list"] as const, + }, }; diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index 4cb1e7f10..53f960b92 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -34,6 +34,7 @@ import * as InsightsNew from "./routes/insights-new"; import * as Settings from "./routes/settings"; import * as SettingsGeneral from "./routes/settings-general"; import * as SettingsIntegrations from "./routes/settings-integrations"; +import * as SettingsModels from "./routes/settings-models"; import * as SettingsSecurity from "./routes/settings-security"; import * as SettingsStorage from "./routes/settings-storage"; import * as SettingsLiveEvents from "./routes/settings-live-events"; @@ -135,6 +136,7 @@ export const routes: RouteObject[] = [ children: [ indexRoute(SettingsGeneral), route("integrations", SettingsIntegrations), + route("models", SettingsModels), route("security", SettingsSecurity), route("storage", SettingsStorage), route("live-events", SettingsLiveEvents), diff --git a/apps/fabro-web/app/routes/settings-models.tsx b/apps/fabro-web/app/routes/settings-models.tsx new file mode 100644 index 000000000..2a2a05fb6 --- /dev/null +++ b/apps/fabro-web/app/routes/settings-models.tsx @@ -0,0 +1,103 @@ +import type { Provider } from "@qltysh/fabro-api-client"; +import { useProviders } from "../lib/queries"; +import { + Badge, + Dot, + Panel, + PanelSkeleton, + Row, + SettingsPageIntro, + plural, +} from "../components/settings-panel"; + +export function meta() { + return [{ title: "Models — Fabro" }]; +} + +export default function SettingsModels() { + const query = useProviders(); + + return ( +
+ + {query.data ? ( + + ) : ( + + )} +
+ ); +} + +function ProvidersPanel({ providers }: { providers: Provider[] }) { + return ( + + {providers.length === 0 ? ( +
+ No LLM providers in the catalog. +
+ ) : ( + providers.map((provider) => ( + + )) + )} +
+ ); +} + +function ProviderRow({ provider }: { provider: Provider }) { + return ( + + {provider.display_name} + {provider.id} + + } + help={} + > + + + ); +} + +function ProviderHelp({ provider }: { provider: Provider }) { + return ( + + + {provider.model_count} {plural(provider.model_count, "model", "models")} + + + default {provider.default_model ?? "—"} + {provider.base_url ? ( + <> + + {provider.base_url} + + ) : null} + + ); +} + +function ProviderStatus({ provider }: { provider: Provider }) { + return ( + + + + + {provider.configured ? "Configured" : "Not configured"} + + + {!provider.configured && provider.api_key_url ? ( + + Get API key → + + ) : null} + + ); +} diff --git a/apps/fabro-web/app/routes/settings.tsx b/apps/fabro-web/app/routes/settings.tsx index eef4be32e..4313b328f 100644 --- a/apps/fabro-web/app/routes/settings.tsx +++ b/apps/fabro-web/app/routes/settings.tsx @@ -2,6 +2,7 @@ import { BoltIcon, CircleStackIcon, Cog6ToothIcon, + CpuChipIcon, PuzzlePieceIcon, ShieldCheckIcon, } from "@heroicons/react/24/outline"; @@ -38,6 +39,12 @@ const navItems: NavEntry[] = [ icon: PuzzlePieceIcon, match: (p) => p.startsWith("/settings/integrations"), }, + { + name: "Models", + href: "/settings/models", + icon: CpuChipIcon, + match: (p) => p.startsWith("/settings/models"), + }, { name: "Security", href: "/settings/security", diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 4426ccb7f..397a2da68 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -3864,6 +3864,20 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/providers: + get: + operationId: listProviders + tags: [Models] + summary: List Providers + description: Returns LLM providers from the catalog with effective config and configured status. + responses: + "200": + description: Provider list + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderList" + # ── Completions ─────────────────────────────────────────────────────── /api/v1/completions: @@ -5028,6 +5042,69 @@ components: meta: $ref: "#/components/schemas/PaginationMeta" + ProviderList: + description: List of LLM providers from the catalog. + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/Provider" + + Provider: + description: An LLM provider from the catalog with effective config and configured status. + type: object + required: + - id + - display_name + - adapter + - priority + - model_count + - configured + properties: + id: + $ref: "#/components/schemas/ProviderId" + display_name: + type: string + description: Human-readable provider name. + example: "Anthropic" + adapter: + type: string + enum: [anthropic, openai, gemini, openai_compatible] + description: Protocol adapter the provider speaks. + example: "anthropic" + base_url: + type: ["string", "null"] + description: Operator-set base URL override, if any. + api_key_url: + type: ["string", "null"] + description: URL where an operator can obtain an API key for this provider. + priority: + type: integer + format: int32 + description: Catalog ordering priority; higher sorts first. + aliases: + type: array + items: + type: string + description: Alternative identifiers that resolve to this provider. + model_count: + type: integer + format: int32 + minimum: 0 + description: Number of catalog models belonging to this provider. + default_model: + type: ["string", "null"] + description: Catalog default model ID for this provider, if any. + configured: + type: boolean + description: | + Whether credential material is present for this provider on the + server when this response was produced. Does NOT imply requests + will succeed. + ProviderId: description: LLM provider identifier. type: string diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index d18345aca..126eb9d13 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -366,6 +366,7 @@ fn main() { ("ExecOutputTail", "fabro_types::ExecOutputTail", &[]), ("ProviderId", "fabro_model::ProviderId", &[]), ("Model", "fabro_model::Model", &[]), + ("Provider", "fabro_model::Provider", &[]), ("ModelLimits", "fabro_model::ModelLimits", &[]), ( "ReasoningEffortFeature", diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 4874218f2..7e1006c08 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -16,7 +16,7 @@ mod generated { pub mod types { pub use fabro_model::{ Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode, - ReasoningEffortFeature, Speed as BillingSpeed, + Provider, ReasoningEffortFeature, Speed as BillingSpeed, }; pub use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings, diff --git a/lib/crates/fabro-api/tests/provider_id_round_trip.rs b/lib/crates/fabro-api/tests/provider_id_round_trip.rs new file mode 100644 index 000000000..b20e15370 --- /dev/null +++ b/lib/crates/fabro-api/tests/provider_id_round_trip.rs @@ -0,0 +1,68 @@ +use std::any::{TypeId, type_name}; + +use fabro_api::types::Model as ApiModel; +use fabro_model::{ + Model, ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffortFeature, +}; +use serde_json::json; + +#[test] +fn provider_id_reuses_canonical_model_field_type() { + assert_same_type::(); +} + +#[test] +fn provider_id_json_matches_openapi_shape_through_model() { + assert_eq!( + serde_json::to_value(ProviderId::anthropic()).unwrap(), + json!("anthropic") + ); + assert_eq!( + serde_json::to_value(ProviderId::openai()).unwrap(), + json!("openai") + ); + + let model = Model { + id: "venice-custom".to_string(), + provider: ProviderId::new("venice"), + family: "venice".to_string(), + display_name: "Venice Custom".to_string(), + limits: ModelLimits { + context_window: 128_000, + max_output: None, + }, + training: None, + knowledge_cutoff: None, + features: ModelFeatures { + tools: false, + vision: false, + reasoning: false, + reasoning_effort: ReasoningEffortFeature::None, + prompt_cache: false, + }, + costs: ModelCosts { + input_cost_per_mtok: None, + output_cost_per_mtok: None, + cache_input_cost_per_mtok: None, + }, + estimated_output_tps: None, + aliases: Vec::new(), + default: false, + configured: true, + }; + + let json = serde_json::to_value(&model).unwrap(); + assert_eq!(json["provider"], "venice"); + let round_trip: ApiModel = serde_json::from_value(json).unwrap(); + assert_eq!(round_trip.provider, ProviderId::new("venice")); +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} diff --git a/lib/crates/fabro-api/tests/provider_round_trip.rs b/lib/crates/fabro-api/tests/provider_round_trip.rs index b20e15370..dc2d75aa2 100644 --- a/lib/crates/fabro-api/tests/provider_round_trip.rs +++ b/lib/crates/fabro-api/tests/provider_round_trip.rs @@ -1,60 +1,81 @@ use std::any::{TypeId, type_name}; -use fabro_api::types::Model as ApiModel; -use fabro_model::{ - Model, ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffortFeature, -}; -use serde_json::json; +use fabro_api::types::Provider as ApiProvider; +use fabro_model::adapter::AdapterKind; +use fabro_model::{Provider, ProviderId}; #[test] -fn provider_id_reuses_canonical_model_field_type() { - assert_same_type::(); +fn provider_reuses_canonical_type() { + assert_same_type::(); } #[test] -fn provider_id_json_matches_openapi_shape_through_model() { - assert_eq!( - serde_json::to_value(ProviderId::anthropic()).unwrap(), - json!("anthropic") - ); - assert_eq!( - serde_json::to_value(ProviderId::openai()).unwrap(), - json!("openai") - ); - - let model = Model { - id: "venice-custom".to_string(), - provider: ProviderId::new("venice"), - family: "venice".to_string(), - display_name: "Venice Custom".to_string(), - limits: ModelLimits { - context_window: 128_000, - max_output: None, - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: false, - vision: false, - reasoning: false, - reasoning_effort: ReasoningEffortFeature::None, - prompt_cache: false, - }, - costs: ModelCosts { - input_cost_per_mtok: None, - output_cost_per_mtok: None, - cache_input_cost_per_mtok: None, - }, - estimated_output_tps: None, - aliases: Vec::new(), - default: false, - configured: true, +fn provider_json_matches_openapi_shape() { + let provider = Provider { + id: ProviderId::anthropic(), + display_name: "Anthropic".to_string(), + adapter: AdapterKind::Anthropic, + base_url: Some("https://api.anthropic.test/v1".to_string()), + api_key_url: Some("https://console.anthropic.com/settings/keys".to_string()), + priority: 100, + aliases: vec!["claude".to_string()], + model_count: 7, + default_model: Some("claude-opus-4-7".to_string()), + configured: true, }; - let json = serde_json::to_value(&model).unwrap(); - assert_eq!(json["provider"], "venice"); - let round_trip: ApiModel = serde_json::from_value(json).unwrap(); - assert_eq!(round_trip.provider, ProviderId::new("venice")); + let json = serde_json::to_value(&provider).unwrap(); + assert_eq!(json["id"], "anthropic"); + assert_eq!(json["display_name"], "Anthropic"); + assert_eq!(json["adapter"], "anthropic"); + assert_eq!(json["base_url"], "https://api.anthropic.test/v1"); + assert_eq!( + json["api_key_url"], + "https://console.anthropic.com/settings/keys" + ); + assert_eq!(json["priority"], 100); + assert_eq!(json["aliases"], serde_json::json!(["claude"])); + assert_eq!(json["model_count"], 7); + assert_eq!(json["default_model"], "claude-opus-4-7"); + assert_eq!(json["configured"], true); + + let round_trip: ApiProvider = serde_json::from_value(json).unwrap(); + assert_eq!(round_trip, provider); +} + +#[test] +fn provider_omits_optional_fields_when_absent() { + // Proves the required/optional split the OpenAPI `Provider` schema + // declares: the four `skip_serializing_if` fields drop out entirely, while + // the six required fields always serialize. + let provider = Provider { + id: ProviderId::new("custom"), + display_name: "Custom".to_string(), + adapter: AdapterKind::OpenAiCompatible, + base_url: None, + api_key_url: None, + priority: 0, + aliases: Vec::new(), + model_count: 0, + default_model: None, + configured: false, + }; + + let json = serde_json::to_value(&provider).unwrap(); + let object = json.as_object().unwrap(); + assert!(!object.contains_key("base_url")); + assert!(!object.contains_key("api_key_url")); + assert!(!object.contains_key("aliases")); + assert!(!object.contains_key("default_model")); + assert!(object.contains_key("id")); + assert!(object.contains_key("display_name")); + assert!(object.contains_key("adapter")); + assert!(object.contains_key("priority")); + assert!(object.contains_key("model_count")); + assert!(object.contains_key("configured")); + + let round_trip: ApiProvider = serde_json::from_value(json).unwrap(); + assert_eq!(round_trip, provider); } fn assert_same_type() { diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index 966d9ee8a..69f687670 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -12,6 +12,7 @@ use tracing::warn; use crate::Speed; use crate::adapter::{AdapterKind, AgentProfileKind}; use crate::ids::ProviderId; +use crate::provider::Provider; use crate::reasoning::ReasoningEffort; use crate::types::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature}; @@ -777,6 +778,37 @@ impl Catalog { &self.providers } + #[must_use] + pub fn provider_summaries(&self, configured: &HashSet) -> Vec { + #[derive(Default)] + struct Stats { + model_count: u32, + default_model: Option, + } + + let mut stats_by_provider = HashMap::::new(); + for model in &self.models { + let stats = stats_by_provider.entry(model.provider.clone()).or_default(); + stats.model_count = stats.model_count.saturating_add(1); + if model.default { + stats.default_model = Some(model.id.clone()); + } + } + + self.providers + .iter() + .map(|provider| { + let stats = stats_by_provider.remove(&provider.id).unwrap_or_default(); + Provider::from_catalog( + provider, + stats.model_count, + stats.default_model, + configured.contains(&provider.id), + ) + }) + .collect() + } + #[must_use] pub fn provider(&self, id: &ProviderId) -> Option<&CatalogProvider> { let canonical = self.provider_aliases.get(id.as_str()).unwrap_or(id); diff --git a/lib/crates/fabro-model/src/lib.rs b/lib/crates/fabro-model/src/lib.rs index 012429302..92e95ca0b 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/crates/fabro-model/src/lib.rs @@ -5,6 +5,7 @@ pub mod catalog; pub mod ids; pub mod model_ref; pub mod model_test; +pub mod provider; pub mod reasoning; pub mod types; @@ -22,5 +23,6 @@ pub use catalog::{ pub use ids::{ModelId, ProviderId}; pub use model_ref::ModelHandle; pub use model_test::ModelTestMode; +pub use provider::Provider; pub use reasoning::ReasoningEffort; pub use types::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature}; diff --git a/lib/crates/fabro-model/src/provider.rs b/lib/crates/fabro-model/src/provider.rs new file mode 100644 index 000000000..03b0caa8c --- /dev/null +++ b/lib/crates/fabro-model/src/provider.rs @@ -0,0 +1,84 @@ +use serde::{Deserialize, Serialize}; + +use crate::adapter::AdapterKind; +use crate::catalog::CatalogProvider; +use crate::ids::ProviderId; + +/// A user-facing LLM provider from the catalog. +/// +/// The public projection of [`CatalogProvider`]. It deliberately omits +/// internal-only fields (`auth`, `extra_headers`, `billing_policy`, +/// `agent_profile`) so credential material never reaches the wire. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Provider { + pub id: ProviderId, + pub display_name: String, + pub adapter: AdapterKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key_url: Option, + pub priority: i32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub aliases: Vec, + /// Number of catalog models for this provider. Stamped by the handler. + pub model_count: u32, + /// Catalog default model ID for this provider, if any. Stamped by the + /// handler. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, + /// True if the server has credential material configured for this provider + /// when the response is produced. Always `false` in static catalog data; + /// stamped by `GET /providers` per request. + #[serde(default)] + pub configured: bool, +} + +impl Provider { + #[must_use] + pub fn from_catalog( + provider: &CatalogProvider, + model_count: u32, + default_model: Option, + configured: bool, + ) -> Self { + Self { + id: provider.id.clone(), + display_name: provider.display_name.clone(), + adapter: provider.adapter, + base_url: provider.base_url.clone(), + api_key_url: provider.api_key_url.clone(), + priority: provider.priority, + aliases: provider.aliases.clone(), + model_count, + default_model, + configured, + } + } +} + +#[cfg(test)] +mod tests { + use super::Provider; + use crate::catalog::Catalog; + use crate::ids::ProviderId; + + #[test] + fn from_catalog_provider_copies_static_fields_and_supplied_runtime_fields() { + let catalog = Catalog::builtin(); + let anthropic = catalog + .provider(&ProviderId::anthropic()) + .expect("builtin catalog must define anthropic"); + + let provider = + Provider::from_catalog(anthropic, 7, Some("claude-opus-4-7".to_string()), true); + + assert_eq!(provider.id, ProviderId::anthropic()); + assert_eq!(provider.display_name, anthropic.display_name); + assert_eq!(provider.adapter, anthropic.adapter); + assert_eq!(provider.priority, anthropic.priority); + assert_eq!(provider.model_count, 7); + assert_eq!(provider.default_model.as_deref(), Some("claude-opus-4-7")); + assert!(provider.configured); + } +} diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 53aba522e..94d44db22 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -30,13 +30,13 @@ pub use fabro_api::types::{ DeleteSecretRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, ForkRequest, ForkResponse, LinkRunPullRequestRequest, MergeRunPullRequestRequest, MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList, - PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, PruneRunEntry, - PruneRunsRequest, PruneRunsResponse, RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, - RewindRequest, RewindResponse, RunArtifactEntry, RunArtifactListResponse, RunBilling, - RunBillingStage, RunBillingTotals, RunError, RunManifest, RunStage, SandboxDetails, - SandboxFileEntry, SandboxFileListResponse, SandboxService, SandboxServiceListResponse, - SshAccessRequest, SshAccessResponse, StageHandler, StageState, StartRunRequest, - SubmitAnswerRequest, SystemFeatures, SystemInfoResponse, SystemRepairRunIssue, + PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, Provider, + ProviderList, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, RenderWorkflowGraphDirection, + RenderWorkflowGraphRequest, RewindRequest, RewindResponse, RunArtifactEntry, + RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, RunError, RunManifest, + RunStage, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService, + SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, StageHandler, StageState, + StartRunRequest, SubmitAnswerRequest, SystemFeatures, SystemInfoResponse, SystemRepairRunIssue, SystemRepairRunsResponse, SystemRunCounts, TimelineEntryResponse, VncPreviewResponse, WriteBlobResponse, }; @@ -714,6 +714,11 @@ impl AppState { resolve_llm_client_from_source(self.llm_source.as_ref(), self.catalog()).await } + pub(crate) async fn configured_llm_provider_ids(&self) -> Vec { + let catalog = self.catalog(); + self.llm_source.configured_providers(catalog.as_ref()).await + } + pub(crate) async fn ready_llm_provider_ids(&self) -> Vec { match self.resolve_llm_client().await { Ok(result) => result.provider_ids(), diff --git a/lib/crates/fabro-server/src/server/handler/models.rs b/lib/crates/fabro-server/src/server/handler/models.rs index 7916bd890..4f218dbf6 100644 --- a/lib/crates/fabro-server/src/server/handler/models.rs +++ b/lib/crates/fabro-server/src/server/handler/models.rs @@ -2,14 +2,15 @@ use std::sync::Arc; use super::super::{ ApiError, AppState, FromStr, HashSet, IntoResponse, Json, MAX_PAGE_OFFSET, ModelTestMode, Path, - ProviderId, Query, RequiredUser, Response, Router, State, StatusCode, auth_issue_message, - default_page_limit, error, get, post, run_model_test, + ProviderId, ProviderList, Query, RequiredUser, Response, Router, State, StatusCode, + auth_issue_message, default_page_limit, error, get, post, run_model_test, }; pub(super) fn routes() -> Router> { Router::new() .route("/models", get(list_models)) .route("/models/{id}/test", post(test_model)) + .route("/providers", get(list_providers)) } #[derive(serde::Deserialize)] @@ -80,6 +81,18 @@ async fn list_models( .into_response() } +async fn list_providers(_auth: RequiredUser, State(state): State>) -> Response { + let catalog = state.catalog(); + let configured: HashSet = state + .configured_llm_provider_ids() + .await + .into_iter() + .collect(); + let data = catalog.provider_summaries(&configured); + + (StatusCode::OK, Json(ProviderList { data })).into_response() +} + async fn test_model( _auth: RequiredUser, State(state): State>, diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index e753ee7c9..b7d3984a5 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -5086,6 +5086,116 @@ reasoning = false assert_eq!(models[0]["provider"], "acme"); } +#[tokio::test] +async fn list_providers_marks_configured_per_provider_and_omits_secrets() { + // Only `ANTHROPIC_API_KEY` is supplied, so anthropic resolves as configured + // while every other catalog provider does not. + let state = test_app_state_with_env_lookup( + default_test_server_settings(), + RunLayer::default(), + 5, + |name| (name == EnvVars::ANTHROPIC_API_KEY).then(|| "test-key".to_string()), + ); + let app = crate::test_support::build_test_router(state); + + let req = Request::builder() + .method("GET") + .uri(api("/providers")) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + let body = response_json!(response, StatusCode::OK).await; + let providers = body["data"].as_array().unwrap(); + + assert!( + providers.len() >= 2, + "builtin catalog should expose multiple providers" + ); + + let anthropic = providers + .iter() + .find(|provider| provider["id"] == "anthropic") + .expect("anthropic provider should be present"); + assert_eq!(anthropic["configured"].as_bool(), Some(true)); + + // `model_count` and `default_model` must reflect the catalog truth for + // this exact provider, not merely be populated. + let catalog = Catalog::builtin(); + let expected_model_count = catalog.list(Some(&ProviderId::anthropic())).len(); + assert_eq!( + anthropic["model_count"].as_u64(), + Some(expected_model_count as u64), + "anthropic model_count should match the catalog" + ); + let expected_default = catalog + .default_for_provider(&ProviderId::anthropic()) + .expect("anthropic should have a catalog default model"); + assert_eq!( + anthropic["default_model"].as_str(), + Some(expected_default.id.as_str()), + "anthropic default_model should match the catalog" + ); + + assert!( + providers + .iter() + .filter(|provider| provider["id"] != "anthropic") + .all(|provider| provider["configured"].as_bool() == Some(false)), + "providers without supplied credentials should be unconfigured" + ); + + // Internal-only catalog fields and the injected credential value must + // never reach the wire. + let serialized = body["data"].to_string(); + assert!(!serialized.contains("\"auth\""), "leaked `auth`"); + assert!( + !serialized.contains("\"extra_headers\""), + "leaked `extra_headers`" + ); + assert!( + !serialized.contains("\"billing_policy\""), + "leaked `billing_policy`" + ); + assert!( + !serialized.contains("\"agent_profile\""), + "leaked `agent_profile`" + ); + assert!( + !serialized.contains("test-key"), + "leaked the injected credential value" + ); +} + +#[tokio::test] +async fn list_providers_marks_all_unconfigured_without_credentials() { + let state = test_app_state_with_env_lookup( + default_test_server_settings(), + RunLayer::default(), + 5, + |_| None, + ); + let app = crate::test_support::build_test_router(state); + + let req = Request::builder() + .method("GET") + .uri(api("/providers")) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + let body = response_json!(response, StatusCode::OK).await; + let providers = body["data"].as_array().unwrap(); + + assert!(!providers.is_empty()); + assert!( + providers + .iter() + .all(|provider| provider["configured"].as_bool() == Some(false)), + "no provider should be configured when no credentials are supplied" + ); +} + #[tokio::test] async fn auth_login_github_redirects_to_github() { let source = r#" diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index bff855da8..46ad2dc83 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -220,6 +220,8 @@ models/principal-webhook.ts models/principal-worker.ts models/principal.ts models/project-namespace.ts +models/provider-list.ts +models/provider.ts models/prune-run-entry.ts models/prune-runs-request.ts models/prune-runs-response.ts diff --git a/lib/packages/fabro-api-client/src/api/models-api.ts b/lib/packages/fabro-api-client/src/api/models-api.ts index df5eaf19a..584e6acc8 100644 --- a/lib/packages/fabro-api-client/src/api/models-api.ts +++ b/lib/packages/fabro-api-client/src/api/models-api.ts @@ -29,6 +29,8 @@ import type { ModelTestMode } from '../models'; import type { ModelTestResult } from '../models'; // @ts-ignore import type { PaginatedModelList } from '../models'; +// @ts-ignore +import type { ProviderList } from '../models'; /** * ModelsApi - axios parameter creator */ @@ -90,6 +92,42 @@ export const ModelsApiAxiosParamCreator = function (configuration?: Configuratio options: localVarRequestOptions, }; }, + /** + * Returns LLM providers from the catalog with effective config and configured status. + * @summary List Providers + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listProviders: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/v1/providers`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Tests a model by sending a simple prompt and reporting pass/fail. * @summary Test Model @@ -160,6 +198,18 @@ export const ModelsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['ModelsApi.listModels']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Returns LLM providers from the catalog with effective config and configured status. + * @summary List Providers + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listProviders(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listProviders(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ModelsApi.listProviders']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Tests a model by sending a simple prompt and reporting pass/fail. * @summary Test Model @@ -196,6 +246,15 @@ export const ModelsApiFactory = function (configuration?: Configuration, basePat listModels(provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.listModels(provider, query, pageLimit, pageOffset, options).then((request) => request(axios, basePath)); }, + /** + * Returns LLM providers from the catalog with effective config and configured status. + * @summary List Providers + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listProviders(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listProviders(options).then((request) => request(axios, basePath)); + }, /** * Tests a model by sending a simple prompt and reporting pass/fail. * @summary Test Model @@ -228,6 +287,16 @@ export class ModelsApi extends BaseAPI { return ModelsApiFp(this.configuration).listModels(provider, query, pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath)); } + /** + * Returns LLM providers from the catalog with effective config and configured status. + * @summary List Providers + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public listProviders(options?: RawAxiosRequestConfig) { + return ModelsApiFp(this.configuration).listProviders(options).then((request) => request(this.axios, this.basePath)); + } + /** * Tests a model by sending a simple prompt and reporting pass/fail. * @summary Test Model diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index a0f8b2dd9..178d33d36 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -196,6 +196,8 @@ export * from './principal-user'; export * from './principal-webhook'; export * from './principal-worker'; export * from './project-namespace'; +export * from './provider'; +export * from './provider-list'; export * from './prune-run-entry'; export * from './prune-runs-request'; export * from './prune-runs-response'; diff --git a/lib/packages/fabro-api-client/src/models/provider-list.ts b/lib/packages/fabro-api-client/src/models/provider-list.ts new file mode 100644 index 000000000..68f3395c8 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/provider-list.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { Provider } from './provider'; + +/** + * List of LLM providers from the catalog. + */ +export interface ProviderList { + 'data': Array; +} + diff --git a/lib/packages/fabro-api-client/src/models/provider.ts b/lib/packages/fabro-api-client/src/models/provider.ts new file mode 100644 index 000000000..049e33fac --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/provider.ts @@ -0,0 +1,72 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * An LLM provider from the catalog with effective config and configured status. + */ +export interface Provider { + /** + * LLM provider identifier. + */ + 'id': string; + /** + * Human-readable provider name. + */ + 'display_name': string; + /** + * Protocol adapter the provider speaks. + */ + 'adapter': ProviderAdapterEnum; + /** + * Operator-set base URL override, if any. + */ + 'base_url'?: string | null; + /** + * URL where an operator can obtain an API key for this provider. + */ + 'api_key_url'?: string | null; + /** + * Catalog ordering priority; higher sorts first. + */ + 'priority': number; + /** + * Alternative identifiers that resolve to this provider. + */ + 'aliases'?: Array; + /** + * Number of catalog models belonging to this provider. + */ + 'model_count': number; + /** + * Catalog default model ID for this provider, if any. + */ + 'default_model'?: string | null; + /** + * Whether credential material is present for this provider on the server when this response was produced. Does NOT imply requests will succeed. + */ + 'configured': boolean; +} + +export const ProviderAdapterEnum = { + ANTHROPIC: 'anthropic', + OPENAI: 'openai', + GEMINI: 'gemini', + OPENAI_COMPATIBLE: 'openai_compatible' +} as const; + +export type ProviderAdapterEnum = typeof ProviderAdapterEnum[keyof typeof ProviderAdapterEnum]; + +