Skip to main content

systemprompt_models/bridge/
profile.rs

1//! Wire contract for `GET /v1/bridge/profile`.
2//!
3//! The desktop bridge (`bin/bridge`) fetches this to render host configuration
4//! and to decide which provider models each host advertises. The server
5//! (`crates/entry/api`) produces it and the bridge consumes it through these
6//! exact types, so the two sides cannot drift.
7//!
8//! Every field is derived in [`build`] from
9//! [`ProviderRegistry::advertised_providers`], the single bearer of the
10//! advertisement rule ([`ApiSurface::is_advertised`]). A `surface: backend`
11//! provider is therefore structurally absent from both `providers` and the
12//! flat `models` front door — the flat list is a projection of the same
13//! advertised set, so it can never disagree with `providers`.
14//!
15//! Copyright (c) systemprompt.io — Business Source License 1.1.
16//! See <https://systemprompt.io> for licensing details.
17
18use serde::{Deserialize, Serialize};
19
20use crate::services::{ApiSurface, ProviderRegistry};
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct BridgeProfileResponse {
24    pub inference_gateway_base_url: String,
25    pub auth_scheme: String,
26    #[serde(default)]
27    pub models: Vec<String>,
28    // Why: `None` leaves the choice to the client, which is the behaviour
29    // before this field existed.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub default_model: Option<String>,
32    #[serde(default)]
33    pub organization_uuid: Option<String>,
34    #[serde(default)]
35    pub providers: Vec<ProviderHealth>,
36}
37
38// Why: the bridge's `HostApp` registry and its sync-only agent table must
39// together cover exactly this list; a bridge-side test asserts it, so a host
40// added on one side without the other fails there rather than vanishing from
41// the GUI.
42pub const KNOWN_HOSTS: &[&str] = &[
43    "claude-code",
44    "claude-desktop",
45    "codex-cli",
46    "hermes",
47    "opencode",
48];
49
50/// A provider whose credential secret is absent is flagged
51/// (`configured = false`) rather than dropped silently.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ProviderHealth {
54    pub name: String,
55    pub surface: ApiSurface,
56    pub configured: bool,
57    #[serde(default)]
58    pub models: Vec<String>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub config_issue: Option<String>,
61}
62
63pub fn provider_health(
64    registry: &ProviderRegistry,
65    secret_present: impl Fn(&str) -> bool,
66) -> Vec<ProviderHealth> {
67    registry
68        .advertised_providers()
69        .map(|entry| {
70            let secret = entry.api_key_secret.as_str();
71            let configured = secret_present(secret);
72            ProviderHealth {
73                name: entry.name.as_str().to_owned(),
74                surface: entry.surface,
75                configured,
76                models: entry
77                    .models
78                    .iter()
79                    .flat_map(|m| {
80                        std::iter::once(m.id.as_str().to_owned())
81                            .chain(m.aliases.iter().map(|a| a.as_str().to_owned()))
82                    })
83                    .collect(),
84                config_issue: (!configured)
85                    .then(|| format!("API key secret '{secret}' is not configured")),
86            }
87        })
88        .collect()
89}
90
91#[derive(Debug, Clone)]
92pub struct BridgeProfileParams<'a> {
93    pub inference_gateway_base_url: String,
94    pub auth_scheme: String,
95    pub organization_uuid: Option<String>,
96    pub default_model: Option<String>,
97    pub registry: &'a ProviderRegistry,
98}
99
100#[must_use]
101pub fn build(
102    params: BridgeProfileParams<'_>,
103    secret_present: impl Fn(&str) -> bool,
104) -> BridgeProfileResponse {
105    let BridgeProfileParams {
106        inference_gateway_base_url,
107        auth_scheme,
108        organization_uuid,
109        default_model,
110        registry,
111    } = params;
112    BridgeProfileResponse {
113        inference_gateway_base_url,
114        auth_scheme,
115        models: registry.advertised_model_ids(&[ApiSurface::Anthropic]),
116        default_model,
117        organization_uuid,
118        providers: provider_health(registry, secret_present),
119    }
120}