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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 94 additions & 4 deletions crates/tui/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1020,13 +1020,23 @@ impl From<CatalogRefreshError> for ModelsFetchError {
// fields that the existing typed provider parsers reject.
#[derive(Deserialize)]
struct ModelsPage<'a> {
#[serde(borrow, alias = "models")]
data: &'a serde_json::value::RawValue,
#[serde(borrow)]
data: Option<&'a serde_json::value::RawValue>,
#[serde(borrow)]
models: Option<&'a serde_json::value::RawValue>,
#[serde(default)]
has_more: bool,
last_id: Option<String>,
}

impl<'a> ModelsPage<'a> {
fn rows(&self) -> Result<&'a serde_json::value::RawValue, CatalogRefreshError> {
self.data
.or(self.models)
.ok_or(CatalogRefreshError::InvalidResponse)
}
}

struct BoundedModelsRows(usize);

impl<'de> serde::de::Visitor<'de> for BoundedModelsRows {
Expand Down Expand Up @@ -1102,7 +1112,7 @@ where
let page: ModelsPage<'_> =
serde_json::from_str(&body).map_err(|_| CatalogRefreshError::InvalidResponse)?;
let page_rows = BoundedModelsRows(limits.rows.saturating_sub(rows.len()))
.deserialize(&mut serde_json::Deserializer::from_str(page.data.get()))
.deserialize(&mut serde_json::Deserializer::from_str(page.rows()?.get()))
.map_err(|_| CatalogRefreshError::InvalidResponse)?;
rows.extend(page_rows);
if !page.has_more {
Expand Down Expand Up @@ -1850,6 +1860,7 @@ impl CodewhaleClient {
auth_disabled,
force_http1,
config,
AuthHeaderSurface::Inference,
)?;
let http_client = if plugin_provider.is_some() || api_key_source == "Claude sign-in" {
http_client.redirect(reqwest::redirect::Policy::none())
Expand All @@ -1866,6 +1877,7 @@ impl CodewhaleClient {
auth_disabled,
force_http1,
config,
AuthHeaderSurface::Listing,
)?
.redirect(reqwest::redirect::Policy::none())
.build()?;
Expand All @@ -1881,6 +1893,7 @@ impl CodewhaleClient {
auth_disabled,
true,
config,
AuthHeaderSurface::Inference,
)?;
let http1_client = if plugin_provider.is_some() || api_key_source == "Claude sign-in" {
http1_client.redirect(reqwest::redirect::Policy::none())
Expand Down Expand Up @@ -2316,6 +2329,7 @@ impl CodewhaleClient {
false,
false,
&Config::default(),
AuthHeaderSurface::Inference,
)?
.build()
.map_err(Into::into)
Expand All @@ -2330,6 +2344,7 @@ impl CodewhaleClient {
auth_disabled: bool,
force_http1: bool,
config: &Config,
surface: AuthHeaderSurface,
) -> Result<reqwest::ClientBuilder> {
let headers = build_default_headers(
api_key,
Expand All @@ -2338,6 +2353,7 @@ impl CodewhaleClient {
base_url,
wire_format,
auth_disabled,
surface,
)?;
let mut builder = crate::tls::reqwest_client_builder()
.default_headers(headers)
Expand Down Expand Up @@ -2379,6 +2395,7 @@ impl CodewhaleClient {
crate::config::DEFAULT_DEEPSEEK_BASE_URL,
WireFormat::ChatCompletions,
false,
AuthHeaderSurface::Inference,
)
}

Expand All @@ -2396,6 +2413,7 @@ impl CodewhaleClient {
base_url,
provider_default_wire_format(api_provider),
false,
AuthHeaderSurface::Inference,
)
}

Expand All @@ -2413,17 +2431,37 @@ impl CodewhaleClient {
base_url,
provider_default_wire_format(api_provider),
true,
AuthHeaderSurface::Inference,
)
}
}

/// Which request surface a client's default auth header is built for.
///
/// Anthropic's `x-api-key` convention belongs to Anthropic's own service, not
/// to the Messages wire format by itself. A custom table speaks Messages
/// because its host does, but it is still reached through the
/// OpenAI-compatible config surface, and its listing follows the OpenAI
/// convention: a relay can answer `/v1/messages` on either header while
/// answering `/v1/models` only on `Authorization: Bearer`. Keeping the two
/// surfaces apart stops a listing from inheriting a header chosen for the
/// Messages endpoint.
#[derive(Clone, Copy, PartialEq, Eq)]
enum AuthHeaderSurface {
/// Chat, Messages and Responses requests: the wire dialect decides.
Inference,
/// The provider's own `/models` listing.
Listing,
}

fn build_default_headers(
api_key: &str,
extra_headers: &HashMap<String, String>,
api_provider: ProviderKind,
base_url: &str,
wire_format: WireFormat,
auth_disabled: bool,
surface: AuthHeaderSurface,
) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Expand All @@ -2442,10 +2480,17 @@ fn build_default_headers(
HeaderValue::from_static("2023-06-01"),
);
}
// A custom host's listing never inherits the Messages choice. Its
// `kind = "openai-compatible"` surface means Bearer, and a relay that
// accepts Messages on either header may still reject `x-api-key` on
// `/v1/models` with `401 missing_api_key`.
let listing_on_custom_host =
surface == AuthHeaderSurface::Listing && api_provider == ProviderKind::Custom;
let auth_header_name = if auth_disabled {
None
} else if !api_key.is_empty()
&& uses_anthropic_messages
&& !listing_on_custom_host
&& !matches!(
api_provider,
ProviderKind::Openmodel | ProviderKind::Codewhale
Expand Down Expand Up @@ -2713,6 +2758,7 @@ pub async fn verify_provider_api_key(
base_url,
provider_default_wire_format(provider),
false,
AuthHeaderSurface::Listing,
)
.map_err(|err| format!("failed to build auth headers: {err:#}"))?;
let client = crate::tls::reqwest_client_builder()
Expand Down Expand Up @@ -3634,7 +3680,10 @@ impl CodewhaleClient {
// at fetch time) and every other custom host. A private route is
// the only place its roster exists, and a failed probe stays
// non-fatal.
if !crate::provider_catalog_live::provider_owns_live_catalog(provider) {
if !crate::provider_catalog_live::provider_owns_live_catalog(
provider,
&config.active_route_base_url(),
) {
return;
}

Expand Down Expand Up @@ -3713,6 +3762,47 @@ impl CodewhaleClient {
}
}

/// Best-effort refresh of every configured route that owns its own
/// `/v1/models` roster, so opening `/model` finds each relay already probed
/// instead of a route still waiting for a provider switch.
///
/// [`Self::spawn_active_provider_catalog_refresh`] keeps its own faster path
/// for the active route (it also publishes that route's persisted scope
/// before the probe). This is the wider sweep behind it, for the relays a
/// session is *not* currently on — the picker lists every configured route,
/// so every one of them has to be able to answer, not just the active one.
///
/// Each route is probed through the same `update_provider_catalog` the
/// explicit `models --update` command uses, so the TTL, the typed receipt,
/// the credential-change guard and the superseded-refresh ticket are shared
/// rather than reimplemented. Failures stay non-fatal: a route that cannot
/// answer keeps its persisted and configured rows.
pub fn spawn_configured_provider_catalog_refresh(config: &Config) {
#[cfg(test)]
let _ = config;
#[cfg(not(test))]
{
let active = config.active_provider_identity().ok();
for identity in crate::provider_lake::configured_providers(config) {
if active.as_ref() == Some(&identity) {
continue;
}
let base_url = config.base_url_for_route(&identity);
if !crate::provider_catalog_live::provider_owns_live_catalog(
identity.provider,
&base_url,
) {
continue;
}
let route_config = config.clone();
tokio::spawn(async move {
let _ = crate::provider_lake::update_provider_catalog(&route_config, &identity)
.await;
});
}
}
}

/// Generate speech with Xiaomi MiMo TTS models.
///
/// The spoken text is placed in an `assistant` message because Xiaomi
Expand Down
132 changes: 132 additions & 0 deletions crates/tui/src/client/catalog_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1118,3 +1118,135 @@ async fn unpaginated_custom_identities_keep_exact_identity_and_endpoint_ownershi
}
assert_eq!(cache.entries.len(), 3);
}

/// `models` is the wider spelling some relays echo beside the OpenAI `data`
/// roster, and it is often a stub. It is not a second name for `data`: when it
/// was declared as one (`alias = "models"`), a body carrying both keys failed
/// the whole probe with `duplicate field`, so a route whose roster was present
/// and well-formed reported `invalid_response` and listed nothing. `data` is
/// the roster this envelope reads first; `models` stays for hosts that serve
/// only that spelling.
#[tokio::test]
async fn roster_envelope_carrying_data_and_models_reads_the_data_roster() {
let server = MockServer::start().await;
mount_models_json(
&server,
200,
json!({
"object": "list",
"data": [{"id": "relay-real-a"}, {"id": "relay-real-b"}],
"models": [{"id": "wider-spelling-stub"}],
"total_count": 2,
}),
)
.await;
let client = custom_mock_client_for_identity(&server, "relay-private");
let delta = client
.fetch_catalog_delta()
.await
.expect("both spellings present is a complete roster, not an invalid response");
assert_eq!(
delta
.offerings
.iter()
.map(|row| row.wire_model_id.as_str())
.collect::<Vec<_>>(),
["relay-real-a", "relay-real-b"]
);
}

/// An empty `data` roster beside a populated `models` array is an empty
/// listing, not a reason to read the other key. A present `data` is the
/// OpenAI-compatible roster; only a host that serves no `data` at all falls
/// back to the wider spelling.
#[tokio::test]
async fn roster_envelope_with_empty_data_is_an_empty_listing_not_a_models_fallback() {
let server = MockServer::start().await;
mount_models_json(
&server,
200,
json!({
"object": "list",
"data": [],
"models": [{"id": "wider-spelling-stub"}],
}),
)
.await;
let client = custom_mock_client_for_identity(&server, "relay-private");
assert_eq!(
client
.fetch_catalog_delta()
.await
.expect_err("an empty data roster is an empty listing"),
CatalogRefreshError::EmptyList
);
}

/// A custom table is reached through the OpenAI-compatible surface, so its
/// `/models` listing follows the OpenAI auth convention even when the table
/// declares `wire = "anthropic"` for Messages. A relay can answer
/// `/v1/messages` on either header and still reject `x-api-key` on
/// `/v1/models` with `401 missing_api_key`, so the listing must not inherit
/// the Messages choice.
#[tokio::test]
async fn custom_messages_wire_listing_authenticates_with_bearer() {
let _ = rustls::crypto::ring::default_provider().install_default();
let server = MockServer::start().await;
mount_models_json(
&server,
200,
json!({"object": "list", "data": [{"id": "relay-model"}]}),
)
.await;
let mut providers = ProvidersConfig::default();
providers.custom.insert(
"relay-messages-wire".to_string(),
ProviderConfig {
kind: Some("openai-compatible".to_string()),
api_key: Some(KEY.to_string()),
base_url: Some(format!("{}/v1", server.uri())),
model: Some("relay-model".to_string()),
wire: Some("anthropic".to_string()),
..ProviderConfig::default()
},
);
let client = CodewhaleClient::new(&Config {
provider: Some("relay-messages-wire".to_string()),
providers: Some(providers),
..Config::default()
})
.expect("custom Messages-wire relay client");

let delta = client
.fetch_catalog_delta()
.await
.expect("the relay's listing answers on its own terms");
assert_eq!(
delta
.offerings
.iter()
.map(|row| row.wire_model_id.as_str())
.collect::<Vec<_>>(),
["relay-model"]
);

let request = server
.received_requests()
.await
.expect("requests")
.pop()
.expect("one listing request");
let expected = format!("Bearer {KEY}");
assert_eq!(
request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok()),
Some(expected.as_str()),
"the listing follows the OpenAI auth convention"
);
assert!(
request.headers.get("x-api-key").is_none(),
"the listing must not inherit the Messages x-api-key convention"
);
}
1 change: 1 addition & 0 deletions crates/tui/src/client/system_one.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ impl CodewhaleClient {
false,
client.force_http1,
config,
AuthHeaderSurface::Inference,
)?
.build()?;
client.base_url = base_url;
Expand Down
4 changes: 4 additions & 0 deletions crates/tui/src/client/test_cases_01.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"https://openrouter.ai/api/v1",
WireFormat::ChatCompletions,
false,
AuthHeaderSurface::Inference,
)
.expect("headers");
assert_eq!(
Expand Down Expand Up @@ -78,6 +79,7 @@
"https://example.invalid/v1",
WireFormat::ChatCompletions,
false,
AuthHeaderSurface::Inference,
)
.expect("headers");
assert!(
Expand All @@ -95,6 +97,7 @@
"https://openrouter.ai/api/v1",
WireFormat::ChatCompletions,
false,
AuthHeaderSurface::Inference,
)
.expect("headers");
// A user title on the legacy header alone must follow onto the
Expand All @@ -116,6 +119,7 @@
"https://openrouter.ai/api/v1",
WireFormat::ChatCompletions,
false,
AuthHeaderSurface::Inference,
)
.expect("headers");
for name in ["x-title", "x-openrouter-title"] {
Expand Down
Loading
Loading