feat(providers): slug-keyed cloud providers + per-workload model routing (#1888)

This commit is contained in:
Steven Enamakel
2026-05-15 21:19:07 -07:00
committed by GitHub
parent 320fd6c74c
commit 86b57281dc
18 changed files with 2044 additions and 1121 deletions
+3
View File
@@ -139,6 +139,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
.extend(crate::openhuman::channels::controllers::all_channels_registered_controllers());
// Persistent configuration management
controllers.extend(crate::openhuman::config::all_config_registered_controllers());
// Cloud provider model catalog queries
controllers.extend(crate::openhuman::providers::all_providers_registered_controllers());
// Local sidecar reachability + backend Socket.IO state diagnostics (#1527)
controllers.extend(crate::openhuman::connectivity::all_connectivity_registered_controllers());
// User credentials and session management
@@ -264,6 +266,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
.extend(crate::openhuman::channels::providers::web::all_web_channel_controller_schemas());
schemas.extend(crate::openhuman::channels::controllers::all_channels_controller_schemas());
schemas.extend(crate::openhuman::config::all_config_controller_schemas());
schemas.extend(crate::openhuman::providers::all_providers_controller_schemas());
schemas.extend(crate::openhuman::connectivity::all_connectivity_controller_schemas());
schemas.extend(crate::openhuman::credentials::all_credentials_controller_schemas());
schemas.extend(crate::openhuman::service::all_service_controller_schemas());
+228 -66
View File
@@ -1,18 +1,233 @@
//! Cloud provider credential schema.
//!
//! Each entry in `Config::cloud_providers` represents one configured LLM
//! backend (OpenHuman, OpenAI, Anthropic, OpenRouter, or a custom
//! OpenAI-compatible endpoint). The factory in
//! `crate::openhuman::providers::factory` resolves workload-to-provider
//! strings against this list at runtime.
//! backend. Providers are keyed by a user-chosen `slug` (e.g. `"openai"`,
//! `"my-deepseek"`). The factory in `crate::openhuman::providers::factory`
//! resolves workload-to-provider strings against this list at runtime using
//! the grammar `"<slug>:<model>"`.
//!
//! Legacy configs that use `type`/`default_model` are migrated in-memory on
//! load via `migrate_legacy_fields()`.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Discriminator for a cloud provider entry.
/// Authentication header style for a cloud provider.
///
/// Wire format is lowercase (e.g. `"openai"`). Dictates the default endpoint
/// and the auth header style used by the chat factory.
/// Wire format is lowercase (e.g. `"bearer"`). Determines which HTTP headers
/// are attached when calling the provider's API.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum AuthStyle {
/// OpenAI-compatible: `Authorization: Bearer <key>`
#[default]
Bearer,
/// Anthropic: `x-api-key: <key>` + `anthropic-version: 2023-06-01`
Anthropic,
/// OpenHuman session JWT (injected by the backend provider, not stored here).
OpenhumanJwt,
/// No auth header — e.g. local Ollama.
None,
}
impl AuthStyle {
pub fn as_str(&self) -> &'static str {
match self {
Self::Bearer => "bearer",
Self::Anthropic => "anthropic",
Self::OpenhumanJwt => "openhuman_jwt",
Self::None => "none",
}
}
}
/// Endpoint config for one cloud LLM provider.
///
/// **Note on secrets**: API keys are NOT stored on this struct. They live in
/// `auth-profiles.json` via [`crate::openhuman::credentials::AuthService`],
/// keyed by `provider:<slug>` (falling back to bare `<slug>` for legacy
/// entries). The factory looks up the token at call time via
/// [`crate::openhuman::providers::factory::auth_key_for_slug`].
///
/// ## Back-compat
///
/// Old configs may have `type` and `default_model` fields. These are
/// tolerated on read (via `legacy_type` / `default_model`) but never written.
/// Call `migrate_legacy_fields()` after deserialising.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(default)]
pub struct CloudProviderCreds {
/// Opaque stable id, e.g. `"p_openai_a8c3f"`. Never shown in the UI.
/// Generated once by [`generate_provider_id`] and never changes.
pub id: String,
/// Routing key chosen by the user or seeded from the legacy type.
/// Lower-case alphanumeric + `-`. Must be unique per config and not in the
/// reserved list (see [`is_slug_reserved`]). The factory resolves
/// `"<slug>:<model>"` strings against this field.
pub slug: String,
/// Human-readable display label, supplied by the frontend. Not used in routing.
pub label: String,
/// OpenAI-compatible base URL (`/models`, `/chat/completions` etc. are appended).
pub endpoint: String,
/// Authentication header style.
pub auth_style: AuthStyle,
// ── Back-compat: old `type` field ───────────────────────────────────────
/// Legacy discriminator written by older builds. Read-only; never emitted.
#[serde(rename = "type", default, skip_serializing)]
pub legacy_type: Option<String>,
// ── Back-compat: old `default_model` field ──────────────────────────────
/// Legacy default model written by older builds. Read-only; never emitted.
#[serde(default, skip_serializing)]
pub default_model: Option<String>,
}
impl Default for CloudProviderCreds {
fn default() -> Self {
Self {
id: String::new(),
slug: String::new(),
label: String::new(),
endpoint: String::new(),
auth_style: AuthStyle::Bearer,
legacy_type: None,
default_model: None,
}
}
}
/// Reserved slugs that may not be used for user-configured providers.
/// These are sentinels in the factory's routing grammar.
pub fn is_slug_reserved(s: &str) -> bool {
matches!(s.trim(), "" | "cloud" | "openhuman" | "ollama" | "pid")
}
/// Apply legacy field migration in-place.
///
/// Idempotent: only fills in empty fields from the legacy `type`/`default_model`
/// values. Safe to call on already-migrated entries.
pub fn migrate_legacy_fields(entry: &mut CloudProviderCreds) {
let legacy_type = entry.legacy_type.clone().unwrap_or_default();
let lt = legacy_type.trim();
// Slug from legacy type when missing.
if entry.slug.is_empty() && !lt.is_empty() {
entry.slug = lt.to_string();
log::debug!(
"[config][cloud_providers] migrated slug from legacy type='{}' id={}",
lt,
entry.id
);
}
// Label from static map when missing.
if entry.label.is_empty() {
entry.label = legacy_label_for(if entry.slug.is_empty() {
lt
} else {
&entry.slug
})
.to_string();
log::debug!(
"[config][cloud_providers] migrated label='{}' for slug='{}' id={}",
entry.label,
entry.slug,
entry.id
);
}
// Endpoint from legacy defaults when missing.
if entry.endpoint.is_empty() {
let ep = legacy_default_endpoint(lt);
if !ep.is_empty() {
entry.endpoint = ep.to_string();
}
}
// Auth style from legacy type when still at default Bearer.
if entry.auth_style == AuthStyle::Bearer {
match lt {
"anthropic" => {
entry.auth_style = AuthStyle::Anthropic;
}
"openhuman" => {
entry.auth_style = AuthStyle::OpenhumanJwt;
}
_ => {}
}
}
}
/// Map a legacy type string (or slug) to a human-readable label.
fn legacy_label_for(type_str: &str) -> &'static str {
match type_str {
"openhuman" => "OpenHuman",
"openai" => "OpenAI",
"anthropic" => "Anthropic",
"openrouter" => "OpenRouter",
"custom" => "Custom",
_ => "Custom",
}
}
/// Map a legacy type string to its well-known default endpoint.
fn legacy_default_endpoint(type_str: &str) -> &'static str {
match type_str {
"openhuman" => "https://api.openhuman.ai/v1",
"openai" => "https://api.openai.com/v1",
"anthropic" => "https://api.anthropic.com/v1",
"openrouter" => "https://openrouter.ai/api/v1",
_ => "",
}
}
/// Generate a short opaque id for a new provider entry.
///
/// Format: `"p_<slug>_<5 random alphanumerics>"`, e.g. `"p_openai_a8c3f"`.
/// The random suffix is not cryptographically strong — it only needs to be
/// unique within a single user's config file.
pub fn generate_provider_id(slug: &str) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
// Cheap pseudo-random from timestamp nanoseconds — adequate for local
// config uniqueness without pulling in a PRNG crate.
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut suffix = String::with_capacity(5);
let mut seed = nanos as usize;
for _ in 0..5 {
suffix.push(chars[seed % chars.len()] as char);
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
seed = (seed >> 33) ^ seed;
}
// Sanitise slug to only alphanumeric + '-' for the id prefix.
let safe_slug: String = slug
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.take(20)
.collect();
format!("p_{}_{}", safe_slug, suffix)
}
// ── Back-compat type alias ──────────────────────────────────────────────────
// Kept so existing code that imports `CloudProviderType` compiles without
// sweeping changes. New code should use `AuthStyle` directly.
/// Legacy discriminator enum. **Deprecated**: use `AuthStyle` on new entries.
/// Retained only to satisfy callers that still pattern-match on
/// `CloudProviderType` (e.g. the migration module). Will be removed once all
/// call sites are updated to slug-keyed lookups.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CloudProviderType {
@@ -56,66 +271,13 @@ impl CloudProviderType {
Self::Custom => "custom",
}
}
}
/// Endpoint config for one cloud LLM provider.
///
/// **Note on secrets**: API keys are NOT stored on this struct. They live in
/// `auth-profiles.json` via [`crate::openhuman::credentials::AuthService`],
/// encrypted at rest under the workspace's `.secret_key`. The factory looks
/// up the bearer token at call time by `provider_type.as_str()` (e.g.
/// `"openai"`, `"anthropic"`), mirroring how the Composio integration stores
/// its key under `"composio-direct:default"`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(default)]
pub struct CloudProviderCreds {
/// Opaque stable id, e.g. `"p_openai_a8c3f"`. Never shown in the UI.
/// Generated once by [`generate_provider_id`] and never changes.
pub id: String,
/// Provider type determines default endpoint, the auth-profile lookup
/// key, and the human-readable label.
#[serde(rename = "type")]
pub r#type: CloudProviderType,
/// OpenAI-compatible base URL (`/v1/chat/completions` is appended).
pub endpoint: String,
/// Default model id sent to this provider when no per-workload override
/// is configured.
pub default_model: String,
}
impl Default for CloudProviderCreds {
fn default() -> Self {
Self {
id: String::new(),
r#type: CloudProviderType::Openhuman,
endpoint: CloudProviderType::Openhuman.default_endpoint().to_string(),
default_model: "reasoning-v1".to_string(),
/// Corresponding `AuthStyle`.
pub fn auth_style(&self) -> AuthStyle {
match self {
Self::Openhuman => AuthStyle::OpenhumanJwt,
Self::Anthropic => AuthStyle::Anthropic,
_ => AuthStyle::Bearer,
}
}
}
/// Generate a short opaque id for a new provider entry.
///
/// Format: `"p_<type>_<5 random alphanumerics>"`, e.g. `"p_openai_a8c3f"`.
/// The random suffix is not cryptographically strong — it only needs to be
/// unique within a single user's config file.
pub fn generate_provider_id(t: &CloudProviderType) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
// Cheap pseudo-random from timestamp nanoseconds — adequate for local
// config uniqueness without pulling in a PRNG crate.
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut suffix = String::with_capacity(5);
let mut seed = nanos as usize;
for _ in 0..5 {
suffix.push(chars[seed % chars.len()] as char);
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
seed = (seed >> 33) ^ seed;
}
format!("p_{}_{}", t.as_str(), suffix)
}
+98
View File
@@ -586,6 +586,103 @@ pub(super) fn redact_url_for_log(raw: &str) -> String {
"<unparseable url>".to_string()
}
/// Migrate `cloud_providers` entries to the new slug-keyed shape and rewrite
/// any per-workload routing strings that still use the old bare-prefix grammar.
///
/// This is idempotent: entries that already have a slug/label are left
/// untouched. Routing fields that already contain a `:` are assumed to be
/// in the new `<slug>:<model>` form.
fn migrate_cloud_provider_slugs(config: &mut Config) {
use super::cloud_providers::migrate_legacy_fields;
// Step 1: migrate every cloud_providers entry in-place.
for entry in &mut config.cloud_providers {
migrate_legacy_fields(entry);
}
// Step 2: rewrite per-workload routing strings from legacy bare grammar.
// Build a lookup: legacy type string → first entry with that slug.
// After migration, `entry.slug` is populated from `legacy_type` when it
// was empty, so we can look up by slug now.
let slug_to_id: std::collections::HashMap<String, String> = config
.cloud_providers
.iter()
.map(|e| (e.slug.clone(), e.id.clone()))
.collect();
// Helper: rewrite a single routing field.
// Legacy bare strings are: "cloud", "openhuman", "openai", "anthropic",
// "openrouter", "custom" (no ':'). New strings contain ':'.
let rewrite = |field: &mut Option<String>| {
let raw = match field.as_deref() {
Some(s) if !s.is_empty() => s.to_string(),
_ => return,
};
// Already in new grammar (contains ':') or is the openhuman sentinel.
if raw.contains(':') || raw == "openhuman" {
return;
}
match raw.as_str() {
"cloud" => {
// "cloud" sentinel: look for the primary or first non-openhuman entry.
// If none found, leave as "openhuman".
let primary_slug = config.primary_cloud.as_deref().and_then(|pid| {
config
.cloud_providers
.iter()
.find(|e| e.id == pid)
.map(|e| e.slug.clone())
});
let slug = primary_slug.or_else(|| {
config
.cloud_providers
.iter()
.find(|e| e.slug != "openhuman")
.map(|e| e.slug.clone())
});
if let Some(s) = slug {
tracing::info!(
"[config][migrate] rewriting routing 'cloud' → '{s}:' (empty model)"
);
*field = Some(format!("{s}:"));
} else {
tracing::debug!(
"[config][migrate] routing 'cloud' with no non-openhuman provider → 'openhuman'"
);
*field = Some("openhuman".to_string());
}
}
other => {
// Bare type string (e.g. "openai") — find entry by slug.
if slug_to_id.contains_key(other) {
tracing::info!(
"[config][migrate] rewriting bare routing '{}' → '{}:'",
other,
other
);
*field = Some(format!("{other}:"));
} else if other != "openhuman" {
tracing::warn!(
"[config][migrate] bare routing '{}' has no matching provider entry, \
falling back to 'openhuman'",
other
);
*field = Some("openhuman".to_string());
}
}
}
};
rewrite(&mut config.reasoning_provider);
rewrite(&mut config.agentic_provider);
rewrite(&mut config.coding_provider);
rewrite(&mut config.memory_provider);
rewrite(&mut config.embeddings_provider);
rewrite(&mut config.heartbeat_provider);
rewrite(&mut config.learning_provider);
rewrite(&mut config.subconscious_provider);
}
fn migrate_legacy_autocomplete_disabled_apps(config: &mut Config) {
// Legacy defaults blocked both terminal and code, which prevented Codex/CLI usage.
// Migrate only the exact legacy default so custom user preferences remain untouched.
@@ -705,6 +802,7 @@ impl Config {
config.workspace_dir = workspace_dir;
migrate_legacy_autocomplete_disabled_apps(&mut config);
migrate_legacy_inference_url(&mut config);
migrate_cloud_provider_slugs(&mut config);
config.apply_env_overrides();
if config_was_corrupted {
+4 -1
View File
@@ -3,7 +3,10 @@
//! Split into submodules; this module re-exports the main `Config` and all public types.
pub mod cloud_providers;
pub use cloud_providers::{generate_provider_id, CloudProviderCreds, CloudProviderType};
pub use cloud_providers::{
generate_provider_id, is_slug_reserved, migrate_legacy_fields, AuthStyle, CloudProviderCreds,
CloudProviderType,
};
mod accessibility;
mod agent;
mod autocomplete;
+61 -23
View File
@@ -19,9 +19,20 @@ struct ModelRouteUpdate {
struct CloudProviderUpdate {
/// Opaque stable id. Empty / missing → server generates a new id.
id: Option<String>,
/// "openhuman" | "openai" | "anthropic" | "openrouter" | "custom"
r#type: String,
/// Routing slug, e.g. "openai", "my-deepseek". Must be unique per config.
slug: String,
/// Human-readable label.
#[serde(default)]
label: Option<String>,
endpoint: String,
/// Auth style: "bearer" | "anthropic" | "openhuman_jwt" | "none".
#[serde(default)]
auth_style: Option<String>,
/// Legacy field — tolerated on read for back-compat but not required.
#[serde(rename = "type", default)]
legacy_type: Option<String>,
/// Legacy field — tolerated on read.
#[serde(default)]
default_model: Option<String>,
}
@@ -407,7 +418,7 @@ pub fn schemas(function: &str) -> ControllerSchema {
FieldSchema {
name: "cloud_providers",
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
comment: "Optional list of cloud provider entries {id, type, endpoint, default_model}. API keys are stored separately via cloud_provider_set_key. Replaces config.cloud_providers wholesale.",
comment: "Optional list of cloud provider entries {id, slug, label, endpoint, auth_style}. API keys are stored separately via cloud_provider_set_key. Replaces config.cloud_providers wholesale.",
required: false,
},
optional_string("primary_cloud", "id of the cloud_providers entry used when a workload routes to 'cloud'. Empty string clears."),
@@ -891,9 +902,10 @@ fn handle_get_client_config(_params: Map<String, Value>) -> ControllerFuture {
.map(|c| {
serde_json::json!({
"id": c.id,
"type": c.r#type.as_str(),
"slug": c.slug,
"label": c.label,
"endpoint": c.endpoint,
"default_model": c.default_model,
"auth_style": c.auth_style.as_str(),
})
})
.collect();
@@ -951,36 +963,62 @@ fn handle_update_model_settings(params: Map<String, Value>) -> ControllerFuture
.cloud_providers
.map(|entries| {
use crate::openhuman::config::schema::cloud_providers::{
generate_provider_id, CloudProviderCreds, CloudProviderType,
generate_provider_id, is_slug_reserved, migrate_legacy_fields, AuthStyle,
CloudProviderCreds,
};
entries
.into_iter()
.map(|e| {
let r#type = match e.r#type.to_ascii_lowercase().as_str() {
"openhuman" => CloudProviderType::Openhuman,
"openai" => CloudProviderType::Openai,
"anthropic" => CloudProviderType::Anthropic,
"openrouter" => CloudProviderType::Openrouter,
"custom" => CloudProviderType::Custom,
let slug = e.slug.trim().to_string();
if slug.is_empty() {
return Err(
"cloud provider slug must not be empty".to_string()
);
}
if is_slug_reserved(&slug) {
return Err(format!(
"slug '{}' is reserved and cannot be used for a custom provider",
slug
));
}
let auth_style = match e
.auth_style
.as_deref()
.unwrap_or("bearer")
.to_ascii_lowercase()
.as_str()
{
"bearer" => AuthStyle::Bearer,
"anthropic" => AuthStyle::Anthropic,
"openhuman_jwt" | "openhumanjwt" => AuthStyle::OpenhumanJwt,
"none" => AuthStyle::None,
other => {
return Err(format!(
"unknown cloud provider type '{}'; \
valid values: openhuman, openai, anthropic, \
openrouter, custom",
"unknown auth_style '{}'; valid: bearer, anthropic, openhuman_jwt, none",
other
))
}
};
let id =
e.id.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| generate_provider_id(&r#type));
let default_model = e.default_model.unwrap_or_default();
Ok(CloudProviderCreds {
let id = e
.id
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| generate_provider_id(&slug));
let label = e
.label
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| slug.clone());
let mut entry = CloudProviderCreds {
id,
r#type,
slug,
label,
endpoint: e.endpoint,
default_model,
})
auth_style,
legacy_type: e.legacy_type,
default_model: e.default_model,
};
// Apply any remaining legacy-field migration.
migrate_legacy_fields(&mut entry);
Ok(entry)
})
.collect::<Result<Vec<_>, String>>()
})
@@ -40,7 +40,7 @@
//! URL doesn't look like the OpenHuman backend.
use crate::openhuman::config::schema::cloud_providers::{
generate_provider_id, CloudProviderCreds, CloudProviderType,
generate_provider_id, AuthStyle, CloudProviderCreds, CloudProviderType,
};
use crate::openhuman::config::Config;
@@ -98,10 +98,13 @@ fn seed_cloud_providers(config: &mut Config, stats: &mut MigrationStats) {
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "reasoning-v1".to_string());
config.cloud_providers.push(CloudProviderCreds {
id: generate_provider_id(&CloudProviderType::Openhuman),
r#type: CloudProviderType::Openhuman,
id: generate_provider_id("openhuman"),
slug: "openhuman".to_string(),
label: "OpenHuman".to_string(),
endpoint: oh_endpoint,
default_model: oh_default_model,
auth_style: AuthStyle::OpenhumanJwt,
default_model: Some(oh_default_model),
..Default::default()
});
stats.cloud_providers_seeded += 1;
@@ -121,12 +124,15 @@ fn seed_cloud_providers(config: &mut Config, stats: &mut MigrationStats) {
.find(|r| r.hint.eq_ignore_ascii_case("reasoning"))
.or_else(|| config.model_routes.first())
.map(|r| r.model.clone())
.unwrap_or_default();
.filter(|m| !m.is_empty());
config.cloud_providers.push(CloudProviderCreds {
id: generate_provider_id(&CloudProviderType::Custom),
r#type: CloudProviderType::Custom,
id: generate_provider_id("custom"),
slug: "custom".to_string(),
label: "Custom".to_string(),
endpoint: trimmed.to_string(),
auth_style: AuthStyle::Bearer,
default_model,
..Default::default()
});
stats.cloud_providers_seeded += 1;
log::info!(
@@ -146,7 +152,7 @@ fn set_primary_cloud(config: &mut Config, stats: &mut MigrationStats) {
let oh = config
.cloud_providers
.iter()
.find(|e| e.r#type == CloudProviderType::Openhuman);
.find(|e| e.slug == "openhuman" || e.legacy_type.as_deref() == Some("openhuman"));
if let Some(entry) = oh {
config.primary_cloud = Some(entry.id.clone());
stats.primary_cloud_set = true;
@@ -1,7 +1,6 @@
//! Tests for the 1 → 2 AI-provider unification migration.
use super::*;
use crate::openhuman::config::schema::cloud_providers::CloudProviderType;
use crate::openhuman::config::schema::{LocalAiConfig, LocalAiUsage};
use crate::openhuman::config::Config;
@@ -30,7 +29,7 @@ fn empty_config_seeds_openhuman_entry() {
assert_eq!(stats.cloud_providers_seeded, 1);
assert_eq!(c.cloud_providers.len(), 1);
assert_eq!(c.cloud_providers[0].r#type, CloudProviderType::Openhuman);
assert_eq!(c.cloud_providers[0].slug, "openhuman");
assert!(c.cloud_providers[0].id.starts_with("p_openhuman_"));
}
@@ -59,10 +58,10 @@ fn legacy_inference_url_becomes_custom_entry() {
let custom = c
.cloud_providers
.iter()
.find(|e| e.r#type == CloudProviderType::Custom)
.find(|e| e.slug == "custom")
.expect("custom entry must be seeded");
assert_eq!(custom.endpoint, "https://api.example.com/v1");
assert_eq!(custom.default_model, "gpt-4o");
assert_eq!(custom.default_model.as_deref(), Some("gpt-4o"));
}
#[test]
@@ -72,7 +71,7 @@ fn openhuman_inference_url_does_not_seed_custom() {
let _ = run(&mut c).expect("migration must succeed");
// Only the openhuman entry should be seeded — no Custom entry.
assert_eq!(c.cloud_providers.len(), 1);
assert_eq!(c.cloud_providers[0].r#type, CloudProviderType::Openhuman);
assert_eq!(c.cloud_providers[0].slug, "openhuman");
}
#[test]
@@ -128,7 +127,7 @@ fn chat_workload_providers_left_unset() {
let mut c = make_legacy_config_local_on();
let _ = run(&mut c).unwrap();
// Reasoning/agentic/coding have no legacy equivalent — they stay None
// and the factory defaults them to "cloud" at runtime.
// and the factory defaults them to "openhuman" at runtime.
assert_eq!(c.reasoning_provider, None);
assert_eq!(c.agentic_provider, None);
assert_eq!(c.coding_provider, None);
+8
View File
@@ -77,6 +77,8 @@ pub enum AuthStyle {
Bearer,
/// `x-api-key: <key>` (used by some Chinese providers)
XApiKey,
/// Anthropic-specific: `x-api-key: <key>` + `anthropic-version: 2023-06-01`
Anthropic,
/// Custom header name
Custom(String),
}
@@ -362,6 +364,9 @@ impl OpenAiCompatibleProvider {
req.header("Authorization", format!("Bearer {credential}"))
}
(AuthStyle::XApiKey, Some(credential)) => req.header("x-api-key", credential),
(AuthStyle::Anthropic, Some(credential)) => req
.header("x-api-key", credential)
.header("anthropic-version", "2023-06-01"),
(AuthStyle::Custom(header), Some(credential)) => req.header(header, credential),
}
}
@@ -1692,6 +1697,9 @@ impl Provider for OpenAiCompatibleProvider {
(AuthStyle::XApiKey, Some(credential)) => {
req_builder.header("x-api-key", credential)
}
(AuthStyle::Anthropic, Some(credential)) => req_builder
.header("x-api-key", credential)
.header("anthropic-version", "2023-06-01"),
(AuthStyle::Custom(header), Some(credential)) => {
req_builder.header(header, credential)
}
+202 -293
View File
@@ -7,45 +7,42 @@
//! ## Provider-string grammar
//!
//! ```text
//! "cloud" → resolves to primary_cloud entry; if that entry has
//! type=openhuman, behaves as "openhuman"
//! "openhuman" → OpenHumanBackendProvider; model = config.default_model
//! "openai:<model>" → cloud_providers entry of type=openai + Bearer auth
//! "anthropic:<model>" → cloud_providers entry of type=anthropic + Bearer auth
//! "openrouter:<model>" → cloud_providers entry of type=openrouter + Bearer auth
//! "custom:<model>" → cloud_providers entry of type=custom + Bearer auth
//! "ollama:<model>" → local Ollama at config.local_ai.base_url
//! "openhuman" → OpenHumanBackendProvider; model = config.default_model
//! "ollama:<model>" → local Ollama at config.local_ai.base_url
//! "<slug>:<model>" → cloud_providers entry keyed by slug;
//! builds OpenAiCompatibleProvider (Bearer) or Anthropic
//! flavour depending on auth_style.
//! "" / missing → falls back to "openhuman"
//! ```
//!
//! Unknown strings and missing-creds configurations produce actionable errors.
//! Unknown slugs and missing-creds configurations produce actionable errors.
use crate::openhuman::config::schema::cloud_providers::CloudProviderType;
use crate::openhuman::config::schema::cloud_providers::AuthStyle;
use crate::openhuman::config::Config;
use crate::openhuman::credentials::AuthService;
use crate::openhuman::providers::compatible::{AuthStyle, OpenAiCompatibleProvider};
use crate::openhuman::providers::compatible::{
AuthStyle as CompatAuthStyle, OpenAiCompatibleProvider,
};
use crate::openhuman::providers::openhuman_backend::OpenHumanBackendProvider;
use crate::openhuman::providers::traits::Provider;
use crate::openhuman::providers::ProviderRuntimeOptions;
/// Sentinel meaning "use whatever primary_cloud resolves to".
pub const PROVIDER_CLOUD: &str = "cloud";
/// Sentinel meaning "use the OpenHuman backend session JWT".
pub const PROVIDER_OPENHUMAN: &str = "openhuman";
/// Prefix for Ollama-local providers: `"ollama:<model>"`.
pub const OLLAMA_PROVIDER_PREFIX: &str = "ollama:";
/// Prefix for OpenAI-compatible providers: `"openai:<model>"`.
pub const OPENAI_PROVIDER_PREFIX: &str = "openai:";
/// Prefix for Anthropic-compatible providers: `"anthropic:<model>"`.
pub const ANTHROPIC_PROVIDER_PREFIX: &str = "anthropic:";
/// Prefix for OpenRouter providers: `"openrouter:<model>"`.
pub const OPENROUTER_PROVIDER_PREFIX: &str = "openrouter:";
/// Prefix for custom OpenAI-compatible providers: `"custom:<model>"`.
pub const CUSTOM_PROVIDER_PREFIX: &str = "custom:";
/// Auth-profile storage key for a slug-keyed provider.
///
/// New writes use `"provider:<slug>"`. Lookups also try the bare `<slug>`
/// as a legacy fallback (old configs stored keys as e.g. `"openai:default"`).
pub fn auth_key_for_slug(slug: &str) -> String {
format!("provider:{slug}")
}
/// Return the configured provider string for a named workload role.
///
/// Returns `"cloud"` when the workload has no explicit override, which causes
/// the factory to resolve via `primary_cloud`.
/// Returns `"openhuman"` when the workload has no explicit override.
pub fn provider_for_role(role: &str, config: &Config) -> String {
let opt = match role {
"reasoning" => config.reasoning_provider.as_deref(),
@@ -64,20 +61,14 @@ pub fn provider_for_role(role: &str, config: &Config) -> String {
_ => None,
};
let s = opt.unwrap_or("").trim();
if s.is_empty() {
PROVIDER_CLOUD.to_string()
if s.is_empty() || s == "cloud" {
PROVIDER_OPENHUMAN.to_string()
} else {
s.to_string()
}
}
/// Build a `(Provider, model)` for the given workload role.
///
/// Equivalent to:
/// ```rust,ignore
/// let s = provider_for_role(role, config);
/// create_chat_provider_from_string(role, &s, config)
/// ```
pub fn create_chat_provider(
role: &str,
config: &Config,
@@ -106,8 +97,9 @@ pub fn create_chat_provider_from_string(
p
);
if p == PROVIDER_CLOUD || p.is_empty() {
return resolve_cloud_primary(role, config);
// Empty / legacy "cloud" sentinel → OpenHuman backend.
if p.is_empty() || p == "cloud" {
return make_openhuman_backend(config);
}
if p == PROVIDER_OPENHUMAN {
@@ -126,106 +118,42 @@ pub fn create_chat_provider_from_string(
return make_ollama_provider(model.trim(), config);
}
// Third-party cloud providers — look up the matching entry by type.
let (type_str, model) = parse_typed_prefix(p).ok_or_else(|| {
anyhow::anyhow!(
"[chat-factory] unrecognised provider string '{}' for role '{}'. \
Valid forms: cloud, openhuman, ollama:<m>, openai:<m>, \
anthropic:<m>, openrouter:<m>, custom:<m>",
p,
role
)
})?;
// New grammar: "<slug>:<model>"
if let Some(colon_pos) = p.find(':') {
let slug = p[..colon_pos].trim();
let model = p[colon_pos + 1..].trim();
if model.trim().is_empty() {
anyhow::bail!(
"[chat-factory] provider string '{}' for role '{}' has an empty model",
p,
role
);
if slug.is_empty() {
anyhow::bail!(
"[chat-factory] provider string '{}' for role '{}' has an empty slug",
p,
role
);
}
return make_cloud_provider_by_slug(role, slug, model, config);
}
let provider_type = match type_str {
"openai" => CloudProviderType::Openai,
"anthropic" => CloudProviderType::Anthropic,
"openrouter" => CloudProviderType::Openrouter,
"custom" => CloudProviderType::Custom,
other => anyhow::bail!(
"[chat-factory] unknown provider type '{}' in provider string '{}' for role '{}'",
other,
p,
role
),
};
make_cloud_provider_by_type(role, &provider_type, model.trim(), config)
}
// ── Internal helpers ──────────────────────────────────────────────────────────
/// Resolve the `"cloud"` sentinel by consulting `primary_cloud`.
fn resolve_cloud_primary(
role: &str,
config: &Config,
) -> anyhow::Result<(Box<dyn Provider>, String)> {
// Find the primary entry (or fall back to an OpenHuman entry / first entry).
let entry = if let Some(ref primary_id) = config.primary_cloud {
config.cloud_providers.iter().find(|e| &e.id == primary_id)
} else {
None
};
let entry = entry.or_else(|| {
// Implicit fallback: first openhuman entry, then any entry.
// No colon: might be a bare legacy type string (e.g. "openai"). Try as
// slug lookup with empty model — gives a clear "no entry" error rather
// than an opaque parse failure.
anyhow::bail!(
"[chat-factory] unrecognised provider string '{}' for role '{}'. \
Valid forms: openhuman, ollama:<model>, <slug>:<model>. \
Configured slugs: [{}]",
p,
role,
config
.cloud_providers
.iter()
.find(|e| e.r#type == CloudProviderType::Openhuman)
.or_else(|| config.cloud_providers.first())
});
match entry {
None => {
// No cloud_providers configured at all — route to the OpenHuman backend.
log::debug!(
"[providers][chat-factory] no cloud_providers entries, \
falling back to openhuman backend for role={}",
role
);
make_openhuman_backend(config)
}
Some(e) if e.r#type == CloudProviderType::Openhuman => {
log::debug!(
"[providers][chat-factory] primary resolves to openhuman backend for role={}",
role
);
make_openhuman_backend(config)
}
Some(e) => {
let model = e.default_model.clone();
if model.trim().is_empty() {
anyhow::bail!(
"[chat-factory] primary cloud provider '{}' (type={}) has an empty \
default_model — set a model for role '{}'",
e.id,
e.r#type.label(),
role
);
}
log::info!(
"[providers][chat-factory] role={} resolved cloud→{}:{} endpoint_host={}",
role,
e.r#type.label(),
model,
redact_endpoint(&e.endpoint)
);
let key = lookup_provider_key(&e.r#type, config)?;
let p = make_openai_compatible_provider(&e.endpoint, &key)?;
Ok((p, model))
}
}
.map(|e| e.slug.as_str())
.collect::<Vec<_>>()
.join(", ")
)
}
// ── Internal helpers ──────────────────────────────────────────────────────────
/// Build the OpenHuman backend provider (session-JWT auth).
fn make_openhuman_backend(config: &Config) -> anyhow::Result<(Box<dyn Provider>, String)> {
let model = config
@@ -253,19 +181,7 @@ fn make_openhuman_backend(config: &Config) -> anyhow::Result<(Box<dyn Provider>,
options.secrets_encrypt
);
// Translate `hint:<tier>` model strings into the OpenHuman backend's
// canonical tier names. The legacy `create_intelligent_routing_provider`
// path did this inside `IntelligentRoutingProvider::resolve_remote_model`;
// #1710's factory bypassed that wrapper, which broke the web-chat
// `model_override` contract (json_rpc_e2e `routing_cases`):
// `hint:reasoning` was reaching the backend verbatim instead of
// `reasoning-v1`. We apply ONLY the model-name mapping here — not the
// full routing wrapper, which also injects local-AI health probing and
// a streaming shim that the web-chat SSE path doesn't tolerate (it
// hangs `chat_done`). Mapping mirrors `resolve_remote_model`'s
// heavy-tier arm exactly: known heavy tiers map to `<tier>-v1`;
// lightweight hints (`hint:reaction`, …) and already-exact tier names
// pass through untouched. Third-party cloud providers never see
// `hint:` strings, so this stays scoped to the OpenHuman backend.
// canonical tier names.
let model = match model.strip_prefix("hint:") {
Some("reasoning") => crate::openhuman::config::MODEL_REASONING_V1.to_string(),
Some("chat") => crate::openhuman::config::MODEL_REASONING_QUICK_V1.to_string(),
@@ -297,85 +213,124 @@ fn make_ollama_provider(
model,
redact_endpoint(&endpoint)
);
let p = make_openai_compatible_provider(&endpoint, "")?;
let p = make_openai_compatible_provider(&endpoint, "", CompatAuthStyle::Bearer)?;
Ok((p, model.to_string()))
}
/// Look up a cloud_providers entry by type and build the provider.
fn make_cloud_provider_by_type(
/// Look up a `cloud_providers` entry by slug and build the provider.
fn make_cloud_provider_by_slug(
role: &str,
provider_type: &CloudProviderType,
slug: &str,
model: &str,
config: &Config,
) -> anyhow::Result<(Box<dyn Provider>, String)> {
let entry = config
.cloud_providers
.iter()
.find(|e| &e.r#type == provider_type);
let entry = config.cloud_providers.iter().find(|e| e.slug == slug);
let entry = entry.ok_or_else(|| {
let known: Vec<&str> = config
.cloud_providers
.iter()
.map(|e| e.slug.as_str())
.collect();
anyhow::anyhow!(
"[chat-factory] no cloud provider configured for type '{}' (role '{}') — \
add a {} entry to cloud_providers in config.toml",
provider_type.as_str(),
"[chat-factory] no cloud provider configured for slug '{}' (role '{}') — \
add an entry with that slug to cloud_providers in config.toml. \
Configured slugs: [{}]",
slug,
role,
provider_type.label()
known.join(", ")
)
})?;
// Resolve effective model: use provided model if non-empty, else fall back
// to the entry's legacy default_model (if any), else empty → error.
let effective_model = if model.trim().is_empty() {
entry.default_model.clone().unwrap_or_default()
} else {
model.to_string()
};
log::info!(
"[providers][chat-factory] role={} type={} model={} endpoint_host={}",
"[providers][chat-factory] role={} slug={} model={} endpoint_host={}",
role,
provider_type.label(),
model,
slug,
effective_model,
redact_endpoint(&entry.endpoint)
);
let key = lookup_provider_key(provider_type, config)?;
let p = make_openai_compatible_provider(&entry.endpoint, &key)?;
Ok((p, model.to_string()))
let key = lookup_key_for_slug(slug, config)?;
match entry.auth_style {
AuthStyle::Anthropic => {
let p =
make_openai_compatible_provider(&entry.endpoint, &key, CompatAuthStyle::Anthropic)?;
Ok((p, effective_model))
}
AuthStyle::OpenhumanJwt => {
// Route to the OpenHuman backend — ignore the entry's endpoint
// and model; use the backend provider with the configured default.
log::debug!(
"[providers][chat-factory] slug='{}' has auth_style=OpenhumanJwt → routing to openhuman backend",
slug
);
make_openhuman_backend(config)
}
AuthStyle::None => {
let p = make_openai_compatible_provider(&entry.endpoint, "", CompatAuthStyle::Bearer)?;
Ok((p, effective_model))
}
AuthStyle::Bearer => {
let p =
make_openai_compatible_provider(&entry.endpoint, &key, CompatAuthStyle::Bearer)?;
Ok((p, effective_model))
}
}
}
/// Fetch the encrypted bearer token for a cloud provider type from the
/// workspace `auth-profiles.json` via the shared [`AuthService`].
/// Fetch the bearer token for a slug from the workspace `auth-profiles.json`.
///
/// Each provider type maps to a default profile named `"<type>:default"`
/// (e.g. `"openai:default"`), mirroring how the Composio integration stores
/// `"composio-direct:default"`. Missing or empty keys return `Ok(String::new())`
/// — callers (and `make_openai_compatible_provider`) treat that as "no auth",
/// which surfaces an authentication error at first call rather than at factory
/// build time. This keeps the factory testable without forcing every test to
/// seed an auth profile.
fn lookup_provider_key(
provider_type: &CloudProviderType,
config: &Config,
) -> anyhow::Result<String> {
// OpenHuman uses the session JWT path; no separate key here.
if matches!(provider_type, CloudProviderType::Openhuman) {
return Ok(String::new());
}
/// Tries `provider:<slug>` first (new key format), then the bare `<slug>`
/// (legacy format where keys were stored as `"openai"`, `"anthropic"`, etc.).
/// Missing or empty keys return `Ok(String::new())` — callers treat that as
/// "no auth", which surfaces an authentication error at first call rather than
/// at factory build time.
pub fn lookup_key_for_slug(slug: &str, config: &Config) -> anyhow::Result<String> {
let auth = AuthService::from_config(config);
// Try new-style key first.
let new_key = auth_key_for_slug(slug);
if let Ok(Some(k)) = auth.get_provider_bearer_token(&new_key, None) {
if !k.is_empty() {
log::debug!(
"[providers][chat-factory] auth lookup slug={} key_present=true (new-style)",
slug
);
return Ok(k);
}
}
// Fall back to legacy bare slug.
let key = auth
.get_provider_bearer_token(provider_type.as_str(), None)
.get_provider_bearer_token(slug, None)
.map_err(|e| {
anyhow::anyhow!(
"[chat-factory] failed to read API key for provider '{}': {}",
provider_type.as_str(),
"[chat-factory] failed to read API key for slug '{}': {}",
slug,
e
)
})?
.unwrap_or_default();
log::debug!(
"[providers][chat-factory] auth lookup type={} key_present={}",
provider_type.as_str(),
"[providers][chat-factory] auth lookup slug={} key_present={}",
slug,
!key.is_empty()
);
Ok(key)
}
/// Build an `OpenAiCompatibleProvider` with Bearer auth.
/// Build an `OpenAiCompatibleProvider` with the given auth style.
fn make_openai_compatible_provider(
endpoint: &str,
api_key: &str,
auth_style: CompatAuthStyle,
) -> anyhow::Result<Box<dyn Provider>> {
let key = if api_key.trim().is_empty() {
None
@@ -383,95 +338,69 @@ fn make_openai_compatible_provider(
Some(api_key)
};
Ok(Box::new(OpenAiCompatibleProvider::new(
"cloud",
endpoint,
key,
AuthStyle::Bearer,
"cloud", endpoint, key, auth_style,
)))
}
/// Return a safe-to-log representation of a URL endpoint: `scheme://host` only.
///
/// User-configured endpoints can embed API keys or tokens in the query string
/// or even in the authority (e.g. `https://key@host/`). Logging the raw URL
/// violates the "never log secrets" rule. This helper strips everything except
/// the scheme and host so logs are still useful for debugging routing issues
/// without leaking credentials.
fn redact_endpoint(url: &str) -> String {
let trimmed = url.trim();
// Try to extract scheme://host by splitting on "://" and then on "/", "@", "?".
if let Some(rest) = trimmed.split_once("://") {
let scheme = rest.0;
// Strip any userinfo (user:pass@host) and take only up to the first path/query.
let authority = rest.1.split('/').next().unwrap_or("");
let host = authority.split('@').last().unwrap_or(authority);
let host_no_query = host.split('?').next().unwrap_or(host);
return format!("{}://{}", scheme, host_no_query);
}
// No "://" — probably a bare host or unrecognised; log a placeholder.
"<endpoint>".to_string()
}
/// Split `"openai:gpt-4o"` → `("openai", "gpt-4o")`, or `None` if unrecognised.
fn parse_typed_prefix(s: &str) -> Option<(&str, &str)> {
for prefix in &[
OPENAI_PROVIDER_PREFIX,
ANTHROPIC_PROVIDER_PREFIX,
OPENROUTER_PROVIDER_PREFIX,
CUSTOM_PROVIDER_PREFIX,
] {
if let Some(model) = s.strip_prefix(prefix) {
let type_str = prefix.trim_end_matches(':');
return Some((type_str, model));
}
}
None
}
// ── Unit tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::config::schema::cloud_providers::{
CloudProviderCreds, CloudProviderType,
};
use crate::openhuman::config::schema::cloud_providers::{AuthStyle, CloudProviderCreds};
use crate::openhuman::config::Config;
fn config_with_providers(
providers: Vec<CloudProviderCreds>,
primary: Option<String>,
) -> Config {
fn config_with_providers(providers: Vec<CloudProviderCreds>) -> Config {
let mut c = Config::default();
c.cloud_providers = providers;
c.primary_cloud = primary;
c
}
fn oh_entry(id: &str) -> CloudProviderCreds {
CloudProviderCreds {
id: id.to_string(),
r#type: CloudProviderType::Openhuman,
slug: "openhuman".to_string(),
label: "OpenHuman".to_string(),
endpoint: "https://api.openhuman.ai/v1".to_string(),
default_model: "reasoning-v1".to_string(),
auth_style: AuthStyle::OpenhumanJwt,
..Default::default()
}
}
fn openai_entry(id: &str, model: &str) -> CloudProviderCreds {
fn openai_entry(id: &str, slug: &str) -> CloudProviderCreds {
CloudProviderCreds {
id: id.to_string(),
r#type: CloudProviderType::Openai,
slug: slug.to_string(),
label: "OpenAI".to_string(),
endpoint: "https://api.openai.com/v1".to_string(),
default_model: model.to_string(),
auth_style: AuthStyle::Bearer,
default_model: Some("gpt-4o".to_string()),
..Default::default()
}
}
fn anthropic_entry(id: &str, model: &str) -> CloudProviderCreds {
fn anthropic_entry(id: &str, slug: &str) -> CloudProviderCreds {
CloudProviderCreds {
id: id.to_string(),
r#type: CloudProviderType::Anthropic,
slug: slug.to_string(),
label: "Anthropic".to_string(),
endpoint: "https://api.anthropic.com/v1".to_string(),
default_model: model.to_string(),
auth_style: AuthStyle::Anthropic,
default_model: Some("claude-sonnet-4-6".to_string()),
..Default::default()
}
}
@@ -488,6 +417,7 @@ mod tests {
#[test]
fn cloud_no_providers_falls_back_to_openhuman() {
let config = Config::default();
// "cloud" sentinel still works — routes to openhuman.
let result = create_chat_provider_from_string("reasoning", "cloud", &config);
assert!(
result.is_ok(),
@@ -497,36 +427,24 @@ mod tests {
}
#[test]
fn cloud_with_openhuman_primary() {
let config = config_with_providers(vec![oh_entry("p_oh")], Some("p_oh".to_string()));
let (_, model) = create_chat_provider_from_string("reasoning", "cloud", &config)
.expect("cloud→openhuman primary must build");
fn openhuman_slug_routes_to_backend() {
let config = config_with_providers(vec![oh_entry("p_oh")]);
let (_, model) = create_chat_provider_from_string("reasoning", "openhuman:", &config)
.expect("openhuman: must build");
assert!(!model.is_empty());
}
#[test]
fn cloud_with_openai_primary() {
let config = config_with_providers(
vec![oh_entry("p_oh"), openai_entry("p_oai", "gpt-4o")],
Some("p_oai".to_string()),
);
let (_, model) = create_chat_provider_from_string("reasoning", "cloud", &config)
.expect("cloud→openai primary must build");
assert_eq!(model, "gpt-4o");
}
#[test]
fn openai_prefix() {
let config = config_with_providers(vec![openai_entry("p_oai", "gpt-4o")], None);
fn openai_slug_model() {
let config = config_with_providers(vec![openai_entry("p_oai", "openai")]);
let (_, model) = create_chat_provider_from_string("agentic", "openai:gpt-4o-mini", &config)
.expect("openai:<model> must build");
assert_eq!(model, "gpt-4o-mini");
}
#[test]
fn anthropic_prefix() {
let config =
config_with_providers(vec![anthropic_entry("p_ant", "claude-sonnet-4-6")], None);
fn anthropic_slug_model() {
let config = config_with_providers(vec![anthropic_entry("p_ant", "anthropic")]);
let (_, model) =
create_chat_provider_from_string("coding", "anthropic:claude-sonnet-4-6", &config)
.expect("anthropic:<model> must build");
@@ -534,13 +452,16 @@ mod tests {
}
#[test]
fn openrouter_prefix() {
fn openrouter_slug_model() {
let mut config = Config::default();
config.cloud_providers.push(CloudProviderCreds {
id: "p_or".to_string(),
r#type: CloudProviderType::Openrouter,
slug: "openrouter".to_string(),
label: "OpenRouter".to_string(),
endpoint: "https://openrouter.ai/api/v1".to_string(),
default_model: "openai/gpt-4o".to_string(),
auth_style: AuthStyle::Bearer,
default_model: Some("openai/gpt-4o".to_string()),
..Default::default()
});
let (_, model) = create_chat_provider_from_string(
"agentic",
@@ -563,7 +484,7 @@ mod tests {
// ── Workload routing ──────────────────────────────────────────────────────
#[test]
fn all_workloads_default_to_cloud() {
fn all_workloads_default_to_openhuman() {
let config = Config::default();
for role in &[
"reasoning",
@@ -577,8 +498,8 @@ mod tests {
] {
assert_eq!(
provider_for_role(role, &config),
"cloud",
"role={role} must default to cloud"
"openhuman",
"role={role} must default to openhuman"
);
}
}
@@ -591,14 +512,13 @@ mod tests {
provider_for_role("heartbeat", &config),
"ollama:llama3.2:3b"
);
assert_eq!(provider_for_role("reasoning", &config), "cloud");
assert_eq!(provider_for_role("reasoning", &config), "openhuman");
}
#[test]
fn create_chat_provider_uses_role() {
let mut config = Config::default();
config.cloud_providers.push(openai_entry("p_oai", "gpt-4o"));
config.primary_cloud = Some("p_oai".to_string());
config.cloud_providers.push(openai_entry("p_oai", "openai"));
config.reasoning_provider = Some("openai:gpt-4o-mini".to_string());
let (_, model) =
create_chat_provider("reasoning", &config).expect("create_chat_provider must succeed");
@@ -608,14 +528,24 @@ mod tests {
// ── Error cases ───────────────────────────────────────────────────────────
#[test]
fn unknown_provider_string_rejected() {
// `Result<(Box<dyn Provider>, String), _>` can't use `.expect_err`
// because `dyn Provider` doesn't implement `Debug` — drop the
// Ok via `.err()` and pattern on the Option instead.
fn unknown_slug_rejected() {
let config = Config::default();
let err = create_chat_provider_from_string("reasoning", "groq:llama3", &config)
.err()
.expect("unknown provider string must fail");
.expect("unknown slug must fail");
assert!(
err.to_string()
.contains("no cloud provider configured for slug"),
"{err}"
);
}
#[test]
fn bare_string_without_colon_rejected() {
let config = Config::default();
let err = create_chat_provider_from_string("reasoning", "openai", &config)
.err()
.expect("bare string must fail");
assert!(
err.to_string().contains("unrecognised provider string"),
"{err}"
@@ -632,23 +562,21 @@ mod tests {
}
#[test]
fn missing_creds_for_openai_gives_clear_error() {
fn missing_slug_for_openai_gives_clear_error() {
// No openai entry in cloud_providers.
let config = Config::default();
let err = create_chat_provider_from_string("reasoning", "openai:gpt-4o", &config)
.err()
.expect("missing creds must fail");
.expect("missing slug must fail");
let msg = err.to_string();
assert!(
msg.contains("no cloud provider configured for type 'openai'"),
msg.contains("no cloud provider configured for slug 'openai'"),
"{msg}"
);
}
#[test]
fn primary_cloud_defaults_to_openhuman_when_none() {
// primary_cloud=None → factory must still succeed by falling back
// to either an openhuman entry or the openhuman backend.
fn primary_cloud_defaults_to_openhuman_when_no_providers() {
let config = Config::default();
assert!(create_chat_provider("reasoning", &config).is_ok());
}
@@ -657,10 +585,6 @@ mod tests {
#[test]
fn summarization_aliases_memory_provider() {
// The summarizer sub-agent declares `[model] hint = "summarization"`
// but there's no `summarization_provider` config field — the workload
// is a synonym for `memory` since both are "condense input" tasks.
// `provider_for_role("summarization", ...)` must read `memory_provider`.
let mut config = Config::default();
config.memory_provider = Some("ollama:llama3.1:8b".to_string());
assert_eq!(provider_for_role("memory", &config), "ollama:llama3.1:8b");
@@ -672,45 +596,30 @@ mod tests {
}
#[test]
fn summarization_defaults_to_cloud_like_memory() {
// No memory_provider → both `memory` and `summarization` fall through
// to "cloud", consistent with every other workload.
fn summarization_defaults_to_openhuman_like_memory() {
let config = Config::default();
assert_eq!(provider_for_role("memory", &config), "cloud");
assert_eq!(provider_for_role("summarization", &config), "cloud");
assert_eq!(provider_for_role("memory", &config), "openhuman");
assert_eq!(provider_for_role("summarization", &config), "openhuman");
}
#[test]
fn unknown_workload_falls_back_to_cloud() {
// The wildcard arm in provider_for_role's match must keep
// unrecognised workloads on the primary so a typo in an agent
// TOML doesn't surface as a NoneProvider crash.
fn unknown_workload_falls_back_to_openhuman() {
let config = Config::default();
assert_eq!(provider_for_role("nope-not-a-workload", &config), "cloud");
assert_eq!(provider_for_role("", &config), "cloud");
assert_eq!(
provider_for_role("nope-not-a-workload", &config),
"openhuman"
);
assert_eq!(provider_for_role("", &config), "openhuman");
}
// ── OpenHuman backend state_dir wiring ────────────────────────────────────
#[test]
fn openhuman_backend_uses_config_path_parent_as_state_dir() {
// Regression test for #1710: when the user's workspace lives outside
// ~/.openhuman (e.g. OPENHUMAN_WORKSPACE override, or a test
// worktree), the factory must propagate config.config_path.parent()
// into ProviderRuntimeOptions.openhuman_dir so the backend's
// AuthService reads `auth-profiles.json` from the same workspace
// login wrote to. Without this, login succeeds but every chat
// call bails with "No backend session".
let mut config = Config::default();
config.config_path = std::path::PathBuf::from("/tmp/oh-test-workspace/config.toml");
// Build via the chat-factory entrypoint — make_openhuman_backend
// is private and called transitively when there are no
// cloud_providers entries.
let (_provider, model) = create_chat_provider("reasoning", &config)
.expect("openhuman backend must build with no cloud_providers");
// Sanity: the model resolution path also goes through the
// backend ctor, so a successful build implies state_dir was
// wired without panic.
assert!(!model.is_empty(), "model must be set");
assert!(!model.is_empty(), "model must be set")
}
}
+5
View File
@@ -5,6 +5,7 @@ pub mod openhuman_backend;
pub mod ops;
pub mod reliable;
pub mod router;
pub mod schemas;
pub mod thread_context;
pub mod traits;
@@ -17,3 +18,7 @@ pub use traits::{
pub use billing_error::is_budget_exhausted_message;
pub use factory::{create_chat_provider, provider_for_role};
pub use ops::*;
pub use schemas::{
all_controller_schemas as all_providers_controller_schemas,
all_registered_controllers as all_providers_registered_controllers,
};
+207
View File
@@ -0,0 +1,207 @@
//! RPC controller schemas for the providers domain.
//!
//! Exposes `openhuman.providers_list_models` — fetches the `/models` endpoint
//! of a configured cloud provider and returns the list.
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::rpc::RpcOutcome;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
// ── Helpers ──────────────────────────────────────────────────────────────────
fn to_json<T: Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
fn deserialize_params<T: for<'de> Deserialize<'de>>(
params: Map<String, Value>,
) -> Result<T, String> {
serde_json::from_value(Value::Object(params)).map_err(|e| e.to_string())
}
// ── Schema catalog ────────────────────────────────────────────────────────────
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![list_models_schema()]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![RegisteredController {
schema: list_models_schema(),
handler: handle_list_models,
}]
}
fn list_models_schema() -> ControllerSchema {
ControllerSchema {
namespace: "providers",
function: "list_models",
description: "Fetch the available model list from a configured cloud provider's /models API.",
inputs: vec![
FieldSchema {
name: "provider_id",
ty: TypeSchema::String,
comment: "Opaque id of the cloud_providers entry to query.",
required: true,
},
],
outputs: vec![
FieldSchema {
name: "models",
ty: TypeSchema::Json,
comment: "Array of { id, owned_by?, context_window? } model descriptors returned by the provider.",
required: true,
},
],
}
}
// ── Request / response types ──────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct ListModelsRequest {
provider_id: String,
}
#[derive(Debug, Serialize)]
struct ModelInfo {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
owned_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
context_window: Option<u64>,
}
// ── Handler ───────────────────────────────────────────────────────────────────
fn handle_list_models(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let req: ListModelsRequest = deserialize_params(params)?;
let provider_id = req.provider_id.trim().to_string();
if provider_id.is_empty() {
return Err("provider_id must not be empty".to_string());
}
log::debug!("[providers][list_models] provider_id={}", provider_id);
let config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| e.to_string())?;
let entry = config
.cloud_providers
.iter()
.find(|e| e.id == provider_id)
.cloned()
.ok_or_else(|| format!("no cloud provider with id '{}' found", provider_id))?;
// Build the /models URL from the provider's endpoint.
let base = entry.endpoint.trim_end_matches('/');
let models_url = format!("{}/models", base);
log::debug!(
"[providers][list_models] fetching url={} slug={}",
models_url,
entry.slug
);
// Fetch the API key for this provider.
let api_key =
crate::openhuman::providers::factory::lookup_key_for_slug(&entry.slug, &config)
.unwrap_or_default();
// Build the HTTP client (reuse the runtime proxy config). Explicit
// timeouts mirror the other external integrations (composio,
// multimodal) so a slow/unresponsive provider can't hang the panel.
let client = crate::openhuman::config::build_runtime_proxy_client_with_timeouts(
"providers.list_models",
30,
10,
);
let mut request = client.get(&models_url);
// Attach auth header per auth_style.
use crate::openhuman::config::schema::cloud_providers::AuthStyle;
request = match entry.auth_style {
AuthStyle::Bearer => {
if !api_key.is_empty() {
request.header("Authorization", format!("Bearer {}", api_key))
} else {
request
}
}
AuthStyle::Anthropic => {
let mut r = request.header("anthropic-version", "2023-06-01");
if !api_key.is_empty() {
r = r.header("x-api-key", &api_key);
}
r
}
AuthStyle::OpenhumanJwt | AuthStyle::None => request,
};
let response = request
.send()
.await
.map_err(|e| format!("[providers][list_models] HTTP request failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
let truncated = crate::openhuman::util::truncate_with_ellipsis(&body, 300);
return Err(format!(
"provider returned {}: {}",
status.as_u16(),
truncated
));
}
let body: Value = response
.json()
.await
.map_err(|e| format!("[providers][list_models] failed to parse JSON: {}", e))?;
// Parse OpenAI-compatible `{ data: [{ id, owned_by? }] }` or
// Anthropic `{ data: [{ id, display_name }] }`.
let data = body
.get("data")
.and_then(|d| d.as_array())
.cloned()
.unwrap_or_default();
let models: Vec<ModelInfo> = data
.iter()
.filter_map(|item| {
let id = item.get("id")?.as_str()?.to_string();
let owned_by = item
.get("owned_by")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let context_window = item
.get("context_length")
.or_else(|| item.get("context_window"))
.and_then(|v| v.as_u64());
Some(ModelInfo {
id,
owned_by,
context_window,
})
})
.collect();
log::info!(
"[providers][list_models] slug={} fetched {} models",
entry.slug,
models.len()
);
to_json(RpcOutcome::new(
serde_json::json!({ "models": models }),
vec![format!("fetched {} models", models.len())],
))
})
}