feat(server): add GET /api/v1/providers and /settings/models page (#321)

## 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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-20 09:05:00 -04:00 • committed by GitHub
parent ef70dbc5be
commit fbe8b50a16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 775 additions and 60 deletions

View file

@ -34,8 +34,8 @@ export function Row({
help,
children,
}: {
title: string;
help?: string;
title: ReactNode;
help?: ReactNode;
children: ReactNode;
}) {
return (

View file

@ -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,
"",

View file

@ -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<ProviderList>(
queryKeys.providers.list(),
() => apiData(() => modelsApi.listProviders()),
immutableOptions,
);
}

View file

@ -92,4 +92,7 @@ export const queryKeys = {
settings: {
server: () => ["settings", "server"] as const,
},
providers: {
list: () => ["providers", "list"] as const,
},
};

View file

@ -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),

View file

@ -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 (
<div className="space-y-6">
<SettingsPageIntro description="LLM providers configured on this Fabro server." />
{query.data ? (
<ProvidersPanel providers={query.data.data} />
) : (
<PanelSkeleton />
)}
</div>
);
}
function ProvidersPanel({ providers }: { providers: Provider[] }) {
return (
<Panel title="Providers">
{providers.length === 0 ? (
<div className="px-4 py-6 text-sm text-fg-muted">
No LLM providers in the catalog.
</div>
) : (
providers.map((provider) => (
<ProviderRow key={provider.id} provider={provider} />
))
)}
</Panel>
);
}
function ProviderRow({ provider }: { provider: Provider }) {
return (
<Row
title={
<span className="inline-flex items-center gap-2">
{provider.display_name}
<Badge>{provider.id}</Badge>
</span>
}
help={<ProviderHelp provider={provider} />}
>
<ProviderStatus provider={provider} />
</Row>
);
}
function ProviderHelp({ provider }: { provider: Provider }) {
return (
<span className="inline-flex flex-wrap items-center gap-x-1.5">
<span>
{provider.model_count} {plural(provider.model_count, "model", "models")}
</span>
<span aria-hidden="true">·</span>
<span>default {provider.default_model ?? "—"}</span>
{provider.base_url ? (
<>
<span aria-hidden="true">·</span>
<span className="font-mono">{provider.base_url}</span>
</>
) : null}
</span>
);
}
function ProviderStatus({ provider }: { provider: Provider }) {
return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="inline-flex items-center gap-2">
<Dot on={provider.configured} />
<span className={provider.configured ? "text-fg" : "text-fg-muted"}>
{provider.configured ? "Configured" : "Not configured"}
</span>
</span>
{!provider.configured && provider.api_key_url ? (
<a
href={provider.api_key_url}
target="_blank"
rel="noreferrer"
className="text-xs text-teal-500 hover:underline"
>
Get API key →
</a>
) : null}
</span>
);
}

View file

@ -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",

View file

@ -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

View file

@ -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",

View file

@ -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,

View file

@ -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::<ApiModel, Model>();
}
#[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<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -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::<ApiModel, Model>();
fn provider_reuses_canonical_type() {
assert_same_type::<ApiProvider, Provider>();
}
#[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<T: 'static, U: 'static>() {

View file

@ -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<ProviderId>) -> Vec<Provider> {
#[derive(Default)]
struct Stats {
model_count: u32,
default_model: Option<String>,
}
let mut stats_by_provider = HashMap::<ProviderId, Stats>::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);

View file

@ -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};

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_url: Option<String>,
pub priority: i32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
/// 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<String>,
/// 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<String>,
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);
}
}

View file

@ -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<ProviderId> {
let catalog = self.catalog();
self.llm_source.configured_providers(catalog.as_ref()).await
}
pub(crate) async fn ready_llm_provider_ids(&self) -> Vec<ProviderId> {
match self.resolve_llm_client().await {
Ok(result) => result.provider_ids(),

View file

@ -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<Arc<AppState>> {
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<Arc<AppState>>) -> Response {
let catalog = state.catalog();
let configured: HashSet<ProviderId> = 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<Arc<AppState>>,

View file

@ -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#"

View file

@ -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

View file

@ -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<RequestArgs> => {
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<ProviderList>> {
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<PaginatedModelList> {
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<ProviderList> {
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

View file

@ -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';

View file

@ -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<Provider>;
}

View file

@ -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<string>;
/**
* 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];