mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-01 07:02:30 +00:00
Serialize generated provider overlays with the TOML library
This commit is contained in:
parent
f79e21872e
commit
2d859496e2
6 changed files with 275 additions and 90 deletions
|
|
@ -2155,29 +2155,14 @@ fn install_catalog_with_base_url(
|
|||
let Some(base_url) = state.upstreams.provider_base_urls.get(provider.id()) else {
|
||||
return Ok(Catalog::clone(&INSTALL_CATALOG));
|
||||
};
|
||||
let overlay = fabro_config::LlmLayer(
|
||||
toml::from_str(&format!(
|
||||
"[providers.{}]\nbase_url = {}\n",
|
||||
toml_key(provider.id().as_str()),
|
||||
toml::Value::String(base_url.clone())
|
||||
))
|
||||
.context("install provider base URL overlay should parse")?,
|
||||
);
|
||||
let overlay = fabro_config::LlmLayer::from_providers([(
|
||||
provider.id().to_string(),
|
||||
toml::Table::from_iter([("base_url".into(), toml::Value::String(base_url.clone()))]),
|
||||
)]);
|
||||
fabro_llm::build_catalog(&overlay, &|_| None)
|
||||
.context("install catalog with provider base URL override should build")
|
||||
}
|
||||
|
||||
fn toml_key(key: &str) -> String {
|
||||
if key
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
key.to_string()
|
||||
} else {
|
||||
format!("{key:?}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn provider_base_url_override(state: &InstallAppState, provider: &CatalogProvider) -> String {
|
||||
state
|
||||
|
|
|
|||
|
|
@ -1729,6 +1729,84 @@ async fn resolve_llm_client_reads_openai_token_from_vault() {
|
|||
assert!(llm_result.auth_issues.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dotted_provider_starts_and_uses_its_vault_key_with_inferred_or_explicit_profile() {
|
||||
for explicit_profile in [false, true] {
|
||||
let upstream = MockServer::start_async().await;
|
||||
let completion = upstream
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/v1/chat/completions")
|
||||
.header("authorization", "Bearer dotted-provider-test-key")
|
||||
.json_body_includes(r#"{"model":"wire-model"}"#);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(json!({
|
||||
"id": "chatcmpl-dotted",
|
||||
"object": "chat.completion",
|
||||
"created": 1_700_000_000,
|
||||
"model": "wire-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "OK"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
|
||||
}));
|
||||
})
|
||||
.await;
|
||||
let mut overlay = r#"
|
||||
[providers."my.gw"]
|
||||
display_name = "Gateway"
|
||||
base_url = "http://unused.invalid"
|
||||
auth = { type = "bearer" }
|
||||
[providers."my.gw".models."model.v1"]
|
||||
display_name = "Model"
|
||||
api_model = "wire-model"
|
||||
"#
|
||||
.to_string();
|
||||
if explicit_profile {
|
||||
overlay.push_str(
|
||||
r#"
|
||||
[providers."my.gw".metadata.agent]
|
||||
profile = "openai"
|
||||
"#,
|
||||
);
|
||||
}
|
||||
let state = TestAppStateBuilder::new()
|
||||
.runtime_settings(default_test_server_settings(), RunLayer::default())
|
||||
.env_lookup(|_| None)
|
||||
.llm_overlay_toml(&overlay)
|
||||
.provider_base_url("my.gw", upstream.base_url())
|
||||
.build();
|
||||
state
|
||||
.stores
|
||||
.vault
|
||||
.set(
|
||||
"MY_GW_API_KEY",
|
||||
"dotted-provider-test-key",
|
||||
SecretType::Token,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let llm = state.resolve_llm_client().await.unwrap();
|
||||
let response = llm
|
||||
.client
|
||||
.complete(
|
||||
LlmRequest::builder()
|
||||
.model("my.gw/model.v1")
|
||||
.user("Hello")
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.text(), "OK");
|
||||
completion.assert_async().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_llm_client_ignores_env_lookup_provider_tokens() {
|
||||
let state = test_app_state_with_env_lookup(
|
||||
|
|
|
|||
|
|
@ -364,12 +364,10 @@ pub fn llm_overlay_with_provider_base_url(
|
|||
provider: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
) -> LlmLayer {
|
||||
let provider = provider.into();
|
||||
llm_overlay_from_toml(&format!(
|
||||
"[providers.{}]\nbase_url = {}\n",
|
||||
toml::Value::String(provider),
|
||||
toml::Value::String(base_url.into())
|
||||
))
|
||||
LlmLayer::from_providers([(
|
||||
provider.into(),
|
||||
toml::Table::from_iter([("base_url".into(), toml::Value::String(base_url.into()))]),
|
||||
)])
|
||||
}
|
||||
|
||||
/// The catalog a test app state builds from `overlay`.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use lithos_llm::catalog::{
|
|||
adapter_ids, codec_ids,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use toml::{Table, Value};
|
||||
|
||||
/// The metadata namespace agent harnesses read.
|
||||
const AGENT_METADATA_NAMESPACE: &str = "agent";
|
||||
|
|
@ -30,75 +31,59 @@ pub fn build_catalog(
|
|||
overlay: &LlmLayer,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<Catalog, CatalogError> {
|
||||
let mut builder = Catalog::builder().with_builtin();
|
||||
if !overlay.is_empty() {
|
||||
let mut document = overlay.to_overlay_toml();
|
||||
document.insert_str(0, "schema_version = 1\n");
|
||||
builder = builder.toml_layer("settings [llm]", &document)?;
|
||||
}
|
||||
if let Some(base_url) = env_lookup(EnvVars::OPENAI_BASE_URL) {
|
||||
let document = format!(
|
||||
"schema_version = 1\n[providers.openai]\nbase_url = {}\n",
|
||||
toml::Value::String(base_url.trim_end_matches("/v1").to_string())
|
||||
);
|
||||
builder = builder.toml_layer("OPENAI_BASE_URL", &document)?;
|
||||
}
|
||||
let catalog = builder.build()?;
|
||||
// The coding agent reads a provider's harness profile from
|
||||
// `metadata.agent.profile` and refuses a provider without one. The lithos
|
||||
// built-ins all declare theirs; an operator-defined provider that does
|
||||
// not gets the profile its wire protocol implies, layered on last.
|
||||
let base_url_overlay = env_lookup(EnvVars::OPENAI_BASE_URL).map(|base_url| {
|
||||
LlmLayer::from_providers([(
|
||||
"openai".into(),
|
||||
Table::from_iter([(
|
||||
"base_url".into(),
|
||||
Value::String(base_url.trim_end_matches("/v1").to_string()),
|
||||
)]),
|
||||
)])
|
||||
});
|
||||
let build_base = || {
|
||||
let mut builder = Catalog::builder().with_builtin();
|
||||
if !overlay.is_empty() {
|
||||
builder = add_catalog_layer(builder, "settings [llm]", overlay)?;
|
||||
}
|
||||
if let Some(overlay) = &base_url_overlay {
|
||||
builder = add_catalog_layer(builder, "OPENAI_BASE_URL", overlay)?;
|
||||
}
|
||||
Ok::<_, CatalogError>(builder)
|
||||
};
|
||||
let catalog = build_base()?.build()?;
|
||||
// Pebble needs provider-level profile metadata. Fill only missing profiles
|
||||
// after the operator overlay has selected the provider's wire protocol.
|
||||
let implied = implied_agent_profiles(&catalog);
|
||||
if implied.is_empty() {
|
||||
return Ok(catalog);
|
||||
}
|
||||
let mut builder = Catalog::builder().with_builtin();
|
||||
if !overlay.is_empty() {
|
||||
let mut document = overlay.to_overlay_toml();
|
||||
document.insert_str(0, "schema_version = 1\n");
|
||||
builder = builder.toml_layer("settings [llm]", &document)?;
|
||||
}
|
||||
if let Some(base_url) = env_lookup(EnvVars::OPENAI_BASE_URL) {
|
||||
let document = format!(
|
||||
"schema_version = 1\n[providers.openai]\nbase_url = {}\n",
|
||||
toml::Value::String(base_url.trim_end_matches("/v1").to_string())
|
||||
);
|
||||
builder = builder.toml_layer("OPENAI_BASE_URL", &document)?;
|
||||
}
|
||||
builder
|
||||
.toml_layer("implied agent profiles", &implied)
|
||||
.and_then(CatalogBuilder::build)
|
||||
add_catalog_layer(build_base()?, "implied agent profiles", &implied)?.build()
|
||||
}
|
||||
|
||||
/// A TOML layer declaring `metadata.agent.profile` for every provider that
|
||||
/// has none, as the profile implied by the provider's adapter. Empty when
|
||||
/// every provider already says.
|
||||
fn implied_agent_profiles(catalog: &Catalog) -> String {
|
||||
use std::fmt::Write as _;
|
||||
fn add_catalog_layer(
|
||||
builder: CatalogBuilder,
|
||||
name: &str,
|
||||
overlay: &LlmLayer,
|
||||
) -> Result<CatalogBuilder, CatalogError> {
|
||||
// The fixed schema declaration is outside the settings overlay. All
|
||||
// dynamic keys and values are serialized together by the TOML library.
|
||||
let document = format!("schema_version = 1\n{}", overlay.to_overlay_toml());
|
||||
builder.toml_layer(name, &document)
|
||||
}
|
||||
|
||||
let mut document = String::new();
|
||||
for provider in catalog.providers() {
|
||||
/// Metadata for providers without an explicit harness profile.
|
||||
fn implied_agent_profiles(catalog: &Catalog) -> LlmLayer {
|
||||
LlmLayer::from_providers(catalog.providers().filter_map(|provider| {
|
||||
if agent_metadata(provider.metadata()).profile.is_some() {
|
||||
continue;
|
||||
return None;
|
||||
}
|
||||
let profile = adapter_agent_profile(provider);
|
||||
let _ = writeln!(
|
||||
document,
|
||||
"[providers.{}.metadata.agent]\nprofile = {}\n",
|
||||
provider.id(),
|
||||
toml::Value::String(
|
||||
serde_json::to_value(profile)
|
||||
.ok()
|
||||
.and_then(|value| value.as_str().map(str::to_owned))
|
||||
.unwrap_or_else(|| "openai".to_string())
|
||||
),
|
||||
);
|
||||
}
|
||||
if document.is_empty() {
|
||||
return document;
|
||||
}
|
||||
document.insert_str(0, "schema_version = 1\n");
|
||||
document
|
||||
let profile = Value::try_from(adapter_agent_profile(provider))
|
||||
.expect("agent profiles serialize as strings");
|
||||
let agent = Table::from_iter([("profile".into(), profile)]);
|
||||
let metadata = Table::from_iter([("agent".into(), Value::Table(agent))]);
|
||||
let fields = Table::from_iter([("metadata".into(), Value::Table(metadata))]);
|
||||
Some((provider.id().to_string(), fields))
|
||||
}))
|
||||
}
|
||||
|
||||
/// The profile a provider's wire protocol implies, for a provider whose
|
||||
|
|
@ -313,6 +298,111 @@ capabilities = { text = true, tools = true }
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_provider_profiles_preserve_identity_metadata_and_model_overrides() {
|
||||
let source = r#"
|
||||
[providers."my.gw"]
|
||||
display_name = "Gateway"
|
||||
base_url = "http://127.0.0.1:1234/v1"
|
||||
auth = { type = "bearer" }
|
||||
[providers."my.gw".metadata.agent]
|
||||
reasoning_by_default = false
|
||||
[providers."my.gw".models."model.v1"]
|
||||
display_name = "Model"
|
||||
api_model = "wire-model"
|
||||
[providers."my.gw".models."model.v1".metadata.agent]
|
||||
profile = "anthropic"
|
||||
[providers.my]
|
||||
display_name = "Sibling"
|
||||
base_url = "http://127.0.0.1:5678"
|
||||
auth = { type = "none" }
|
||||
[providers.my.metadata.agent]
|
||||
profile = "gemini"
|
||||
"#;
|
||||
for enabled in [true, false] {
|
||||
let mut overlay = LlmLayer(toml::from_str(source).unwrap());
|
||||
overlay.0["providers"]["my.gw"]
|
||||
.as_table_mut()
|
||||
.unwrap()
|
||||
.insert("enabled".into(), Value::Boolean(enabled));
|
||||
let catalog = build_catalog(&overlay, &|name| {
|
||||
(name == EnvVars::OPENAI_BASE_URL).then(|| "http://127.0.0.1:4321/v1".into())
|
||||
})
|
||||
.unwrap();
|
||||
let gateway = catalog.provider("my.gw").unwrap();
|
||||
assert_eq!(gateway.id().as_str(), "my.gw");
|
||||
assert_eq!(gateway.base_url(), "http://127.0.0.1:1234/v1");
|
||||
assert_eq!(
|
||||
agent_metadata(gateway.metadata()).profile,
|
||||
Some(AgentProfileKind::OpenAi)
|
||||
);
|
||||
assert_eq!(
|
||||
agent_metadata(gateway.metadata()).reasoning_by_default,
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(gateway.model("model.v1").unwrap().api_model(), "wire-model");
|
||||
assert_eq!(
|
||||
model_agent_profile(gateway, gateway.model("model.v1").unwrap()),
|
||||
AgentProfileKind::Anthropic
|
||||
);
|
||||
assert_eq!(
|
||||
fabro_auth::expected_secret_name(gateway).as_deref(),
|
||||
Some("MY_GW_API_KEY")
|
||||
);
|
||||
assert_eq!(
|
||||
agent_profile(&catalog, "my", None),
|
||||
Some(AgentProfileKind::Gemini)
|
||||
);
|
||||
assert_eq!(catalog.enabled_provider("my.gw").is_some(), enabled);
|
||||
assert_eq!(
|
||||
catalog.provider("openai").unwrap().base_url(),
|
||||
"http://127.0.0.1:4321"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_names_remain_literal_with_inferred_and_explicit_profiles() {
|
||||
for id in [
|
||||
"my.gw", "my.gw.v2", "my-gw", "my_gw", ".gateway", "gateway.",
|
||||
] {
|
||||
for explicit in [None, Some("openai"), Some("gemini")] {
|
||||
let mut fields: Table = toml::from_str(
|
||||
r#"
|
||||
display_name = "Gateway"
|
||||
base_url = "http://127.0.0.1:1234"
|
||||
auth = { type = "none" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(profile) = explicit {
|
||||
fields.insert(
|
||||
"metadata".into(),
|
||||
Value::Table(Table::from_iter([(
|
||||
"agent".into(),
|
||||
Value::Table(Table::from_iter([(
|
||||
"profile".into(),
|
||||
Value::String(profile.into()),
|
||||
)])),
|
||||
)])),
|
||||
);
|
||||
}
|
||||
let overlay = LlmLayer::from_providers([(id.into(), fields)]);
|
||||
let catalog = build_catalog(&overlay, &|_| None).unwrap();
|
||||
assert_eq!(catalog.provider(id).unwrap().id().as_str(), id);
|
||||
assert_eq!(
|
||||
agent_profile(&catalog, id, None),
|
||||
Some(if explicit == Some("gemini") {
|
||||
AgentProfileKind::Gemini
|
||||
} else {
|
||||
AgentProfileKind::OpenAi
|
||||
})
|
||||
);
|
||||
assert!(catalog.provider("my").is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The implied profile follows the provider's first codec, so a host that
|
||||
/// speaks Anthropic Messages gets the Anthropic harness.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -35,10 +35,11 @@ pub fn test_catalog_with_overlay(overlay: &str) -> Catalog {
|
|||
/// The test catalog with one provider's base URL pointed elsewhere.
|
||||
#[must_use]
|
||||
pub fn test_catalog_with_provider_base_url(provider: &str, base_url: &str) -> Catalog {
|
||||
test_catalog_with_overlay(&format!(
|
||||
"[providers.{provider}]\nbase_url = {}\n",
|
||||
toml::Value::String(base_url.to_string())
|
||||
))
|
||||
let overlay = LlmLayer::from_providers([(
|
||||
provider.to_string(),
|
||||
toml::Table::from_iter([("base_url".into(), toml::Value::String(base_url.to_string()))]),
|
||||
)]);
|
||||
crate::build_catalog(&overlay, &|_| None).expect("test catalog should build")
|
||||
}
|
||||
|
||||
/// Builds a text response attributed to `provider/model`.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
//! validates it when the catalog is built.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use toml::Table;
|
||||
use toml::{Table, Value};
|
||||
|
||||
use super::combine::Combine;
|
||||
|
||||
|
|
@ -28,6 +28,23 @@ use super::combine::Combine;
|
|||
pub struct LlmLayer(pub Table);
|
||||
|
||||
impl LlmLayer {
|
||||
/// Build an overlay from provider tables, treating every provider name as
|
||||
/// one key. TOML quoting and escaping are handled by `to_overlay_toml`.
|
||||
#[must_use]
|
||||
pub fn from_providers(providers: impl IntoIterator<Item = (String, Table)>) -> Self {
|
||||
let providers: Table = providers
|
||||
.into_iter()
|
||||
.map(|(id, fields)| (id, Value::Table(fields)))
|
||||
.collect();
|
||||
if providers.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
Self(Table::from_iter([(
|
||||
"providers".into(),
|
||||
Value::Table(providers),
|
||||
)]))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
|
|
@ -74,6 +91,22 @@ mod tests {
|
|||
LlmLayer(toml::from_str(source).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_provider_tables_round_trip_literal_keys_and_values() {
|
||||
// The layer is raw TOML; catalog validation separately restricts IDs.
|
||||
for name in ["my.gw", "my.gw.v2", "a b", "a\"b", "a\\b", "a\nb", "日本語"] {
|
||||
let value = "https://example.test/\"quoted\"\\path\nnext\tline";
|
||||
let fields = Table::from_iter([("base_url".into(), Value::String(value.into()))]);
|
||||
let source = LlmLayer::from_providers([(name.into(), fields.clone())]);
|
||||
let parsed = layer(&source.to_overlay_toml());
|
||||
assert_eq!(parsed, source);
|
||||
let providers = parsed.0["providers"].as_table().unwrap();
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(providers[name].as_table().unwrap(), &fields);
|
||||
}
|
||||
assert!(LlmLayer::from_providers([]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_layer_wins_scalars_and_merges_tables() {
|
||||
let higher = layer(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue