mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 15:03:57 +00:00
feat(providers): slug-keyed cloud providers + per-workload model routing (#1888)
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,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,
|
||||
};
|
||||
|
||||
@@ -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())],
|
||||
))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user