mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
Adopt lithos vocabulary in fabro-types, fabro-auth, and fabro-config
fabro-types re-exports the lithos request, response, content, tool, and stream types and absorbs the identifiers, billing rollup, provider ids, controls, catalog API views, and Fabro catalog policy (`metadata.fabro`) that lived in fabro-model. Stored and wire formats use the lithos serde shapes directly with no compatibility shims. fabro-auth becomes a lithos `CredentialProvider`: `CredentialSource` resolves credentials per catalog provider, with env, vault, SQL vault, extra-headers, and API-key sources. fabro-config's `[llm]` settings become an opaque TOML overlay layer (`LlmLayer`) that is applied on top of the lithos built-ins and the Fabro policy layer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
580bb85f5b
commit
f37151ed46
42 changed files with 2412 additions and 3645 deletions
|
|
@ -18,12 +18,12 @@ async-trait.workspace = true
|
|||
base64.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
fabro-http.workspace = true
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-oauth = { path = "../fabro-oauth" }
|
||||
fabro-redact.workspace = true
|
||||
fabro-static.workspace = true
|
||||
fabro-types = { path = "../fabro-types" }
|
||||
fabro-vault = { path = "../fabro-vault" }
|
||||
lithos-llm = { workspace = true, features = ["runtime"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
|
@ -32,6 +32,7 @@ tracing.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
httpmock = "0.8"
|
||||
lithos-llm = { workspace = true, features = ["runtime", "builtin-catalog"] }
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "test-util"] }
|
||||
toml.workspace = true
|
||||
|
|
|
|||
72
lib/foundation/fabro-auth/src/api_key_source.rs
Normal file
72
lib/foundation/fabro-auth/src/api_key_source.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
//! A credential source holding one operator-supplied API key.
|
||||
//!
|
||||
//! Used to validate a pasted key before it is stored: the key is shaped into
|
||||
//! the provider's declared auth scheme and offered for that provider only.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_vault::Vault;
|
||||
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::Credentials;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
use crate::credential_source::CredentialSource;
|
||||
use crate::resolve::{ResolveError, credentials_for_api_key};
|
||||
|
||||
pub struct ApiKeyCredentialSource {
|
||||
provider: ProviderId,
|
||||
key: String,
|
||||
vault: Arc<AsyncRwLock<Vault>>,
|
||||
}
|
||||
|
||||
impl ApiKeyCredentialSource {
|
||||
/// A source for `provider` with no vault behind it, so extra headers that
|
||||
/// interpolate vault secrets fail to resolve.
|
||||
#[must_use]
|
||||
pub fn new(provider: ProviderId, key: String) -> Self {
|
||||
Self::with_vault(
|
||||
provider,
|
||||
key,
|
||||
Arc::new(AsyncRwLock::new(Vault::from_entries(HashMap::new()))),
|
||||
)
|
||||
}
|
||||
|
||||
/// A source for `provider` whose extra headers resolve against `vault`.
|
||||
#[must_use]
|
||||
pub fn with_vault(provider: ProviderId, key: String, vault: Arc<AsyncRwLock<Vault>>) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
key,
|
||||
vault,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ApiKeyCredentialSource {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ApiKeyCredentialSource")
|
||||
.field("provider", &self.provider)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CredentialSource for ApiKeyCredentialSource {
|
||||
async fn credentials(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
|
||||
if provider.id() != &self.provider {
|
||||
return Err(ResolveError::NotConfigured(provider.id().clone()));
|
||||
}
|
||||
let vault = self.vault.read().await;
|
||||
credentials_for_api_key(provider, self.key.clone(), &vault)
|
||||
}
|
||||
|
||||
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
catalog
|
||||
.provider(self.provider.as_str())
|
||||
.ok()
|
||||
.map(|provider| vec![provider.id().clone()])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
use fabro_model::ProviderId;
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthContextRequest {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use chrono::{DateTime, Duration, Utc};
|
||||
use fabro_redact::redact_string;
|
||||
pub use fabro_types::{OAuthConfig, OAuthCredential, OAuthTokens};
|
||||
|
||||
pub(crate) fn expires_at_from_now(expires_in: Option<u64>) -> DateTime<Utc> {
|
||||
|
|
@ -7,44 +6,6 @@ pub(crate) fn expires_at_from_now(expires_in: Option<u64>) -> DateTime<Utc> {
|
|||
Utc::now() + Duration::seconds(seconds)
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum ApiKeyHeader {
|
||||
Bearer(String),
|
||||
Custom {
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
/// No static header: the request is authenticated by AWS SigV4 signing,
|
||||
/// with credentials resolved from the AWS default chain at request time.
|
||||
AwsSigv4,
|
||||
}
|
||||
|
||||
fn redact_for_debug(value: &str) -> String {
|
||||
let redacted = redact_string(value);
|
||||
if redacted == value && !value.is_empty() {
|
||||
"REDACTED".to_string()
|
||||
} else {
|
||||
redacted
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ApiKeyHeader {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Bearer(value) => f
|
||||
.debug_tuple("Bearer")
|
||||
.field(&redact_for_debug(value))
|
||||
.finish(),
|
||||
Self::Custom { name, value } => f
|
||||
.debug_struct("Custom")
|
||||
.field("name", name)
|
||||
.field("value", &redact_for_debug(value))
|
||||
.finish(),
|
||||
Self::AwsSigv4 => f.write_str("AwsSigv4"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -81,12 +42,4 @@ mod tests {
|
|||
assert!(fixture(Utc::now() + Duration::minutes(4)).needs_refresh());
|
||||
assert!(!fixture(Utc::now() + Duration::minutes(6)).needs_refresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_header_debug_redacts_secret_values() {
|
||||
let header = ApiKeyHeader::Bearer("sk-test".to_string());
|
||||
let debug = format!("{header:?}");
|
||||
assert!(!debug.contains("sk-test"));
|
||||
assert!(debug.contains("REDACTED"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
124
lib/foundation/fabro-auth/src/credential_ref.rs
Normal file
124
lib/foundation/fabro-auth/src/credential_ref.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
//! Credential references declared in `metadata.fabro.credentials`.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, de};
|
||||
|
||||
/// Where one provider secret comes from.
|
||||
///
|
||||
/// A provider lists these in order; the first that resolves wins.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CredentialRef {
|
||||
/// A token or OAuth entry in the Fabro vault.
|
||||
Vault(String),
|
||||
/// A process environment variable.
|
||||
Env(String),
|
||||
/// The AWS default credential chain. Resolves without a secret; the
|
||||
/// Bedrock adapter signs each request.
|
||||
AwsSigv4,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CredentialRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Vault(name) => write!(f, "vault:{name}"),
|
||||
Self::Env(name) => write!(f, "env:{name}"),
|
||||
Self::AwsSigv4 => f.write_str("aws_sigv4"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CredentialRef {
|
||||
type Err = CredentialRefParseError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
if value == "aws_sigv4" {
|
||||
return Ok(Self::AwsSigv4);
|
||||
}
|
||||
if let Some(name) = value.strip_prefix("vault:") {
|
||||
return if name.is_empty() {
|
||||
Err(CredentialRefParseError::EmptyVault)
|
||||
} else {
|
||||
Ok(Self::Vault(name.to_string()))
|
||||
};
|
||||
}
|
||||
if let Some(name) = value.strip_prefix("env:") {
|
||||
return if name.is_empty() {
|
||||
Err(CredentialRefParseError::EmptyEnv)
|
||||
} else {
|
||||
Ok(Self::Env(name.to_string()))
|
||||
};
|
||||
}
|
||||
Err(CredentialRefParseError::Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for CredentialRef {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CredentialRef {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
value.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum CredentialRefParseError {
|
||||
#[error("credential reference must be `vault:<name>`, `env:<NAME>`, or `aws_sigv4`")]
|
||||
Invalid,
|
||||
#[error("credential reference is missing a name after `vault:`")]
|
||||
EmptyVault,
|
||||
#[error("credential reference is missing a name after `env:`")]
|
||||
EmptyEnv,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_each_form() {
|
||||
assert_eq!(
|
||||
"vault:OPENAI_CODEX".parse::<CredentialRef>().unwrap(),
|
||||
CredentialRef::Vault("OPENAI_CODEX".into())
|
||||
);
|
||||
assert_eq!(
|
||||
"env:KIMI_API_KEY".parse::<CredentialRef>().unwrap(),
|
||||
CredentialRef::Env("KIMI_API_KEY".into())
|
||||
);
|
||||
assert_eq!(
|
||||
"aws_sigv4".parse::<CredentialRef>().unwrap(),
|
||||
CredentialRef::AwsSigv4
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_literal_secrets_without_echoing_them() {
|
||||
let err = "sk-ant-1234".parse::<CredentialRef>().unwrap_err();
|
||||
assert_eq!(err, CredentialRefParseError::Invalid);
|
||||
assert!(!err.to_string().contains("sk-ant"));
|
||||
assert_eq!(
|
||||
"vault:".parse::<CredentialRef>().unwrap_err(),
|
||||
CredentialRefParseError::EmptyVault
|
||||
);
|
||||
assert_eq!(
|
||||
"env:".parse::<CredentialRef>().unwrap_err(),
|
||||
CredentialRefParseError::EmptyEnv
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_serde_strings() {
|
||||
let value: Vec<CredentialRef> =
|
||||
serde_json::from_str(r#"["env:A", "vault:b", "aws_sigv4"]"#).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&value).unwrap(),
|
||||
r#"["env:A","vault:b","aws_sigv4"]"#
|
||||
);
|
||||
assert!(serde_json::from_str::<Vec<CredentialRef>>(r#"["sk-literal"]"#).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,102 @@
|
|||
//! Per-attempt credential lookup for LLM providers.
|
||||
//!
|
||||
//! [`CredentialSource`] is Fabro's storage-aware credential seam: the vault,
|
||||
//! the SQL secret store, and the process environment each implement it.
|
||||
//! [`lithos_credentials`] adapts a source into the lithos
|
||||
//! [`CredentialProvider`] the client calls before every provider attempt, so a
|
||||
//! refreshed OAuth token is picked up by the next retry.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_types::catalog_policy;
|
||||
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::{CredentialError, CredentialProvider, Credentials};
|
||||
|
||||
use crate::{ApiCredential, ResolveError};
|
||||
use crate::{ResolveError, auth_issue_message};
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Which providers a source can serve right now, and why the rest cannot.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ResolvedCredentials {
|
||||
pub credentials: Vec<ApiCredential>,
|
||||
/// Enabled providers whose credentials resolved.
|
||||
pub ready: Vec<ProviderId>,
|
||||
/// Enabled providers with credential material that failed to resolve,
|
||||
/// such as an expired OAuth token that could not be refreshed. Providers
|
||||
/// with no material at all are not issues; they are simply absent.
|
||||
pub auth_issues: Vec<(ProviderId, ResolveError)>,
|
||||
}
|
||||
|
||||
impl ResolvedCredentials {
|
||||
/// A human-readable line per auth issue.
|
||||
#[must_use]
|
||||
pub fn issue_messages(&self) -> Vec<String> {
|
||||
self.auth_issues
|
||||
.iter()
|
||||
.map(|(provider, issue)| auth_issue_message(provider, issue))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait CredentialSource: Send + Sync {
|
||||
async fn resolve(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials>;
|
||||
/// Resolves `provider`'s credentials for one request attempt.
|
||||
async fn credentials(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError>;
|
||||
|
||||
/// Providers with credential material present. Does not refresh or
|
||||
/// validate anything, so it is cheap enough for listings.
|
||||
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId>;
|
||||
|
||||
/// Resolves every enabled provider once, separating the ready set from
|
||||
/// the providers that have material but cannot use it.
|
||||
async fn resolve_all(&self, catalog: &Catalog) -> ResolvedCredentials {
|
||||
let mut resolved = ResolvedCredentials::default();
|
||||
for provider in catalog.providers() {
|
||||
if !catalog_policy::provider_policy(provider).is_enabled() {
|
||||
continue;
|
||||
}
|
||||
match self.credentials(provider).await {
|
||||
Ok(_) => resolved.ready.push(provider.id().clone()),
|
||||
Err(ResolveError::NotConfigured(_)) => {}
|
||||
Err(err) => resolved.auth_issues.push((provider.id().clone(), err)),
|
||||
}
|
||||
}
|
||||
resolved
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapts a [`CredentialSource`] into the lithos credential provider.
|
||||
#[must_use]
|
||||
pub fn lithos_credentials(source: Arc<dyn CredentialSource>) -> Arc<dyn CredentialProvider> {
|
||||
Arc::new(SourceCredentialProvider { source })
|
||||
}
|
||||
|
||||
struct SourceCredentialProvider {
|
||||
source: Arc<dyn CredentialSource>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CredentialProvider for SourceCredentialProvider {
|
||||
async fn credentials(
|
||||
&self,
|
||||
provider: &CatalogProvider,
|
||||
) -> Result<Credentials, CredentialError> {
|
||||
self.source.credentials(provider).await.map_err(|err| {
|
||||
tracing::warn!(
|
||||
provider = %provider.id(),
|
||||
error = %err,
|
||||
"LLM credentials could not be resolved for this attempt"
|
||||
);
|
||||
match err {
|
||||
ResolveError::NotConfigured(provider) => {
|
||||
CredentialError::NotConfigured { provider }
|
||||
}
|
||||
ResolveError::SchemeMismatch { provider, .. } => {
|
||||
CredentialError::SchemeMismatch { provider }
|
||||
}
|
||||
other => CredentialError::NotConfigured {
|
||||
provider: other.provider().clone(),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,23 +2,21 @@ use std::collections::HashMap;
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_vault::Vault;
|
||||
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::Credentials;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
use crate::resolve::apply_openai_codex_api_context;
|
||||
use crate::{CredentialSource, EnvLookup, ResolvedCredentials, VaultCredentialSource};
|
||||
use crate::{CredentialSource, EnvLookup, ResolveError, VaultCredentialSource};
|
||||
|
||||
/// A credential source for provider credentials declared as `env:<NAME>`.
|
||||
///
|
||||
/// This public SDK facade does not resolve `{{ env.NAME }}` settings
|
||||
/// interpolation. Provider extra headers can use literals, but secret
|
||||
/// interpolation requires a vault-backed source.
|
||||
/// This public SDK facade does not resolve `{{ secrets.NAME }}` header
|
||||
/// interpolation, so providers whose headers come from the vault stay
|
||||
/// unconfigured here.
|
||||
#[derive(Clone)]
|
||||
pub struct EnvCredentialSource {
|
||||
inner: VaultCredentialSource,
|
||||
env_lookup: EnvLookup,
|
||||
inner: VaultCredentialSource,
|
||||
}
|
||||
|
||||
impl EnvCredentialSource {
|
||||
|
|
@ -34,13 +32,8 @@ impl EnvCredentialSource {
|
|||
#[must_use]
|
||||
pub fn with_env_lookup(env_lookup: EnvLookup) -> Self {
|
||||
let vault = Arc::new(AsyncRwLock::new(Vault::from_entries(HashMap::new())));
|
||||
let inner_lookup = Arc::clone(&env_lookup);
|
||||
let inner = VaultCredentialSource::with_env_lookup(vault, move |name| inner_lookup(name));
|
||||
Self { inner, env_lookup }
|
||||
}
|
||||
|
||||
fn lookup(&self, name: &str) -> Option<String> {
|
||||
(self.env_lookup)(name)
|
||||
let inner = VaultCredentialSource::with_env_lookup(vault, move |name| env_lookup(name));
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,18 +52,8 @@ impl Default for EnvCredentialSource {
|
|||
|
||||
#[async_trait]
|
||||
impl CredentialSource for EnvCredentialSource {
|
||||
async fn resolve(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
|
||||
let mut resolved = self.inner.resolve(catalog).await?;
|
||||
if let (Some(account_id), Some(credential)) = (
|
||||
self.lookup(EnvVars::CHATGPT_ACCOUNT_ID),
|
||||
resolved
|
||||
.credentials
|
||||
.iter_mut()
|
||||
.find(|credential| credential.provider == ProviderId::openai()),
|
||||
) {
|
||||
apply_openai_codex_api_context(credential, Some(&account_id), self.env_lookup.as_ref());
|
||||
}
|
||||
Ok(resolved)
|
||||
async fn credentials(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
|
||||
self.inner.credentials(provider).await
|
||||
}
|
||||
|
||||
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
|
|
@ -83,12 +66,11 @@ mod tests {
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_types::settings::interp::Namespace;
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
|
||||
use super::EnvCredentialSource;
|
||||
use crate::CredentialSource;
|
||||
use crate::test_support::test_catalog;
|
||||
|
||||
fn test_source(entries: &[(&str, &str)]) -> EnvCredentialSource {
|
||||
let entries: HashMap<String, String> = entries
|
||||
|
|
@ -101,111 +83,24 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn configured_providers_reads_injected_provider_env() {
|
||||
let source = test_source(&[("ANTHROPIC_API_KEY", "anthropic-key")]);
|
||||
let catalog = Catalog::from_builtin().unwrap();
|
||||
|
||||
assert_eq!(source.configured_providers(&catalog).await, vec![
|
||||
ProviderId::anthropic()
|
||||
assert_eq!(source.configured_providers(&test_catalog()).await, vec![
|
||||
ProviderId::new("anthropic")
|
||||
]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_builds_openai_codex_env_credential() {
|
||||
let source = test_source(&[
|
||||
("OPENAI_API_KEY", "openai-key"),
|
||||
("CHATGPT_ACCOUNT_ID", "acct_123"),
|
||||
("OPENAI_PROJECT_ID", "project_123"),
|
||||
]);
|
||||
let catalog = Catalog::from_builtin().unwrap();
|
||||
|
||||
let resolved = source.resolve(&catalog).await.unwrap();
|
||||
let credential = resolved.credentials.first().unwrap();
|
||||
|
||||
assert_eq!(credential.provider, ProviderId::openai());
|
||||
assert!(credential.codex_mode);
|
||||
assert_eq!(
|
||||
credential.base_url.as_deref(),
|
||||
Some("https://chatgpt.com/backend-api/codex")
|
||||
);
|
||||
assert_eq!(
|
||||
credential.extra_headers.get("ChatGPT-Account-Id"),
|
||||
Some(&"acct_123".to_string())
|
||||
);
|
||||
assert_eq!(credential.project_id.as_deref(), Some("project_123"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn env_settings_interpolation_remains_unsupported() {
|
||||
let settings: LlmCatalogSettings = toml::from_str(
|
||||
r#"
|
||||
[providers.acme]
|
||||
display_name = "Acme"
|
||||
adapter = "openai_compatible"
|
||||
agent_profile = "openai"
|
||||
base_url = "https://api.acme.test/v1"
|
||||
|
||||
[providers.acme.auth]
|
||||
credentials = ["env:ACME_API_KEY"]
|
||||
|
||||
[providers.acme.extra_headers]
|
||||
x-account = "{{ env.ACME_ACCOUNT }}"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
|
||||
let source = test_source(&[("ACME_API_KEY", "acme-key"), ("ACME_ACCOUNT", "account-id")]);
|
||||
|
||||
let resolved = source.resolve(&catalog).await.unwrap();
|
||||
|
||||
assert!(
|
||||
resolved
|
||||
.credentials
|
||||
.iter()
|
||||
.all(|credential| credential.provider != ProviderId::new("acme"))
|
||||
);
|
||||
assert!(resolved.auth_issues.iter().any(|(provider, issue)| {
|
||||
provider == &ProviderId::new("acme")
|
||||
&& matches!(
|
||||
issue,
|
||||
crate::ResolveError::Interpolation { source, .. }
|
||||
if source.namespace == Namespace::Env
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn modal_env_vars_do_not_replace_vault_secrets() {
|
||||
let settings: LlmCatalogSettings = toml::from_str(
|
||||
r#"
|
||||
[providers.modal]
|
||||
enabled = true
|
||||
base_url = "https://example--kimi-k3.modal.run/v1"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
|
||||
let source = test_source(&[
|
||||
("MODAL_TOKEN_ID", "wk-test"),
|
||||
("MODAL_TOKEN_SECRET", "ws-test"),
|
||||
]);
|
||||
let catalog = test_catalog();
|
||||
let modal = ProviderId::new("modal");
|
||||
|
||||
assert!(!source.configured_providers(&catalog).await.contains(&modal));
|
||||
|
||||
let resolved = source.resolve(&catalog).await.unwrap();
|
||||
|
||||
assert!(
|
||||
resolved
|
||||
.credentials
|
||||
.iter()
|
||||
.all(|credential| credential.provider != modal)
|
||||
);
|
||||
assert!(resolved.auth_issues.iter().any(|(provider, issue)| {
|
||||
provider == &modal
|
||||
&& matches!(
|
||||
issue,
|
||||
crate::ResolveError::Interpolation { source, .. }
|
||||
if source.namespace == Namespace::Secrets
|
||||
)
|
||||
}));
|
||||
let err = source
|
||||
.credentials(catalog.provider("modal").unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, crate::ResolveError::Interpolation { .. }));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,18 @@ use std::collections::HashMap;
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::{CredentialHeader, Credentials, SecretValue};
|
||||
|
||||
use crate::credential_source::{CredentialSource, ResolvedCredentials};
|
||||
use crate::ResolveError;
|
||||
use crate::credential_source::CredentialSource;
|
||||
|
||||
/// Decorates another [`CredentialSource`] by appending fixed extra headers to
|
||||
/// every credential it resolves.
|
||||
/// every HTTP credential it resolves.
|
||||
///
|
||||
/// Headers already present on a credential (for example from explicit
|
||||
/// provider configuration) are left untouched.
|
||||
/// provider configuration) are left untouched. AWS-signed credentials carry
|
||||
/// no header list and pass through unchanged.
|
||||
pub struct ExtraHeadersCredentialSource {
|
||||
inner: Arc<dyn CredentialSource>,
|
||||
headers: HashMap<String, String>,
|
||||
|
|
@ -25,21 +28,24 @@ impl ExtraHeadersCredentialSource {
|
|||
|
||||
#[async_trait]
|
||||
impl CredentialSource for ExtraHeadersCredentialSource {
|
||||
async fn resolve(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
|
||||
let mut resolved = self.inner.resolve(catalog).await?;
|
||||
for credential in &mut resolved.credentials {
|
||||
async fn credentials(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
|
||||
let mut credentials = self.inner.credentials(provider).await?;
|
||||
if let Credentials::Http(http) = &mut credentials {
|
||||
for (name, value) in &self.headers {
|
||||
if credential
|
||||
if http
|
||||
.extra_headers
|
||||
.keys()
|
||||
.any(|existing| existing.eq_ignore_ascii_case(name))
|
||||
.iter()
|
||||
.any(|existing| existing.name.eq_ignore_ascii_case(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
credential.extra_headers.insert(name.clone(), value.clone());
|
||||
http.extra_headers.push(CredentialHeader::new(
|
||||
name.clone(),
|
||||
SecretValue::new(value.clone()),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(resolved)
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
|
|
@ -49,31 +55,35 @@ impl CredentialSource for ExtraHeadersCredentialSource {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lithos_llm::credentials::HttpAuthentication;
|
||||
|
||||
use super::*;
|
||||
use crate::{ApiCredential, ResolveError};
|
||||
use crate::test_support::test_catalog;
|
||||
|
||||
struct StubSource {
|
||||
credentials: Vec<ApiCredential>,
|
||||
auth_issue_provider: Option<ProviderId>,
|
||||
configured_providers: Vec<ProviderId>,
|
||||
existing_header: Option<(String, String)>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CredentialSource for StubSource {
|
||||
async fn resolve(&self, _catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
|
||||
Ok(ResolvedCredentials {
|
||||
credentials: self.credentials.clone(),
|
||||
auth_issues: self
|
||||
.auth_issue_provider
|
||||
.iter()
|
||||
.map(|provider| {
|
||||
(
|
||||
provider.clone(),
|
||||
ResolveError::RefreshTokenMissing(provider.clone()),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
async fn credentials(
|
||||
&self,
|
||||
provider: &CatalogProvider,
|
||||
) -> Result<Credentials, ResolveError> {
|
||||
if provider.id().as_str() == "bedrock" {
|
||||
return Ok(Credentials::AwsDefaultChain { region: None });
|
||||
}
|
||||
let mut credentials = Credentials::bearer(SecretValue::new("key"));
|
||||
if let (Credentials::Http(http), Some((name, value))) =
|
||||
(&mut credentials, &self.existing_header)
|
||||
{
|
||||
http.extra_headers.push(CredentialHeader::new(
|
||||
name.clone(),
|
||||
SecretValue::new(value.clone()),
|
||||
));
|
||||
}
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
async fn configured_providers(&self, _catalog: &Catalog) -> Vec<ProviderId> {
|
||||
|
|
@ -81,95 +91,60 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn credential(provider: ProviderId, extra_headers: HashMap<String, String>) -> ApiCredential {
|
||||
ApiCredential {
|
||||
provider,
|
||||
auth_header: None,
|
||||
extra_headers,
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn appends_headers_to_every_resolved_credential() {
|
||||
let source = ExtraHeadersCredentialSource::new(
|
||||
Arc::new(StubSource {
|
||||
credentials: vec![
|
||||
credential(ProviderId::anthropic(), HashMap::new()),
|
||||
credential(ProviderId::openai(), HashMap::new()),
|
||||
],
|
||||
auth_issue_provider: None,
|
||||
configured_providers: Vec::new(),
|
||||
}),
|
||||
HashMap::from([("x-session-id".to_string(), "run-123".to_string())]),
|
||||
);
|
||||
|
||||
let resolved = source.resolve(Catalog::builtin()).await.unwrap();
|
||||
|
||||
assert_eq!(resolved.credentials.len(), 2);
|
||||
for credential in &resolved.credentials {
|
||||
assert_eq!(
|
||||
credential
|
||||
.extra_headers
|
||||
.get("x-session-id")
|
||||
.map(String::as_str),
|
||||
Some("run-123")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preserves_case_insensitive_headers_already_set_on_a_credential() {
|
||||
let source = ExtraHeadersCredentialSource::new(
|
||||
Arc::new(StubSource {
|
||||
credentials: vec![credential(
|
||||
ProviderId::new("openrouter"),
|
||||
HashMap::from([("X-Session-Id".to_string(), "configured".to_string())]),
|
||||
)],
|
||||
auth_issue_provider: None,
|
||||
configured_providers: Vec::new(),
|
||||
}),
|
||||
HashMap::from([("x-session-id".to_string(), "run-123".to_string())]),
|
||||
);
|
||||
|
||||
let resolved = source.resolve(Catalog::builtin()).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.credentials[0]
|
||||
fn header<'a>(credentials: &'a Credentials, name: &str) -> Option<&'a str> {
|
||||
match credentials {
|
||||
Credentials::Http(http) => http
|
||||
.extra_headers
|
||||
.get("X-Session-Id")
|
||||
.map(String::as_str),
|
||||
Some("configured")
|
||||
);
|
||||
assert_eq!(resolved.credentials[0].extra_headers.len(), 1);
|
||||
.iter()
|
||||
.find(|header| header.name.eq_ignore_ascii_case(name))
|
||||
.map(|header| header.value.expose_secret()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passes_through_auth_issues_and_configured_providers() {
|
||||
let auth_issue_provider = ProviderId::anthropic();
|
||||
let configured_provider = ProviderId::gemini();
|
||||
async fn appends_headers_to_http_credentials_only() {
|
||||
let catalog = test_catalog();
|
||||
let source = ExtraHeadersCredentialSource::new(
|
||||
Arc::new(StubSource {
|
||||
credentials: vec![credential(ProviderId::openai(), HashMap::new())],
|
||||
auth_issue_provider: Some(auth_issue_provider.clone()),
|
||||
configured_providers: vec![configured_provider.clone()],
|
||||
configured_providers: Vec::new(),
|
||||
existing_header: None,
|
||||
}),
|
||||
HashMap::from([("x-session-id".to_string(), "run-123".to_string())]),
|
||||
);
|
||||
let openai = source
|
||||
.credentials(catalog.provider("openai").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
&openai,
|
||||
Credentials::Http(http) if matches!(http.auth, HttpAuthentication::Bearer(_))
|
||||
));
|
||||
assert_eq!(header(&openai, "x-session-id"), Some("run-123"));
|
||||
let bedrock = source
|
||||
.credentials(catalog.provider("bedrock").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(bedrock, Credentials::AwsDefaultChain { .. }));
|
||||
}
|
||||
|
||||
let resolved = source.resolve(Catalog::builtin()).await.unwrap();
|
||||
let [(reported_provider, ResolveError::RefreshTokenMissing(error_provider))] =
|
||||
resolved.auth_issues.as_slice()
|
||||
else {
|
||||
panic!("expected the inner source's refresh-token issue");
|
||||
};
|
||||
assert_eq!(reported_provider, &auth_issue_provider);
|
||||
assert_eq!(error_provider, &auth_issue_provider);
|
||||
|
||||
let providers = source.configured_providers(Catalog::builtin()).await;
|
||||
assert_eq!(providers, vec![configured_provider]);
|
||||
#[tokio::test]
|
||||
async fn preserves_case_insensitive_headers_already_set() {
|
||||
let catalog = test_catalog();
|
||||
let source = ExtraHeadersCredentialSource::new(
|
||||
Arc::new(StubSource {
|
||||
configured_providers: vec![ProviderId::new("openai")],
|
||||
existing_header: Some(("X-Session-Id".to_string(), "configured".to_string())),
|
||||
}),
|
||||
HashMap::from([("x-session-id".to_string(), "run-123".to_string())]),
|
||||
);
|
||||
let credentials = source
|
||||
.credentials(catalog.provider("openai").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(header(&credentials, "x-session-id"), Some("configured"));
|
||||
assert_eq!(source.configured_providers(&catalog).await, vec![
|
||||
ProviderId::new("openai")
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
mod api_key_source;
|
||||
mod context;
|
||||
mod credential;
|
||||
mod credential_ref;
|
||||
mod credential_source;
|
||||
mod env_source;
|
||||
mod extra_headers_source;
|
||||
|
|
@ -14,15 +16,17 @@ mod vault_source;
|
|||
|
||||
pub mod strategies;
|
||||
|
||||
pub use api_key_source::ApiKeyCredentialSource;
|
||||
pub use context::{AuthContextRequest, AuthContextResponse};
|
||||
pub use credential::{ApiKeyHeader, OAuthConfig, OAuthCredential, OAuthTokens};
|
||||
pub use credential_source::{CredentialSource, ResolvedCredentials};
|
||||
pub use credential::{OAuthConfig, OAuthCredential, OAuthTokens};
|
||||
pub use credential_ref::{CredentialRef, CredentialRefParseError};
|
||||
pub use credential_source::{CredentialSource, ResolvedCredentials, lithos_credentials};
|
||||
pub use env_source::EnvCredentialSource;
|
||||
pub use extra_headers_source::ExtraHeadersCredentialSource;
|
||||
pub use refresh::refresh_oauth_credential;
|
||||
pub use resolve::{
|
||||
ApiCredential, CredentialResolver, CredentialUsage, EnvLookup, ResolveError,
|
||||
ResolvedCredential, auth_issue_message, build_api_key_header,
|
||||
CredentialResolver, EnvLookup, ResolveError, accepts_api_key, auth_issue_message,
|
||||
credential_refs, credentials_for_api_key, env_var_names, expected_vault_secret_name,
|
||||
};
|
||||
pub use sql_vault_source::SqlVaultCredentialSource;
|
||||
pub use strategy::{
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +1,21 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_types::SecretType;
|
||||
use fabro_vault::{SecretSnapshot, SecretStore, SecretStoreError, Vault};
|
||||
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::Credentials;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::error;
|
||||
|
||||
use crate::credential_source::{CredentialSource, ResolvedCredentials};
|
||||
use crate::{EnvLookup, VaultCredentialSource};
|
||||
use crate::credential_source::CredentialSource;
|
||||
use crate::{EnvLookup, ResolveError, VaultCredentialSource};
|
||||
|
||||
/// Credentials backed by the SQL secret store.
|
||||
///
|
||||
/// Every lookup snapshots the store, resolves against the snapshot, and
|
||||
/// writes refreshed OAuth tokens back with a revision check so two concurrent
|
||||
/// refreshes cannot clobber each other.
|
||||
#[derive(Clone)]
|
||||
pub struct SqlVaultCredentialSource {
|
||||
store: Arc<SecretStore>,
|
||||
|
|
@ -83,6 +89,13 @@ impl SqlVaultCredentialSource {
|
|||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn store_error(provider: &ProviderId, err: SecretStoreError) -> ResolveError {
|
||||
ResolveError::RefreshFailed {
|
||||
provider: provider.clone(),
|
||||
source: anyhow::Error::new(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SqlVaultCredentialSource {
|
||||
|
|
@ -94,9 +107,13 @@ impl std::fmt::Debug for SqlVaultCredentialSource {
|
|||
|
||||
#[async_trait]
|
||||
impl CredentialSource for SqlVaultCredentialSource {
|
||||
async fn resolve(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
|
||||
async fn credentials(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
|
||||
for _ in 0..2 {
|
||||
let before = self.store.snapshot().await?;
|
||||
let before = self
|
||||
.store
|
||||
.snapshot()
|
||||
.await
|
||||
.map_err(|err| Self::store_error(provider.id(), err))?;
|
||||
let has_oauth = before
|
||||
.entries()
|
||||
.values()
|
||||
|
|
@ -104,16 +121,23 @@ impl CredentialSource for SqlVaultCredentialSource {
|
|||
if !has_oauth {
|
||||
// Only OAuth resolution can write back (token refresh); with no
|
||||
// OAuth secrets, skip the snapshot clones and CAS machinery.
|
||||
return self.source_for_snapshot(before).resolve(catalog).await;
|
||||
return self.source_for_snapshot(before).credentials(provider).await;
|
||||
}
|
||||
let source = self.source_for_snapshot(before.clone());
|
||||
let resolved = source.resolve(catalog).await?;
|
||||
let credentials = source.credentials(provider).await?;
|
||||
let after = source.snapshot().await;
|
||||
if self.persist_oauth_refreshes(&before, &after).await? {
|
||||
return Ok(resolved);
|
||||
if self
|
||||
.persist_oauth_refreshes(&before, &after)
|
||||
.await
|
||||
.map_err(|err| Self::store_error(provider.id(), err))?
|
||||
{
|
||||
return Ok(credentials);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("OAuth credential changed concurrently during refresh")
|
||||
Err(ResolveError::RefreshFailed {
|
||||
provider: provider.id().clone(),
|
||||
source: anyhow::anyhow!("OAuth credential changed concurrently during refresh"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use async_trait::async_trait;
|
||||
use fabro_model::catalog::CatalogProvider;
|
||||
use fabro_model::{CredentialRef, ProviderId};
|
||||
use fabro_types::catalog_policy;
|
||||
use lithos_llm::catalog::{CatalogProvider, ProviderId};
|
||||
|
||||
use crate::context::{AuthContextRequest, AuthContextResponse};
|
||||
use crate::strategy::{AuthStrategy, LoginResult};
|
||||
|
|
@ -15,24 +15,11 @@ pub struct ApiKeyStrategy {
|
|||
impl ApiKeyStrategy {
|
||||
#[must_use]
|
||||
pub fn new(provider: &CatalogProvider) -> Self {
|
||||
let env_var_names = provider
|
||||
.auth
|
||||
.as_ref()
|
||||
.map(|auth| {
|
||||
auth.credentials
|
||||
.iter()
|
||||
.filter_map(|credential_ref| match credential_ref {
|
||||
CredentialRef::Env(name) => Some(name.clone()),
|
||||
CredentialRef::Vault(_) | CredentialRef::AwsSigv4 => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
provider_id: provider.id.clone(),
|
||||
display_name: provider.display_name.clone(),
|
||||
env_var_names,
|
||||
api_key_url: provider.api_key_url.clone(),
|
||||
provider_id: provider.id().clone(),
|
||||
display_name: provider.display_name().to_string(),
|
||||
env_var_names: crate::env_var_names(provider),
|
||||
api_key_url: catalog_policy::provider_policy(provider).api_key_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use base64::Engine;
|
|||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_http::HttpClient;
|
||||
use fabro_types::provider_ids;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tokio::time::sleep;
|
||||
|
|
@ -298,7 +299,7 @@ impl AuthStrategy for CodexDeviceStrategy {
|
|||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
Ok(LoginResult::OAuth {
|
||||
provider: fabro_model::ProviderId::openai(),
|
||||
provider: provider_ids::openai(),
|
||||
credential: OAuthCredential {
|
||||
tokens: OAuthTokens {
|
||||
access_token: token_response.access_token,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use async_trait::async_trait;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_types::provider_ids;
|
||||
use lithos_llm::catalog::{Catalog, ProviderId};
|
||||
|
||||
use crate::context::{AuthContextRequest, AuthContextResponse};
|
||||
use crate::credential::{OAuthConfig, OAuthCredential};
|
||||
|
|
@ -60,7 +61,7 @@ pub fn strategy_for(
|
|||
match method {
|
||||
AuthMethod::ApiKey => {
|
||||
let provider = catalog
|
||||
.provider(provider_id)
|
||||
.provider(provider_id.as_str())
|
||||
.expect("API key auth requires a catalog provider");
|
||||
Box::new(ApiKeyStrategy::new(provider))
|
||||
}
|
||||
|
|
@ -73,7 +74,7 @@ pub fn strategy_for(
|
|||
// forgets the constraint.
|
||||
assert_eq!(
|
||||
provider_id.as_str(),
|
||||
ProviderId::OPENAI,
|
||||
provider_ids::OPENAI,
|
||||
"CodexDevice auth is only constructed by CLI code for the \
|
||||
OpenAI provider; all existing call sites enforce this pairing: \
|
||||
got provider_id={provider_id}"
|
||||
|
|
@ -87,6 +88,7 @@ pub fn strategy_for(
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::context::AuthContextRequest;
|
||||
use crate::test_support::test_catalog;
|
||||
|
||||
#[test]
|
||||
fn codex_oauth_config_has_expected_defaults() {
|
||||
|
|
@ -100,12 +102,12 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn api_key_strategy_uses_provider_env_names() {
|
||||
let catalog = Catalog::builtin();
|
||||
let provider = catalog.provider(&ProviderId::anthropic()).unwrap();
|
||||
let catalog = test_catalog();
|
||||
let provider = catalog.provider("anthropic").unwrap();
|
||||
let mut strategy = ApiKeyStrategy::new(provider);
|
||||
let request = strategy.init().await.unwrap();
|
||||
assert_eq!(request, AuthContextRequest::ApiKey {
|
||||
provider_id: ProviderId::anthropic(),
|
||||
provider_id: ProviderId::new("anthropic"),
|
||||
display_name: "Anthropic".to_string(),
|
||||
env_var_names: vec!["ANTHROPIC_API_KEY".to_string()],
|
||||
api_key_url: Some("https://console.anthropic.com/settings/keys".to_string()),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Test-only credential sources.
|
||||
//! Test-only credential sources and catalogs.
|
||||
//!
|
||||
//! Feature-gated so they never link into production builds. Production code
|
||||
//! resolves credentials through [`VaultCredentialSource`] over a real vault;
|
||||
|
|
@ -8,11 +8,28 @@ use std::collections::HashMap;
|
|||
use std::sync::Arc;
|
||||
|
||||
use fabro_vault::Vault;
|
||||
use lithos_llm::catalog::Catalog;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
use crate::credential_source::CredentialSource;
|
||||
use crate::vault_source::VaultCredentialSource;
|
||||
|
||||
/// Fabro's policy layer, checked in under `fabro-llm`. Tests in this crate
|
||||
/// need the built-in catalog with `metadata.fabro.credentials` attached.
|
||||
pub const FABRO_POLICY_TOML: &str =
|
||||
include_str!("../../../components/fabro-llm/catalog/fabro-policy.toml");
|
||||
|
||||
/// The lithos built-in catalog with Fabro's policy layer applied.
|
||||
#[must_use]
|
||||
pub fn test_catalog() -> Catalog {
|
||||
Catalog::builder()
|
||||
.with_builtin()
|
||||
.toml_layer("fabro-policy.toml", FABRO_POLICY_TOML)
|
||||
.expect("fabro policy layer should parse")
|
||||
.build()
|
||||
.expect("built-in catalog with fabro policy should build")
|
||||
}
|
||||
|
||||
/// A detached in-memory vault holding no secrets.
|
||||
#[must_use]
|
||||
pub fn empty_vault() -> Arc<AsyncRwLock<Vault>> {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_vault::Vault;
|
||||
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::Credentials;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
use crate::credential_source::{CredentialSource, ResolvedCredentials};
|
||||
use crate::{CredentialResolver, CredentialUsage, EnvLookup, ResolveError, ResolvedCredential};
|
||||
use crate::credential_source::CredentialSource;
|
||||
use crate::{CredentialResolver, EnvLookup, ResolveError};
|
||||
|
||||
/// Credentials backed by an in-memory [`Vault`] plus an environment lookup.
|
||||
#[derive(Clone)]
|
||||
pub struct VaultCredentialSource {
|
||||
vault: Arc<AsyncRwLock<Vault>>,
|
||||
|
|
@ -50,26 +52,8 @@ impl std::fmt::Debug for VaultCredentialSource {
|
|||
|
||||
#[async_trait]
|
||||
impl CredentialSource for VaultCredentialSource {
|
||||
async fn resolve(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
|
||||
let mut credentials = Vec::new();
|
||||
let mut auth_issues = Vec::new();
|
||||
|
||||
for provider in catalog.providers() {
|
||||
match self
|
||||
.resolver
|
||||
.resolve(provider.id.clone(), CredentialUsage::ApiRequest, catalog)
|
||||
.await
|
||||
{
|
||||
Ok(ResolvedCredential::Api(credential)) => credentials.push(credential),
|
||||
Err(ResolveError::NotConfigured(_)) if provider.auth.is_some() => {}
|
||||
Err(err) => auth_issues.push((provider.id.clone(), err)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ResolvedCredentials {
|
||||
credentials,
|
||||
auth_issues,
|
||||
})
|
||||
async fn credentials(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
|
||||
self.resolver.resolve(provider).await
|
||||
}
|
||||
|
||||
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
|
|
@ -80,15 +64,17 @@ impl CredentialSource for VaultCredentialSource {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_vault::Vault;
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
use super::VaultCredentialSource;
|
||||
use crate::credential::{OAuthConfig, OAuthCredential, OAuthTokens};
|
||||
use crate::test_support::test_catalog;
|
||||
use crate::vault_ext::{vault_set_oauth, vault_set_token};
|
||||
use crate::{CredentialSource, ResolveError};
|
||||
|
||||
|
|
@ -111,14 +97,9 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn default_catalog() -> Catalog {
|
||||
Catalog::from_builtin().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_returns_credentials_and_auth_issues() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
async fn resolve_all_separates_ready_providers_from_auth_issues() {
|
||||
let mut vault = Vault::from_entries(HashMap::new());
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
|
|
@ -129,63 +110,50 @@ mod tests {
|
|||
|
||||
let source =
|
||||
VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None);
|
||||
let catalog = default_catalog();
|
||||
let catalog = test_catalog();
|
||||
|
||||
let resolved = source.resolve(&catalog).await.unwrap();
|
||||
let resolved = source.resolve_all(&catalog).await;
|
||||
|
||||
assert_eq!(resolved.credentials.len(), 1);
|
||||
assert_eq!(resolved.credentials[0].provider, ProviderId::anthropic());
|
||||
assert_eq!(resolved.ready, vec![ProviderId::new("anthropic")]);
|
||||
assert_eq!(resolved.auth_issues.len(), 1);
|
||||
assert!(matches!(
|
||||
&resolved.auth_issues[0].1,
|
||||
ResolveError::RefreshFailed {
|
||||
provider,
|
||||
..
|
||||
} if provider == &ProviderId::openai()
|
||||
ResolveError::RefreshFailed { provider, .. } if provider.as_str() == "openai-codex"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_providers_reads_from_vault_without_refreshing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
let mut vault = Vault::from_entries(HashMap::new());
|
||||
vault_set_token(&mut vault, "OPENAI_API_KEY", "openai-key").unwrap();
|
||||
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
|
||||
let source =
|
||||
VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None);
|
||||
let catalog = default_catalog();
|
||||
let catalog = test_catalog();
|
||||
|
||||
assert_eq!(source.configured_providers(&catalog).await, vec![
|
||||
ProviderId::anthropic(),
|
||||
ProviderId::openai()
|
||||
ProviderId::new("anthropic"),
|
||||
ProviderId::new("openai")
|
||||
]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vault_only_ignores_env_lookup_values() {
|
||||
let env_dir = tempfile::tempdir().unwrap();
|
||||
let vault_only_dir = tempfile::tempdir().unwrap();
|
||||
let catalog = default_catalog();
|
||||
let catalog = test_catalog();
|
||||
let env_backed = VaultCredentialSource::with_env_lookup(
|
||||
Arc::new(AsyncRwLock::new(
|
||||
Vault::load(env_dir.path().join("secrets.json")).unwrap(),
|
||||
)),
|
||||
Arc::new(AsyncRwLock::new(Vault::from_entries(HashMap::new()))),
|
||||
|name| (name == "OPENAI_API_KEY").then(|| "env-openai-key".to_string()),
|
||||
);
|
||||
assert_eq!(env_backed.configured_providers(&catalog).await, vec![
|
||||
ProviderId::openai()
|
||||
ProviderId::new("openai")
|
||||
]);
|
||||
|
||||
let vault_only = VaultCredentialSource::vault_only(Arc::new(AsyncRwLock::new(
|
||||
Vault::load(vault_only_dir.path().join("secrets.json")).unwrap(),
|
||||
Vault::from_entries(HashMap::new()),
|
||||
)));
|
||||
|
||||
assert!(
|
||||
vault_only.configured_providers(&catalog).await.is_empty(),
|
||||
"vault_only must not resolve env-backed provider keys"
|
||||
);
|
||||
let resolved = vault_only.resolve(&catalog).await.unwrap();
|
||||
assert!(resolved.credentials.is_empty());
|
||||
assert!(vault_only.configured_providers(&catalog).await.is_empty());
|
||||
let resolved = vault_only.resolve_all(&catalog).await;
|
||||
assert!(resolved.ready.is_empty());
|
||||
assert!(resolved.auth_issues.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ anyhow.workspace = true
|
|||
clap = { workspace = true, optional = true }
|
||||
chrono.workspace = true
|
||||
fabro-macros = { path = "../fabro-macros" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-options-metadata.workspace = true
|
||||
fabro-proc = { path = "../fabro-proc" }
|
||||
fabro-static.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
|
||||
use fabro_model::catalog as model_catalog;
|
||||
use fabro_types::settings::run::McpServerSettings;
|
||||
use fabro_types::settings::{RunNamespace, WorkflowNamespace};
|
||||
use fabro_types::{ServerSettings, UserSettings, WorkflowSettings};
|
||||
|
|
@ -16,9 +15,8 @@ use crate::resolve::{
|
|||
};
|
||||
use crate::user::load_settings_config;
|
||||
use crate::{
|
||||
CliLayer, Combine, CostRates, EnvironmentLayer, Error, LlmLayer, LlmModelFeatures,
|
||||
LlmModelLimits, MergeMap, ModelControls, ModelCostTable, ModelSettings, ProviderSettings,
|
||||
Result, RunLayer, ServerLayer, SettingsLayer, run,
|
||||
CliLayer, Combine, EnvironmentLayer, Error, LlmLayer, MergeMap, Result, RunLayer, ServerLayer,
|
||||
SettingsLayer, run,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -231,7 +229,8 @@ pub struct ServerRuntimeSettings {
|
|||
pub manifest_run_defaults: RunLayer,
|
||||
pub manifest_environment_defaults: crate::MergeMap<crate::EnvironmentLayer>,
|
||||
pub manifest_run_settings: std::result::Result<RunNamespace, SharedError>,
|
||||
pub llm_catalog_settings: model_catalog::LlmCatalogSettings,
|
||||
/// Operator catalog overlay, applied above the built-in and policy layers.
|
||||
pub llm_overlay: LlmLayer,
|
||||
}
|
||||
|
||||
pub fn load_server_runtime_settings(
|
||||
|
|
@ -246,12 +245,12 @@ pub fn load_server_runtime_settings(
|
|||
resolve_server_runtime_settings(layer, run_overrides, server_overrides)
|
||||
}
|
||||
|
||||
pub fn load_llm_catalog_settings(path: Option<&Path>) -> Result<model_catalog::LlmCatalogSettings> {
|
||||
pub fn load_llm_overlay(path: Option<&Path>) -> Result<LlmLayer> {
|
||||
let layer = match path {
|
||||
Some(path) => load_settings_path(path, SettingsSource::ActiveSettings)?,
|
||||
None => load_settings_config(None)?,
|
||||
};
|
||||
Ok(llm_catalog_settings_from_layer(&layer))
|
||||
Ok(llm_overlay_from_layer(&layer))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -286,7 +285,7 @@ fn resolve_server_runtime_settings(
|
|||
|
||||
let manifest_run_defaults = layer.run.clone().unwrap_or_default();
|
||||
let manifest_environment_defaults = layer.environments.clone();
|
||||
let llm_catalog_settings = llm_catalog_settings_from_layer(&layer);
|
||||
let llm_overlay = llm_overlay_from_layer(&layer);
|
||||
Ok(ServerRuntimeSettings {
|
||||
server_settings: ServerSettingsBuilder::from_layer(&layer)?,
|
||||
manifest_run_settings: RunSettingsBuilder::from_layer(&SettingsLayer {
|
||||
|
|
@ -297,162 +296,13 @@ fn resolve_server_runtime_settings(
|
|||
.map_err(|err| SharedError::new(anyhow::Error::new(err))),
|
||||
manifest_run_defaults,
|
||||
manifest_environment_defaults,
|
||||
llm_catalog_settings,
|
||||
llm_overlay,
|
||||
})
|
||||
}
|
||||
|
||||
fn llm_catalog_settings_from_layer(layer: &SettingsLayer) -> model_catalog::LlmCatalogSettings {
|
||||
fn llm_overlay_from_layer(layer: &SettingsLayer) -> LlmLayer {
|
||||
let layer = layer.clone().combine(DEFAULTS_LAYER.clone());
|
||||
layer
|
||||
.llm
|
||||
.map(llm_layer_to_catalog_settings)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn llm_layer_to_catalog_settings(llm: LlmLayer) -> model_catalog::LlmCatalogSettings {
|
||||
model_catalog::LlmCatalogSettings {
|
||||
providers: llm
|
||||
.providers
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(|(id, settings)| (id, provider_settings_to_catalog(settings)))
|
||||
.collect(),
|
||||
models: llm
|
||||
.models
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(|(id, settings)| (id, model_settings_to_catalog(settings)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_settings_to_catalog(
|
||||
settings: ProviderSettings,
|
||||
) -> model_catalog::ProviderCatalogSettings {
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "collapse the authoring InterpString header values to their catalog source \
|
||||
strings; they are re-parsed and resolved at the credential boundary"
|
||||
)]
|
||||
let extra_headers = settings.extra_headers.map(|headers| {
|
||||
headers
|
||||
.into_iter()
|
||||
.map(|(name, value)| (name, value.as_source()))
|
||||
.collect()
|
||||
});
|
||||
let models = settings
|
||||
.models
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(|(id, settings)| (id, model_settings_to_catalog(settings)))
|
||||
.collect();
|
||||
model_catalog::ProviderCatalogSettings {
|
||||
display_name: settings.display_name,
|
||||
adapter: settings.adapter,
|
||||
codec: settings.codec,
|
||||
agent_profile: settings.agent_profile,
|
||||
auth: settings.auth,
|
||||
billing_policy: settings.billing_policy,
|
||||
api_key_url: settings.api_key_url,
|
||||
base_url: settings.base_url,
|
||||
extra_headers,
|
||||
priority: settings.priority,
|
||||
enabled: settings.enabled,
|
||||
aliases: settings.aliases,
|
||||
models,
|
||||
}
|
||||
}
|
||||
|
||||
fn model_settings_to_catalog(settings: ModelSettings) -> model_catalog::ModelCatalogSettings {
|
||||
let ModelSettings {
|
||||
provider,
|
||||
api_id,
|
||||
codec,
|
||||
billing_policy,
|
||||
agent_profile,
|
||||
display_name,
|
||||
family,
|
||||
training,
|
||||
knowledge_cutoff,
|
||||
default,
|
||||
small_default,
|
||||
probe,
|
||||
enabled,
|
||||
aliases,
|
||||
estimated_output_tps,
|
||||
limits,
|
||||
features,
|
||||
controls,
|
||||
costs,
|
||||
} = settings;
|
||||
model_catalog::ModelCatalogSettings {
|
||||
provider,
|
||||
api_id,
|
||||
codec,
|
||||
billing_policy,
|
||||
agent_profile,
|
||||
display_name,
|
||||
family,
|
||||
training,
|
||||
knowledge_cutoff,
|
||||
default,
|
||||
small_default,
|
||||
probe,
|
||||
enabled,
|
||||
aliases,
|
||||
estimated_output_tps,
|
||||
limits: limits.as_ref().map(model_limits_to_catalog),
|
||||
features: features.as_ref().map(model_features_to_catalog),
|
||||
controls: controls.map(model_controls_to_catalog),
|
||||
costs: costs.as_ref().map(model_cost_table_to_catalog),
|
||||
}
|
||||
}
|
||||
|
||||
fn model_limits_to_catalog(limits: &LlmModelLimits) -> model_catalog::SettingsModelLimits {
|
||||
model_catalog::SettingsModelLimits {
|
||||
context_window: limits.context_window,
|
||||
max_output: limits.max_output,
|
||||
}
|
||||
}
|
||||
|
||||
fn model_features_to_catalog(features: &LlmModelFeatures) -> model_catalog::SettingsModelFeatures {
|
||||
model_catalog::SettingsModelFeatures {
|
||||
tools: features.tools,
|
||||
vision: features.vision,
|
||||
reasoning: features.reasoning,
|
||||
reasoning_by_default: features.reasoning_by_default,
|
||||
reasoning_effort: features.reasoning_effort,
|
||||
prompt_cache: features.prompt_cache,
|
||||
cache_control_breakpoints: features.cache_control_breakpoints,
|
||||
sampling_params: features.sampling_params,
|
||||
}
|
||||
}
|
||||
|
||||
fn model_controls_to_catalog(controls: ModelControls) -> model_catalog::SettingsModelControls {
|
||||
model_catalog::SettingsModelControls {
|
||||
reasoning_effort: controls.reasoning_effort,
|
||||
speed: controls.speed,
|
||||
}
|
||||
}
|
||||
|
||||
fn model_cost_table_to_catalog(costs: &ModelCostTable) -> model_catalog::SettingsModelCostTable {
|
||||
model_catalog::SettingsModelCostTable {
|
||||
base: cost_rates_to_catalog(&costs.base),
|
||||
speed: costs.speed.as_ref().map(|speed| {
|
||||
speed
|
||||
.iter()
|
||||
.map(|(key, rates)| (key.clone(), cost_rates_to_catalog(rates)))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_rates_to_catalog(rates: &CostRates) -> model_catalog::CostRates {
|
||||
model_catalog::CostRates {
|
||||
input_cost_per_mtok: rates.input_cost_per_mtok,
|
||||
output_cost_per_mtok: rates.output_cost_per_mtok,
|
||||
cache_input_cost_per_mtok: rates.cache_input_cost_per_mtok,
|
||||
}
|
||||
layer.llm.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parse_settings_toml(source: &str, kind: SettingsSource) -> Result<SettingsLayer> {
|
||||
|
|
@ -829,7 +679,7 @@ provider = "docker"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn server_runtime_settings_preserves_llm_catalog_overrides() {
|
||||
fn server_runtime_settings_preserves_llm_overlay() {
|
||||
let settings = server_runtime_settings_from_toml(
|
||||
r#"
|
||||
_version = 1
|
||||
|
|
@ -839,92 +689,31 @@ methods = ["dev-token"]
|
|||
|
||||
[llm.providers.acme]
|
||||
display_name = "Acme"
|
||||
adapter = "openai_compatible"
|
||||
adapter = "openai-compatible"
|
||||
codec = "openai-chat"
|
||||
base_url = "https://api.acme.test/v1"
|
||||
agent_profile = "anthropic"
|
||||
auth = { type = "bearer" }
|
||||
|
||||
[llm.providers.acme.auth]
|
||||
[llm.providers.acme.metadata.fabro]
|
||||
enabled = true
|
||||
credentials = ["env:ACME_API_KEY"]
|
||||
|
||||
[llm.models."acme-large"]
|
||||
provider = "acme"
|
||||
[llm.providers.acme.models."acme-large"]
|
||||
display_name = "Acme Large"
|
||||
family = "acme"
|
||||
default = true
|
||||
agent_profile = "gemini"
|
||||
|
||||
[llm.models."acme-large".limits]
|
||||
context_window = 128000
|
||||
|
||||
[llm.models."acme-large".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
api_model = "acme-large"
|
||||
"#,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("server runtime settings should resolve");
|
||||
|
||||
let catalog =
|
||||
fabro_model::Catalog::from_builtin_with_overrides(&settings.llm_catalog_settings)
|
||||
.expect("catalog overrides should build");
|
||||
|
||||
let overlay = settings.llm_overlay.0;
|
||||
let acme = &overlay["providers"]["acme"];
|
||||
assert_eq!(acme["display_name"].as_str(), Some("Acme"));
|
||||
assert_eq!(acme["metadata"]["fabro"]["enabled"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
catalog
|
||||
.get_on_provider(&fabro_model::ProviderId::new("acme"), "acme-large")
|
||||
.map(|model| model.provider.clone()),
|
||||
Some(fabro_model::ProviderId::new("acme"))
|
||||
);
|
||||
assert_eq!(
|
||||
catalog
|
||||
.effective_agent_profile(&fabro_model::ProviderId::new("acme"), Some("acme-large")),
|
||||
Some(fabro_model::AgentProfileKind::Gemini)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_runtime_settings_preserves_extra_header_sources() {
|
||||
let settings = server_runtime_settings_from_toml(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.auth]
|
||||
methods = ["dev-token"]
|
||||
|
||||
[llm.providers.acme]
|
||||
display_name = "Acme"
|
||||
adapter = "openai_compatible"
|
||||
base_url = "https://api.acme.test/v1"
|
||||
|
||||
[llm.providers.acme.extra_headers]
|
||||
x-title = "My App"
|
||||
x-api-key = "{{ env.ACME_GATEWAY_API_KEY }}"
|
||||
x-team-secret = "Bearer {{ secrets.ACME_GATEWAY_TOKEN }}"
|
||||
"#,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("server runtime settings should resolve");
|
||||
|
||||
let provider = settings
|
||||
.llm_catalog_settings
|
||||
.providers
|
||||
.get("acme")
|
||||
.expect("provider settings should be present");
|
||||
let headers = provider
|
||||
.extra_headers
|
||||
.as_ref()
|
||||
.expect("extra header settings should be present");
|
||||
|
||||
assert_eq!(headers.get("x-title").map(String::as_str), Some("My App"));
|
||||
assert_eq!(
|
||||
headers.get("x-api-key").map(String::as_str),
|
||||
Some("{{ env.ACME_GATEWAY_API_KEY }}")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-team-secret").map(String::as_str),
|
||||
Some("Bearer {{ secrets.ACME_GATEWAY_TOKEN }}")
|
||||
acme["models"]["acme-large"]["api_model"].as_str(),
|
||||
Some("acme-large")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use fabro_model::{AgentProfileKind, BillingPolicy, CodecKind, ProviderAuthConfig};
|
||||
use fabro_types::PermissionLevel;
|
||||
use fabro_types::settings::cli::{CliAuthStrategy, OutputFormat, OutputVerbosity};
|
||||
use fabro_types::settings::run::{
|
||||
|
|
@ -15,7 +14,6 @@ use fabro_types::settings::{Duration, InterpString, Size};
|
|||
use super::LogFilter;
|
||||
use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer};
|
||||
use super::environment::EnvironmentDockerfileLayer;
|
||||
use super::llm::{CostRates, CredentialRef, ReasoningEffortFeature};
|
||||
use super::run::{
|
||||
HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, ModelRefOrSplice,
|
||||
NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer, RunGoalLayer,
|
||||
|
|
@ -85,11 +83,6 @@ impl_combine_or_option!(
|
|||
ServerAuthMethod,
|
||||
WebhookStrategy,
|
||||
LogFilter,
|
||||
AgentProfileKind,
|
||||
BillingPolicy,
|
||||
CodecKind,
|
||||
ProviderAuthConfig,
|
||||
ReasoningEffortFeature,
|
||||
);
|
||||
|
||||
impl Combine for Option<Vec<String>> {
|
||||
|
|
@ -98,24 +91,12 @@ impl Combine for Option<Vec<String>> {
|
|||
}
|
||||
}
|
||||
|
||||
impl Combine for Option<Vec<CredentialRef>> {
|
||||
fn combine(self, other: Self) -> Self {
|
||||
self.or(other)
|
||||
}
|
||||
}
|
||||
|
||||
impl Combine for Option<Vec<ServerAuthMethod>> {
|
||||
fn combine(self, other: Self) -> Self {
|
||||
self.or(other)
|
||||
}
|
||||
}
|
||||
|
||||
impl Combine for Option<BTreeMap<String, CostRates>> {
|
||||
fn combine(self, other: Self) -> Self {
|
||||
self.or(other)
|
||||
}
|
||||
}
|
||||
|
||||
impl Combine for Option<HashMap<String, toml::Value>> {
|
||||
fn combine(self, other: Self) -> Self {
|
||||
self.or(other)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -20,11 +20,7 @@ pub use environment::{
|
|||
EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer,
|
||||
EnvironmentNetworkLayer, EnvironmentResourcesLayer, RunEnvironmentLayer,
|
||||
};
|
||||
pub use llm::{
|
||||
CostRates, CredentialRef, CredentialRefParseError, LlmLayer, ModelControls, ModelCostTable,
|
||||
ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits, ModelSettings,
|
||||
ProviderSettings, ReasoningEffortFeature,
|
||||
};
|
||||
pub use llm::LlmLayer;
|
||||
pub use log_filter::LogFilter;
|
||||
pub use maps::{MergeMap, ReplaceMap, StickyMap};
|
||||
pub use project::ProjectLayer;
|
||||
|
|
|
|||
|
|
@ -32,8 +32,7 @@ use std::path::Path;
|
|||
|
||||
pub use builders::{
|
||||
ResolveErrors, RunSettingsBuilder, ServerRuntimeSettings, ServerSettingsBuilder,
|
||||
UserSettingsBuilder, WorkflowSettingsBuilder, load_llm_catalog_settings,
|
||||
load_server_runtime_settings,
|
||||
UserSettingsBuilder, WorkflowSettingsBuilder, load_llm_overlay, load_server_runtime_settings,
|
||||
};
|
||||
pub use error::{Error, Result};
|
||||
pub use fabro_util::path::expand_tilde;
|
||||
|
|
@ -42,22 +41,21 @@ pub use input_overrides::{InputOverrideParseError, parse_input_overrides, parse_
|
|||
pub(crate) use layers::Combine;
|
||||
pub use layers::{
|
||||
CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer,
|
||||
CliOutputLayer, CliTargetLayer, CliUpdatesLayer, CostRates, CredentialRef,
|
||||
CredentialRefParseError, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer,
|
||||
EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, GitAuthorLayer,
|
||||
GithubIntegrationLayer, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer,
|
||||
InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter,
|
||||
McpEntryLayer, MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings,
|
||||
NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
|
||||
PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer,
|
||||
RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer,
|
||||
RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer,
|
||||
RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer,
|
||||
RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
|
||||
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer,
|
||||
ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer,
|
||||
ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer,
|
||||
ServerWebLayer, SettingsLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
|
||||
CliOutputLayer, CliTargetLayer, CliUpdatesLayer, EnvironmentDockerfileLayer,
|
||||
EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer, EnvironmentNetworkLayer,
|
||||
EnvironmentResourcesLayer, GitAuthorLayer, GithubIntegrationLayer, HookAgentMarker, HookEntry,
|
||||
HookTlsMode, IntegrationWebhooksLayer, InterviewProviderLayer, InterviewsLayer, LlmLayer,
|
||||
LogFilter, McpEntryLayer, MergeMap, ModelRefOrSplice, NotificationProviderLayer,
|
||||
NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer,
|
||||
ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer,
|
||||
RunEnvironmentLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer,
|
||||
RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer,
|
||||
RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer, ScmGitHubLayer,
|
||||
ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer,
|
||||
ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
|
||||
ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer,
|
||||
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer,
|
||||
SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
|
||||
};
|
||||
pub use logging::{resolve_log_destination, resolve_log_destination_with_env};
|
||||
pub use parse::ParseError;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
use std::fmt;
|
||||
|
||||
use fabro_model::catalog::LegacyModelError;
|
||||
|
||||
use crate::SettingsLayer;
|
||||
|
||||
const CURRENT_VERSION: u32 = 1;
|
||||
|
|
@ -32,7 +30,6 @@ const LEGACY_LLM_KEYS: &[&str] = &[
|
|||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ParseError {
|
||||
Toml(String),
|
||||
LlmCatalog(LegacyModelError),
|
||||
Version(VersionError),
|
||||
UnknownTopLevelKey {
|
||||
key: String,
|
||||
|
|
@ -48,7 +45,6 @@ impl fmt::Display for ParseError {
|
|||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Toml(msg) => write!(f, "settings file is not valid TOML: {msg}"),
|
||||
Self::LlmCatalog(err) => fmt::Display::fmt(err, f),
|
||||
Self::Version(err) => fmt::Display::fmt(err, f),
|
||||
Self::UnknownTopLevelKey { key, hint } => {
|
||||
if let Some(hint) = hint {
|
||||
|
|
@ -122,14 +118,8 @@ pub(crate) fn parse_settings(input: &str) -> Result<SettingsLayer, ParseError> {
|
|||
}
|
||||
}
|
||||
|
||||
let mut layer = raw
|
||||
.try_into::<SettingsLayer>()
|
||||
.map_err(|e| ParseError::Toml(e.to_string()))?;
|
||||
if let Some(llm) = layer.llm.as_mut() {
|
||||
llm.normalize_legacy_models()
|
||||
.map_err(ParseError::LlmCatalog)?;
|
||||
}
|
||||
Ok(layer)
|
||||
raw.try_into::<SettingsLayer>()
|
||||
.map_err(|e| ParseError::Toml(e.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -291,227 +281,36 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_new_llm_providers_subtree() {
|
||||
let parsed = "[llm.providers.moonshot]\nadapter = \"openai_compatible\"\n"
|
||||
fn accepts_llm_overlay_subtree() {
|
||||
let parsed = "[llm.providers.moonshot]\npriority = 60\n"
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
assert!(parsed.llm.unwrap().providers.contains_key("moonshot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_new_llm_models_subtree() {
|
||||
let parsed = "[llm.providers.moonshot.models.\"foo\"]\n"
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
assert!(
|
||||
parsed
|
||||
.llm
|
||||
.unwrap()
|
||||
.providers
|
||||
.get("moonshot")
|
||||
.unwrap()
|
||||
.models
|
||||
.contains_key("foo")
|
||||
let llm = parsed.llm.unwrap();
|
||||
assert_eq!(
|
||||
llm.0["providers"]["moonshot"]["priority"].as_integer(),
|
||||
Some(60)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_scoped_model_rejects_redundant_provider_field() {
|
||||
let error = r#"
|
||||
[llm.providers.openai.models."gpt-5.4"]
|
||||
provider = "openai"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
ParseError::LlmCatalog(LegacyModelError::ScopedModelDeclaresProvider {
|
||||
provider,
|
||||
model,
|
||||
}) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_model_row_with_provider_normalizes_before_merge() {
|
||||
use crate::layers::Combine as _;
|
||||
|
||||
fn llm_overlay_merges_across_layers() {
|
||||
let higher = r#"
|
||||
[llm.models."gpt-5.4"]
|
||||
provider = "openai"
|
||||
display_name = "Configured display name"
|
||||
[llm.providers.openai.models."gpt-5.4".metadata.fabro]
|
||||
small_default = true
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
let fallback = r#"
|
||||
[llm.providers.openai.models."gpt-5.4"]
|
||||
family = "gpt-5"
|
||||
let lower = r#"
|
||||
[llm.providers.openai.models."gpt-5.4".metadata.fabro]
|
||||
probe = true
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
|
||||
let merged = higher.combine(fallback);
|
||||
let llm = merged.llm.unwrap();
|
||||
assert!(llm.models.is_empty());
|
||||
let model = llm
|
||||
.providers
|
||||
.get("openai")
|
||||
.unwrap()
|
||||
.models
|
||||
.get("gpt-5.4")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
model.display_name.as_deref(),
|
||||
Some("Configured display name")
|
||||
);
|
||||
assert_eq!(model.family.as_deref(), Some("gpt-5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_less_legacy_row_adopts_unique_builtin_offering() {
|
||||
let parsed = r#"
|
||||
[llm.models.mercury]
|
||||
display_name = "Configured Mercury"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
let llm = parsed.llm.unwrap();
|
||||
|
||||
assert!(llm.models.is_empty());
|
||||
assert!(
|
||||
llm.providers
|
||||
.get("inception")
|
||||
.unwrap()
|
||||
.models
|
||||
.contains_key("mercury-2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_less_legacy_row_rejects_ambiguous_builtin_offering() {
|
||||
let error = r#"
|
||||
[llm.models."gpt-5.6-sol"]
|
||||
display_name = "Ambiguous"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
ParseError::LlmCatalog(LegacyModelError::AmbiguousModel {
|
||||
model,
|
||||
candidates,
|
||||
}) if model == "gpt-5.6-sol" && candidates.len() >= 2
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_source_legacy_and_provider_scoped_rows_conflict() {
|
||||
let error = r#"
|
||||
[llm.providers.openai.models."gpt-5.4"]
|
||||
display_name = "Canonical"
|
||||
|
||||
[llm.models."gpt-5.4"]
|
||||
provider = "openai"
|
||||
display_name = "Legacy"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
ParseError::LlmCatalog(LegacyModelError::DuplicateModel {
|
||||
provider,
|
||||
model,
|
||||
}) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_builtin_model_id_normalizes_with_explicit_provider() {
|
||||
let parsed = r#"
|
||||
[llm.models."openai/gpt-5.6-sol"]
|
||||
provider = "openrouter"
|
||||
display_name = "Configured Sol"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
let llm = parsed.llm.unwrap();
|
||||
|
||||
assert!(llm.models.is_empty());
|
||||
let model = llm
|
||||
.providers
|
||||
.get("openrouter")
|
||||
.unwrap()
|
||||
.models
|
||||
.get("gpt-5.6-sol")
|
||||
.unwrap();
|
||||
assert_eq!(model.display_name.as_deref(), Some("Configured Sol"));
|
||||
assert!(model.provider.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_builtin_model_id_without_provider_uses_historical_catalog_provider() {
|
||||
let parsed = r#"
|
||||
[llm.models."anthropic/claude-fable-5"]
|
||||
display_name = "Configured Fable"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
let llm = parsed.llm.unwrap();
|
||||
|
||||
assert!(llm.models.is_empty());
|
||||
assert!(
|
||||
llm.providers
|
||||
.get("openrouter")
|
||||
.unwrap()
|
||||
.models
|
||||
.contains_key("claude-fable-5")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_model_slug_on_different_providers_merges_independently() {
|
||||
use crate::layers::Combine as _;
|
||||
|
||||
let direct = r#"
|
||||
[llm.providers.openai.models.shared]
|
||||
display_name = "Direct"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
let aggregator = r#"
|
||||
[llm.providers.openrouter.models.shared]
|
||||
display_name = "Aggregator"
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.unwrap();
|
||||
|
||||
let merged = direct.combine(aggregator);
|
||||
let providers = merged.llm.unwrap().providers;
|
||||
assert_eq!(
|
||||
providers
|
||||
.get("openai")
|
||||
.unwrap()
|
||||
.models
|
||||
.get("shared")
|
||||
.unwrap()
|
||||
.display_name
|
||||
.as_deref(),
|
||||
Some("Direct")
|
||||
);
|
||||
assert_eq!(
|
||||
providers
|
||||
.get("openrouter")
|
||||
.unwrap()
|
||||
.models
|
||||
.get("shared")
|
||||
.unwrap()
|
||||
.display_name
|
||||
.as_deref(),
|
||||
Some("Aggregator")
|
||||
);
|
||||
let merged = crate::Combine::combine(higher, lower);
|
||||
let fabro =
|
||||
&merged.llm.unwrap().0["providers"]["openai"]["models"]["gpt-5.4"]["metadata"]["fabro"];
|
||||
assert_eq!(fabro["small_default"].as_bool(), Some(true));
|
||||
assert_eq!(fabro["probe"].as_bool(), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ workspace = true
|
|||
chrono = { workspace = true, features = ["serde"] }
|
||||
clap = { workspace = true, optional = true }
|
||||
dirs.workspace = true
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-util = { path = "../fabro-util" }
|
||||
hex.workspace = true
|
||||
lithos-llm = { workspace = true, features = ["runtime"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
|
|
|
|||
81
lib/foundation/fabro-types/src/agent_profile.rs
Normal file
81
lib/foundation/fabro-types/src/agent_profile.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! Agent profile vocabulary shared by the catalog policy and the agent.
|
||||
//!
|
||||
//! The catalog records which profile a model should run under in its
|
||||
//! `metadata.fabro.agent_profile` entry. This enum is the Rust spelling of
|
||||
//! that value.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::{Display, EnumString, IntoStaticStr, VariantArray};
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Display,
|
||||
EnumString,
|
||||
IntoStaticStr,
|
||||
VariantArray,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum AgentProfileKind {
|
||||
Anthropic,
|
||||
/// Claude 5 models trained against Anthropic's current coding-agent
|
||||
/// harness. This remains model-scoped so older Claude models keep the
|
||||
/// established Anthropic profile.
|
||||
#[serde(rename = "claude-5")]
|
||||
#[strum(to_string = "claude-5")]
|
||||
Claude5,
|
||||
#[serde(rename = "openai")]
|
||||
#[strum(to_string = "openai")]
|
||||
OpenAi,
|
||||
Gemini,
|
||||
/// Kimi (Moonshot) models, wherever they are served from. Selected per
|
||||
/// model rather than per provider, so a Kimi model reached through a
|
||||
/// gateway such as OpenRouter gets the same profile as one reached
|
||||
/// directly at `api.moonshot.ai`.
|
||||
Kimi,
|
||||
/// GPT-5.6 models (Sol, Terra, Luna), which Codex drives with a narrower
|
||||
/// core tool set than earlier GPT models: a shell, a file editor, and
|
||||
/// `update_plan`, plus optional web search. The profile omits dedicated
|
||||
/// file-read, discovery, and fetch tools. Selected per model rather than
|
||||
/// per provider, so other models on the `openai` provider keep
|
||||
/// [`Self::OpenAi`].
|
||||
Gpt56,
|
||||
}
|
||||
|
||||
impl AgentProfileKind {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn agent_profile_kind_round_trips_as_settings_strings() {
|
||||
for kind in AgentProfileKind::VARIANTS {
|
||||
let expected = kind.to_string();
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
assert_eq!(json, format!("\"{expected}\""));
|
||||
let parsed: AgentProfileKind = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, *kind);
|
||||
assert_eq!(expected.parse::<AgentProfileKind>().unwrap(), *kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude5_and_gpt56_use_their_catalog_spellings() {
|
||||
assert_eq!(AgentProfileKind::Claude5.as_str(), "claude-5");
|
||||
assert_eq!(AgentProfileKind::Gpt56.as_str(), "gpt56");
|
||||
assert_eq!(AgentProfileKind::OpenAi.as_str(), "openai");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,466 @@
|
|||
pub use fabro_model::{
|
||||
AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts,
|
||||
GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment,
|
||||
ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage,
|
||||
OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros,
|
||||
};
|
||||
//! Billing rollup vocabulary.
|
||||
//!
|
||||
//! Per-response token usage and cost come from lithos: [`TokenCounts`] holds
|
||||
//! the five disjoint buckets and [`CostSource`] says where a cost came from.
|
||||
//! Fabro sums that usage across responses, stages, and runs. The types here
|
||||
//! are those sums, plus [`ModelRef`], the identity a billed response is
|
||||
//! grouped under.
|
||||
|
||||
use lithos_llm::catalog::{ModelHandle, ModelId, ProviderId};
|
||||
pub use lithos_llm::types::{Cost, CostSource, Speed, TokenCounts};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::controls;
|
||||
|
||||
const USD_MICROS_PER_USD_F64: f64 = 1_000_000.0;
|
||||
|
||||
#[allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_precision_loss,
|
||||
reason = "Billing rounds bounded finite floats into i64 counters by design."
|
||||
)]
|
||||
fn saturating_rounded_f64_to_i64(value: f64) -> i64 {
|
||||
if !value.is_finite() {
|
||||
return if value.is_sign_negative() {
|
||||
i64::MIN
|
||||
} else {
|
||||
i64::MAX
|
||||
};
|
||||
}
|
||||
|
||||
if value <= i64::MIN as f64 {
|
||||
i64::MIN
|
||||
} else if value >= i64::MAX as f64 {
|
||||
i64::MAX
|
||||
} else {
|
||||
value as i64
|
||||
}
|
||||
}
|
||||
|
||||
fn saturating_u64_to_i64(value: u64) -> i64 {
|
||||
i64::try_from(value).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
fn saturating_i64_to_u64(value: i64) -> u64 {
|
||||
u64::try_from(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A USD amount in micros (one millionth of a dollar).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
|
||||
pub struct UsdMicros(pub i64);
|
||||
|
||||
impl UsdMicros {
|
||||
#[must_use]
|
||||
pub fn from_usd(usd: f64) -> Self {
|
||||
Self(saturating_rounded_f64_to_i64(
|
||||
(usd * USD_MICROS_PER_USD_F64).round(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Converts a lithos cost into Fabro's signed micros.
|
||||
#[must_use]
|
||||
pub fn from_cost(cost: &Cost) -> Self {
|
||||
Self(saturating_u64_to_i64(cost.usd_micros))
|
||||
}
|
||||
|
||||
/// Folds a cost into a running total that stays `None` until a cost is
|
||||
/// observed (`None` means "no provider data", not $0).
|
||||
pub fn accumulate(total: &mut Option<Self>, cost: Option<Self>) {
|
||||
if let Some(cost) = cost {
|
||||
*total.get_or_insert_default() += cost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Add for UsdMicros {
|
||||
type Output = Self;
|
||||
|
||||
fn add(self, rhs: Self) -> Self::Output {
|
||||
Self(self.0.saturating_add(rhs.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::AddAssign for UsdMicros {
|
||||
fn add_assign(&mut self, rhs: Self) {
|
||||
*self = *self + rhs;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::iter::Sum for UsdMicros {
|
||||
fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
|
||||
iter.fold(Self::default(), |acc, value| acc + value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `rhs` into `total` bucket by bucket with saturation.
|
||||
pub fn add_usage(total: &mut TokenCounts, rhs: TokenCounts) {
|
||||
total.input = total.input.saturating_add(rhs.input);
|
||||
total.output = total.output.saturating_add(rhs.output);
|
||||
total.reasoning = total.reasoning.saturating_add(rhs.reasoning);
|
||||
total.cache_read = total.cache_read.saturating_add(rhs.cache_read);
|
||||
total.cache_write = total.cache_write.saturating_add(rhs.cache_write);
|
||||
}
|
||||
|
||||
fn accumulate_optional_usd_micros(total: &mut Option<i64>, cost: Option<i64>) {
|
||||
let mut typed_total = (*total).map(UsdMicros);
|
||||
UsdMicros::accumulate(&mut typed_total, cost.map(UsdMicros));
|
||||
*total = typed_total.map(|value| value.0);
|
||||
}
|
||||
|
||||
/// Provider-qualified model identity a billed response is grouped under.
|
||||
///
|
||||
/// Carries the requested speed tier because providers price tiers
|
||||
/// differently, so two responses from the same model at different speeds are
|
||||
/// separate billing rows.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ModelRef {
|
||||
pub provider: ProviderId,
|
||||
pub model_id: ModelId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub speed: Option<Speed>,
|
||||
}
|
||||
|
||||
impl ModelRef {
|
||||
#[must_use]
|
||||
pub fn new(provider: ProviderId, model_id: ModelId) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
model_id,
|
||||
speed: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_handle(handle: &ModelHandle, speed: Option<Speed>) -> Self {
|
||||
Self {
|
||||
provider: handle.provider().clone(),
|
||||
model_id: handle.model().clone(),
|
||||
speed,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_speed(mut self, speed: Option<Speed>) -> Self {
|
||||
self.speed = speed;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn handle(&self) -> ModelHandle {
|
||||
ModelHandle::new(self.provider.clone(), self.model_id.clone())
|
||||
}
|
||||
|
||||
/// Stable ordering key: provider, then model, then speed label.
|
||||
#[must_use]
|
||||
pub fn sort_key(&self) -> (&str, &str, &'static str) {
|
||||
(
|
||||
self.provider.as_str(),
|
||||
self.model_id.as_str(),
|
||||
self.speed.map_or("", controls::speed_name),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::hash::Hash for ModelRef {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.provider.hash(state);
|
||||
self.model_id.hash(state);
|
||||
self.speed.map(controls::speed_name).hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ModelRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}/{}", self.provider, self.model_id)?;
|
||||
if let Some(speed) = self.speed {
|
||||
write!(f, " ({})", controls::speed_name(speed))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage and cost of one billed model response.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BilledModelUsage {
|
||||
pub model: ModelRef,
|
||||
pub tokens: TokenCounts,
|
||||
/// Cost for `tokens`, when the provider reported one or the catalog could
|
||||
/// price them. `None` means no cost data, not zero.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub total_usd_micros: Option<i64>,
|
||||
}
|
||||
|
||||
impl BilledModelUsage {
|
||||
#[must_use]
|
||||
pub fn new(model: ModelRef, tokens: TokenCounts, cost: Option<Cost>) -> Self {
|
||||
Self {
|
||||
model,
|
||||
tokens,
|
||||
total_usd_micros: cost.map(|cost| UsdMicros::from_cost(&cost).0),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model(&self) -> &ModelRef {
|
||||
&self.model
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model_id(&self) -> &str {
|
||||
self.model.model_id.as_str()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn tokens(&self) -> TokenCounts {
|
||||
self.tokens
|
||||
}
|
||||
|
||||
/// Overrides the billed total with a reported cost; `None` leaves the
|
||||
/// existing value in place.
|
||||
#[must_use]
|
||||
pub fn with_reported_cost(mut self, cost: Option<UsdMicros>) -> Self {
|
||||
if let Some(cost) = cost {
|
||||
self.total_usd_micros = Some(cost.0);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Token counts summed across one or more responses, with the summed cost.
|
||||
///
|
||||
/// `total_tokens` is the sum of the five buckets. `total_usd_micros` stays
|
||||
/// `None` until at least one summed response carried a cost.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct BilledTokenCounts {
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
pub total_tokens: i64,
|
||||
#[serde(default)]
|
||||
pub reasoning_tokens: i64,
|
||||
#[serde(default)]
|
||||
pub cache_read_tokens: i64,
|
||||
#[serde(default)]
|
||||
pub cache_write_tokens: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub total_usd_micros: Option<i64>,
|
||||
}
|
||||
|
||||
impl BilledTokenCounts {
|
||||
#[must_use]
|
||||
pub fn from_token_counts(tokens: TokenCounts, total_usd_micros: Option<i64>) -> Self {
|
||||
Self {
|
||||
input_tokens: saturating_u64_to_i64(tokens.input),
|
||||
output_tokens: saturating_u64_to_i64(tokens.output),
|
||||
total_tokens: saturating_u64_to_i64(tokens.total()),
|
||||
reasoning_tokens: saturating_u64_to_i64(tokens.reasoning),
|
||||
cache_read_tokens: saturating_u64_to_i64(tokens.cache_read),
|
||||
cache_write_tokens: saturating_u64_to_i64(tokens.cache_write),
|
||||
total_usd_micros,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_billed_usage(billed: &[BilledModelUsage]) -> Self {
|
||||
let mut counts = Self::default();
|
||||
for entry in billed {
|
||||
counts.add_billed_usage(entry);
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
/// Returns the five disjoint per-call token buckets, dropping the derived
|
||||
/// `total_tokens` sum and the optional `total_usd_micros` cost.
|
||||
#[must_use]
|
||||
pub fn token_counts(&self) -> TokenCounts {
|
||||
TokenCounts {
|
||||
input: saturating_i64_to_u64(self.input_tokens),
|
||||
output: saturating_i64_to_u64(self.output_tokens),
|
||||
reasoning: saturating_i64_to_u64(self.reasoning_tokens),
|
||||
cache_read: saturating_i64_to_u64(self.cache_read_tokens),
|
||||
cache_write: saturating_i64_to_u64(self.cache_write_tokens),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_counts(&mut self, source: &Self) {
|
||||
self.input_tokens = self.input_tokens.saturating_add(source.input_tokens);
|
||||
self.output_tokens = self.output_tokens.saturating_add(source.output_tokens);
|
||||
self.total_tokens = self.total_tokens.saturating_add(source.total_tokens);
|
||||
self.reasoning_tokens = self
|
||||
.reasoning_tokens
|
||||
.saturating_add(source.reasoning_tokens);
|
||||
self.cache_read_tokens = self
|
||||
.cache_read_tokens
|
||||
.saturating_add(source.cache_read_tokens);
|
||||
self.cache_write_tokens = self
|
||||
.cache_write_tokens
|
||||
.saturating_add(source.cache_write_tokens);
|
||||
accumulate_optional_usd_micros(&mut self.total_usd_micros, source.total_usd_micros);
|
||||
}
|
||||
|
||||
pub fn add_billed_usage(&mut self, usage: &BilledModelUsage) {
|
||||
self.add_counts(&Self::from_token_counts(
|
||||
usage.tokens,
|
||||
usage.total_usd_micros,
|
||||
));
|
||||
}
|
||||
|
||||
pub fn replace_with_billed_usage(&mut self, usage: &BilledModelUsage) {
|
||||
*self = Self::from_billed_usage(std::slice::from_ref(usage));
|
||||
}
|
||||
|
||||
/// Overrides the billed total with a reported cost; `None` leaves any
|
||||
/// existing value in place.
|
||||
#[must_use]
|
||||
pub fn with_reported_cost(mut self, cost: Option<UsdMicros>) -> Self {
|
||||
if let Some(cost) = cost {
|
||||
self.total_usd_micros = Some(cost.0);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.input_tokens == 0
|
||||
&& self.output_tokens == 0
|
||||
&& self.total_tokens == 0
|
||||
&& self.reasoning_tokens == 0
|
||||
&& self.cache_read_tokens == 0
|
||||
&& self.cache_write_tokens == 0
|
||||
&& self.total_usd_micros.unwrap_or(0) == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn tokens() -> TokenCounts {
|
||||
TokenCounts {
|
||||
input: 100,
|
||||
output: 20,
|
||||
reasoning: 5,
|
||||
cache_read: 7,
|
||||
cache_write: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn model() -> ModelRef {
|
||||
ModelRef::new(
|
||||
ProviderId::new("anthropic"),
|
||||
ModelId::new("claude-sonnet-5"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_micros_from_usd_rounds_to_nearest_micro() {
|
||||
assert_eq!(UsdMicros::from_usd(0.012_345), UsdMicros(12_345));
|
||||
assert_eq!(UsdMicros::from_usd(1.0), UsdMicros(1_000_000));
|
||||
assert_eq!(UsdMicros::from_usd(f64::INFINITY), UsdMicros(i64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_micros_from_cost_saturates() {
|
||||
let cost = Cost {
|
||||
usd_micros: u64::MAX,
|
||||
source: CostSource::Provider,
|
||||
};
|
||||
assert_eq!(UsdMicros::from_cost(&cost), UsdMicros(i64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulate_stays_none_without_costs() {
|
||||
let mut total = None;
|
||||
UsdMicros::accumulate(&mut total, None);
|
||||
assert_eq!(total, None);
|
||||
UsdMicros::accumulate(&mut total, Some(UsdMicros(5)));
|
||||
UsdMicros::accumulate(&mut total, None);
|
||||
UsdMicros::accumulate(&mut total, Some(UsdMicros(7)));
|
||||
assert_eq!(total, Some(UsdMicros(12)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billed_token_counts_from_token_counts_sums_total() {
|
||||
let counts = BilledTokenCounts::from_token_counts(tokens(), Some(42));
|
||||
assert_eq!(counts.input_tokens, 100);
|
||||
assert_eq!(counts.output_tokens, 20);
|
||||
assert_eq!(counts.reasoning_tokens, 5);
|
||||
assert_eq!(counts.cache_read_tokens, 7);
|
||||
assert_eq!(counts.cache_write_tokens, 3);
|
||||
assert_eq!(counts.total_tokens, 135);
|
||||
assert_eq!(counts.total_usd_micros, Some(42));
|
||||
assert_eq!(counts.token_counts(), tokens());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billed_token_counts_sum_billed_usage_and_costs() {
|
||||
let priced = BilledModelUsage::new(
|
||||
model(),
|
||||
tokens(),
|
||||
Some(Cost {
|
||||
usd_micros: 10,
|
||||
source: CostSource::Catalog,
|
||||
}),
|
||||
);
|
||||
let unpriced = BilledModelUsage::new(model(), tokens(), None);
|
||||
let counts = BilledTokenCounts::from_billed_usage(&[priced, unpriced]);
|
||||
assert_eq!(counts.input_tokens, 200);
|
||||
assert_eq!(counts.total_tokens, 270);
|
||||
assert_eq!(counts.total_usd_micros, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billed_token_counts_without_costs_report_none() {
|
||||
let counts =
|
||||
BilledTokenCounts::from_billed_usage(&[BilledModelUsage::new(model(), tokens(), None)]);
|
||||
assert_eq!(counts.total_usd_micros, None);
|
||||
assert!(!counts.is_zero());
|
||||
assert!(BilledTokenCounts::default().is_zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billed_model_usage_serializes_lithos_token_buckets() {
|
||||
let usage = BilledModelUsage::new(model().with_speed(Some(Speed::Fast)), tokens(), None);
|
||||
let value = serde_json::to_value(&usage).unwrap();
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"model_id": "claude-sonnet-5",
|
||||
"speed": "fast",
|
||||
},
|
||||
"tokens": {
|
||||
"input": 100,
|
||||
"output": 20,
|
||||
"reasoning": 5,
|
||||
"cache_read": 7,
|
||||
"cache_write": 3,
|
||||
},
|
||||
})
|
||||
);
|
||||
let back: BilledModelUsage = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(back, usage);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_ref_hash_distinguishes_speed_tiers() {
|
||||
use std::collections::HashSet;
|
||||
|
||||
let mut set = HashSet::new();
|
||||
set.insert(model());
|
||||
set.insert(model().with_speed(Some(Speed::Fast)));
|
||||
set.insert(model().with_speed(Some(Speed::Fast)));
|
||||
assert_eq!(set.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_ref_display_names_the_route_and_speed() {
|
||||
assert_eq!(model().to_string(), "anthropic/claude-sonnet-5");
|
||||
assert_eq!(
|
||||
model().with_speed(Some(Speed::Fast)).to_string(),
|
||||
"anthropic/claude-sonnet-5 (fast)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use fabro_model::Catalog;
|
||||
|
||||
use crate::{BilledTokenCounts, ModelRef, RunProjection, RunTiming, StageSummary, StageTiming};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
|
@ -119,10 +117,7 @@ impl ProjectionBillingRollup {
|
|||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn billing_rollup_from_projection(
|
||||
projection: &RunProjection,
|
||||
catalog: Option<&Catalog>,
|
||||
) -> ProjectionBillingRollup {
|
||||
pub fn billing_rollup_from_projection(projection: &RunProjection) -> ProjectionBillingRollup {
|
||||
let mut stage_indices = HashMap::<String, usize>::new();
|
||||
let mut stages = Vec::<ProjectionBillingStage>::new();
|
||||
let mut by_model = HashMap::<ModelRef, ProjectionBillingByModel>::new();
|
||||
|
|
@ -134,8 +129,7 @@ pub fn billing_rollup_from_projection(
|
|||
if projection.is_boundary_stage(stage_id.node_id()) {
|
||||
continue;
|
||||
}
|
||||
let usage = stage.billed_usage(catalog);
|
||||
let usage = usage.as_ref();
|
||||
let usage = &stage.usage;
|
||||
if stage.completion.is_none() && stage.timing.is_none() && usage.is_zero() {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -180,19 +174,7 @@ pub fn billing_rollup_from_projection(
|
|||
}
|
||||
|
||||
let mut by_model = by_model.into_values().collect::<Vec<_>>();
|
||||
by_model.sort_by(|left, right| {
|
||||
let left_provider = left.model.provider.to_string();
|
||||
let right_provider = right.model.provider.to_string();
|
||||
left_provider
|
||||
.cmp(&right_provider)
|
||||
.then_with(|| left.model.model_id.cmp(&right.model.model_id))
|
||||
.then_with(|| {
|
||||
left.model
|
||||
.speed
|
||||
.map(<&'static str>::from)
|
||||
.cmp(&right.model.speed.map(<&'static str>::from))
|
||||
})
|
||||
});
|
||||
by_model.sort_by(|left, right| left.model.sort_key().cmp(&right.model.sort_key()));
|
||||
|
||||
ProjectionBillingRollup {
|
||||
stages,
|
||||
|
|
|
|||
95
lib/foundation/fabro-types/src/catalog_api.rs
Normal file
95
lib/foundation/fabro-types/src/catalog_api.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! API projections of the model catalog.
|
||||
//!
|
||||
//! `GET /models` and `GET /providers` return these. They are views over the
|
||||
//! lithos catalog plus Fabro policy, stamped per request with whether the
|
||||
//! server holds credentials for each provider.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{ModelId, ProviderId, ReasoningEffort};
|
||||
|
||||
/// Token limits for a model.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ModelLimits {
|
||||
pub context_window: i64,
|
||||
pub max_output: Option<i64>,
|
||||
}
|
||||
|
||||
/// Capability flags for a model.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ModelFeatures {
|
||||
pub tools: bool,
|
||||
pub vision: bool,
|
||||
pub reasoning: bool,
|
||||
pub prompt_cache: bool,
|
||||
/// Whether the model accepts classic sampling parameters
|
||||
/// (`temperature`, `top_p`).
|
||||
pub sampling: bool,
|
||||
}
|
||||
|
||||
/// Request-control values a model accepts.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ModelControls {
|
||||
/// Reasoning-effort values accepted by this offering. Empty means the
|
||||
/// control is unsupported.
|
||||
#[serde(default)]
|
||||
pub reasoning_effort: Vec<ReasoningEffort>,
|
||||
}
|
||||
|
||||
/// Pricing per million tokens in USD.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelCosts {
|
||||
pub input_cost_per_mtok: Option<f64>,
|
||||
pub output_cost_per_mtok: Option<f64>,
|
||||
pub cache_input_cost_per_mtok: Option<f64>,
|
||||
}
|
||||
|
||||
/// One provider's offering of a model.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Model {
|
||||
pub id: ModelId,
|
||||
pub provider: ProviderId,
|
||||
pub family: String,
|
||||
pub display_name: String,
|
||||
pub limits: ModelLimits,
|
||||
pub training: Option<String>,
|
||||
pub knowledge_cutoff: Option<String>,
|
||||
pub features: ModelFeatures,
|
||||
#[serde(default)]
|
||||
pub controls: ModelControls,
|
||||
pub costs: ModelCosts,
|
||||
pub estimated_output_tps: Option<f64>,
|
||||
pub aliases: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub default: bool,
|
||||
#[serde(default)]
|
||||
pub small_default: bool,
|
||||
/// Whether the server holds credential material for this model's
|
||||
/// provider. Stamped per request; never implies the credential works.
|
||||
#[serde(default)]
|
||||
pub configured: bool,
|
||||
}
|
||||
|
||||
/// An LLM provider with effective configuration and configured status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Provider {
|
||||
pub id: ProviderId,
|
||||
pub display_name: String,
|
||||
/// lithos adapter id, such as `openai` or `openai-compatible`.
|
||||
pub adapter: String,
|
||||
pub base_url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_key_url: Option<String>,
|
||||
pub priority: i32,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub aliases: Vec<String>,
|
||||
pub model_count: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub configured: bool,
|
||||
/// Vault secret an operator creates to configure this provider, when the
|
||||
/// provider reads one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_secret_name: Option<String>,
|
||||
}
|
||||
229
lib/foundation/fabro-types/src/catalog_policy.rs
Normal file
229
lib/foundation/fabro-types/src/catalog_policy.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
//! Fabro's `metadata.fabro` catalog namespace.
|
||||
//!
|
||||
//! lithos-llm owns provider and model facts. Fabro attaches its own policy to
|
||||
//! each entry under `metadata.fabro`, which lithos carries verbatim and never
|
||||
//! interprets. These types are the typed view of that namespace. Every field
|
||||
//! is optional in the TOML; the accessors here apply Fabro's defaults.
|
||||
|
||||
use lithos_llm::catalog::{CatalogModel, CatalogProvider};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::AgentProfileKind;
|
||||
|
||||
/// Name of the metadata namespace Fabro owns on catalog entries.
|
||||
pub const FABRO_METADATA_NAMESPACE: &str = "fabro";
|
||||
|
||||
/// Provider-level Fabro policy.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ProviderPolicy {
|
||||
/// Whether Fabro offers this provider at all. Missing means enabled.
|
||||
pub enabled: Option<bool>,
|
||||
/// Default agent profile for models on this provider.
|
||||
pub agent_profile: Option<AgentProfileKind>,
|
||||
/// Where an operator obtains an API key.
|
||||
pub api_key_url: Option<String>,
|
||||
/// Ordered credential references (`env:NAME`, `vault:NAME`, `aws_sigv4`).
|
||||
/// The first that resolves wins.
|
||||
pub credentials: Vec<String>,
|
||||
/// Extra request headers. Values are literal text or `{{ secrets.NAME }}`
|
||||
/// interpolation strings resolved against the vault.
|
||||
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub extra_headers: std::collections::BTreeMap<String, String>,
|
||||
/// Another provider this one serves requests for when that provider has
|
||||
/// no credentials of its own. Used by `openai-codex`, which answers
|
||||
/// `openai` requests with a ChatGPT OAuth credential.
|
||||
pub stands_in_for: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderPolicy {
|
||||
#[must_use]
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Model-level Fabro policy.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ModelPolicy {
|
||||
/// Whether Fabro offers this model. Missing means enabled.
|
||||
pub enabled: Option<bool>,
|
||||
/// Agent profile override for this model.
|
||||
pub agent_profile: Option<AgentProfileKind>,
|
||||
/// Model family label for display and grouping.
|
||||
pub family: Option<String>,
|
||||
/// Training data cutoff label.
|
||||
pub training: Option<String>,
|
||||
/// Public knowledge cutoff label.
|
||||
pub knowledge_cutoff: Option<String>,
|
||||
/// Estimated output tokens per second.
|
||||
pub estimated_output_tps: Option<f64>,
|
||||
/// Preferred for small utility calls such as title generation.
|
||||
pub small_default: bool,
|
||||
/// Preferred for provider connectivity probes.
|
||||
pub probe: bool,
|
||||
/// Whether requests reason when no effort is requested. Missing means
|
||||
/// "reasons when the model supports reasoning".
|
||||
pub reasoning_by_default: Option<bool>,
|
||||
}
|
||||
|
||||
impl ModelPolicy {
|
||||
#[must_use]
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a provider's Fabro policy. Malformed metadata falls back to the
|
||||
/// defaults; the catalog build is the place to validate shape, and Fabro's
|
||||
/// own policy file is checked in tests.
|
||||
#[must_use]
|
||||
pub fn provider_policy(provider: &CatalogProvider) -> ProviderPolicy {
|
||||
provider
|
||||
.metadata()
|
||||
.namespace::<ProviderPolicy>(FABRO_METADATA_NAMESPACE)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reads a model's Fabro policy.
|
||||
#[must_use]
|
||||
pub fn model_policy(model: &CatalogModel) -> ModelPolicy {
|
||||
model
|
||||
.metadata()
|
||||
.namespace::<ModelPolicy>(FABRO_METADATA_NAMESPACE)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The agent profile a model runs under: the model override, then the
|
||||
/// provider default, then the profile implied by the provider's adapter.
|
||||
#[must_use]
|
||||
pub fn effective_agent_profile(
|
||||
provider: &CatalogProvider,
|
||||
model: &CatalogModel,
|
||||
) -> AgentProfileKind {
|
||||
model_policy(model)
|
||||
.agent_profile
|
||||
.or(provider_policy(provider).agent_profile)
|
||||
.unwrap_or_else(|| default_agent_profile(provider))
|
||||
}
|
||||
|
||||
/// The agent profile implied by a provider's wire protocol.
|
||||
#[must_use]
|
||||
pub fn default_agent_profile(provider: &CatalogProvider) -> AgentProfileKind {
|
||||
match provider.adapter().as_str() {
|
||||
"anthropic" | "bedrock" => AgentProfileKind::Anthropic,
|
||||
"gemini" => AgentProfileKind::Gemini,
|
||||
_ => AgentProfileKind::OpenAi,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lithos_llm::catalog::Catalog;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn catalog() -> Catalog {
|
||||
Catalog::builder()
|
||||
.toml_layer(
|
||||
"test",
|
||||
r#"
|
||||
schema_version = 1
|
||||
|
||||
[providers.acme]
|
||||
display_name = "Acme"
|
||||
adapter = "openai-compatible"
|
||||
codec = "openai-chat"
|
||||
base_url = "https://acme.test/v1"
|
||||
auth = { type = "bearer" }
|
||||
default_model = "large"
|
||||
|
||||
[providers.acme.metadata.fabro]
|
||||
enabled = false
|
||||
credentials = ["env:ACME_API_KEY"]
|
||||
agent_profile = "kimi"
|
||||
|
||||
[providers.acme.models.large]
|
||||
display_name = "Large"
|
||||
api_model = "large"
|
||||
|
||||
[providers.acme.models.large.metadata.fabro]
|
||||
small_default = true
|
||||
probe = true
|
||||
family = "acme"
|
||||
|
||||
[providers.acme.models.small]
|
||||
display_name = "Small"
|
||||
api_model = "small"
|
||||
[providers.acme.models.small.metadata.fabro]
|
||||
agent_profile = "openai"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_provider_and_model_policy() {
|
||||
let catalog = catalog();
|
||||
let provider = catalog.provider("acme").unwrap();
|
||||
let policy = provider_policy(provider);
|
||||
assert!(!policy.is_enabled());
|
||||
assert_eq!(policy.credentials, vec!["env:ACME_API_KEY"]);
|
||||
assert_eq!(policy.agent_profile, Some(AgentProfileKind::Kimi));
|
||||
|
||||
let large = provider.model("large").unwrap();
|
||||
let policy = model_policy(large);
|
||||
assert!(policy.small_default && policy.probe && policy.is_enabled());
|
||||
assert_eq!(policy.family.as_deref(), Some("acme"));
|
||||
assert_eq!(
|
||||
effective_agent_profile(provider, large),
|
||||
AgentProfileKind::Kimi
|
||||
);
|
||||
|
||||
let small = provider.model("small").unwrap();
|
||||
assert!(!model_policy(small).is_enabled());
|
||||
assert_eq!(
|
||||
effective_agent_profile(provider, small),
|
||||
AgentProfileKind::OpenAi
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_namespace_yields_defaults() {
|
||||
let catalog = Catalog::builder()
|
||||
.toml_layer(
|
||||
"test",
|
||||
r#"
|
||||
schema_version = 1
|
||||
[providers.bare]
|
||||
display_name = "Bare"
|
||||
adapter = "anthropic"
|
||||
codec = "anthropic-messages"
|
||||
base_url = "https://bare.test"
|
||||
auth = { type = "none" }
|
||||
[providers.bare.models.m]
|
||||
display_name = "M"
|
||||
api_model = "m"
|
||||
"#,
|
||||
)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
let provider = catalog.provider("bare").unwrap();
|
||||
assert!(provider_policy(provider).is_enabled());
|
||||
let model = provider.model("m").unwrap();
|
||||
assert!(model_policy(model).is_enabled());
|
||||
assert_eq!(
|
||||
effective_agent_profile(provider, model),
|
||||
AgentProfileKind::Anthropic
|
||||
);
|
||||
}
|
||||
}
|
||||
137
lib/foundation/fabro-types/src/controls.rs
Normal file
137
lib/foundation/fabro-types/src/controls.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! Helpers over the lithos request-control enums.
|
||||
//!
|
||||
//! lithos owns [`ReasoningEffort`] and [`Speed`] and marks both
|
||||
//! `#[non_exhaustive]`. Fabro needs to list, name, and parse them for
|
||||
//! settings, graph attributes, and CLI flags, so the spellings live here in
|
||||
//! one place. The names match the lithos serde form.
|
||||
|
||||
pub use lithos_llm::types::{ReasoningEffort, Speed};
|
||||
|
||||
/// Every reasoning effort, least to most.
|
||||
pub const REASONING_EFFORTS: &[ReasoningEffort] = &[
|
||||
ReasoningEffort::Minimal,
|
||||
ReasoningEffort::Low,
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
ReasoningEffort::Xhigh,
|
||||
ReasoningEffort::Max,
|
||||
];
|
||||
|
||||
/// Every speed tier.
|
||||
pub const SPEEDS: &[Speed] = &[Speed::Fast, Speed::Balanced, Speed::Economical];
|
||||
|
||||
/// The wire spelling of a reasoning effort.
|
||||
#[must_use]
|
||||
pub fn reasoning_effort_name(effort: ReasoningEffort) -> &'static str {
|
||||
match effort {
|
||||
ReasoningEffort::Minimal => "minimal",
|
||||
ReasoningEffort::Low => "low",
|
||||
ReasoningEffort::Medium => "medium",
|
||||
ReasoningEffort::High => "high",
|
||||
ReasoningEffort::Xhigh => "xhigh",
|
||||
ReasoningEffort::Max => "max",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire spelling of a speed tier.
|
||||
#[must_use]
|
||||
pub fn speed_name(speed: Speed) -> &'static str {
|
||||
match speed {
|
||||
Speed::Fast => "fast",
|
||||
Speed::Balanced => "balanced",
|
||||
Speed::Economical => "economical",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a reasoning effort from its wire spelling.
|
||||
#[must_use]
|
||||
pub fn parse_reasoning_effort(value: &str) -> Option<ReasoningEffort> {
|
||||
REASONING_EFFORTS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|effort| reasoning_effort_name(*effort) == value)
|
||||
}
|
||||
|
||||
/// Parses a speed tier from its wire spelling.
|
||||
#[must_use]
|
||||
pub fn parse_speed(value: &str) -> Option<Speed> {
|
||||
SPEEDS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|speed| speed_name(*speed) == value)
|
||||
}
|
||||
|
||||
/// Position of an effort in the least-to-most ordering.
|
||||
fn effort_rank(effort: ReasoningEffort) -> usize {
|
||||
REASONING_EFFORTS
|
||||
.iter()
|
||||
.position(|candidate| *candidate == effort)
|
||||
.unwrap_or(REASONING_EFFORTS.len())
|
||||
}
|
||||
|
||||
/// Selects the supported effort nearest to `requested`.
|
||||
///
|
||||
/// When two supported values are equally distant, the higher effort wins.
|
||||
/// Returns `None` when nothing is supported.
|
||||
#[must_use]
|
||||
pub fn closest_supported_effort(
|
||||
requested: ReasoningEffort,
|
||||
supported: impl Fn(ReasoningEffort) -> bool,
|
||||
) -> Option<ReasoningEffort> {
|
||||
let target = effort_rank(requested);
|
||||
REASONING_EFFORTS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|effort| supported(*effort))
|
||||
.min_by_key(|effort| {
|
||||
let rank = effort_rank(*effort);
|
||||
(rank.abs_diff(target), std::cmp::Reverse(rank))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_round_trip_through_serde() {
|
||||
for effort in REASONING_EFFORTS {
|
||||
let json = serde_json::to_string(effort).unwrap();
|
||||
assert_eq!(json, format!("\"{}\"", reasoning_effort_name(*effort)));
|
||||
assert_eq!(
|
||||
parse_reasoning_effort(reasoning_effort_name(*effort)),
|
||||
Some(*effort)
|
||||
);
|
||||
}
|
||||
for speed in SPEEDS {
|
||||
let json = serde_json::to_string(speed).unwrap();
|
||||
assert_eq!(json, format!("\"{}\"", speed_name(*speed)));
|
||||
assert_eq!(parse_speed(speed_name(*speed)), Some(*speed));
|
||||
}
|
||||
assert_eq!(parse_reasoning_effort("standard"), None);
|
||||
assert_eq!(parse_speed("standard"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_supported_prefers_the_higher_neighbor_on_ties() {
|
||||
let supported = |effort| matches!(effort, ReasoningEffort::Low | ReasoningEffort::High);
|
||||
assert_eq!(
|
||||
closest_supported_effort(ReasoningEffort::Medium, supported),
|
||||
Some(ReasoningEffort::High)
|
||||
);
|
||||
assert_eq!(
|
||||
closest_supported_effort(ReasoningEffort::Max, supported),
|
||||
Some(ReasoningEffort::High)
|
||||
);
|
||||
assert_eq!(
|
||||
closest_supported_effort(ReasoningEffort::Minimal, supported),
|
||||
Some(ReasoningEffort::Low)
|
||||
);
|
||||
assert_eq!(
|
||||
closest_supported_effort(ReasoningEffort::Medium, |_| false),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,18 @@
|
|||
extern crate self as fabro_types;
|
||||
|
||||
pub mod agent_profile;
|
||||
pub mod artifact;
|
||||
pub mod auth;
|
||||
pub mod billing;
|
||||
pub mod billing_rollup;
|
||||
pub mod blob_hash;
|
||||
pub mod blob_ref;
|
||||
pub mod catalog_api;
|
||||
pub mod catalog_policy;
|
||||
pub mod checkpoint;
|
||||
pub mod command_output;
|
||||
pub mod conclusion;
|
||||
pub mod controls;
|
||||
pub mod dense;
|
||||
pub mod diff;
|
||||
pub mod event_envelope;
|
||||
|
|
@ -20,10 +24,12 @@ pub mod interview;
|
|||
pub mod llm_backend;
|
||||
pub mod manifest_path;
|
||||
pub mod mcp_store;
|
||||
pub mod model_test;
|
||||
pub mod outcome;
|
||||
pub mod pair;
|
||||
pub mod parallel;
|
||||
pub mod principal;
|
||||
pub mod provider_ids;
|
||||
pub mod pull_request;
|
||||
pub mod reasoning;
|
||||
pub mod repository;
|
||||
|
|
@ -60,23 +66,23 @@ pub mod workflow_path;
|
|||
pub mod workflow_version;
|
||||
pub mod workflow_version_id;
|
||||
|
||||
pub use agent_profile::AgentProfileKind;
|
||||
pub use artifact::ArtifactUpload;
|
||||
pub use auth::{IdpIdentity, IdpIdentityError};
|
||||
pub use billing::{
|
||||
AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts,
|
||||
GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment,
|
||||
ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage,
|
||||
OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros,
|
||||
BilledModelUsage, BilledTokenCounts, Cost, CostSource, ModelRef, Speed, TokenCounts, UsdMicros,
|
||||
};
|
||||
pub use blob_hash::BlobHash;
|
||||
pub use blob_ref::{format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref};
|
||||
pub use catalog_api::{Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, Provider};
|
||||
pub use catalog_policy::{ModelPolicy, ProviderPolicy};
|
||||
pub use checkpoint::Checkpoint;
|
||||
pub use command_output::{CommandOutputStream, CommandTermination};
|
||||
pub use conclusion::{Conclusion, StageSummary};
|
||||
pub use controls::ReasoningEffort;
|
||||
pub use dense::{ServerSettings, UserSettings, WorkflowSettings};
|
||||
pub use diff::{DiffStats, DiffSummary, RunDiff};
|
||||
pub use event_envelope::EventEnvelope;
|
||||
pub use fabro_model::ReasoningEffort;
|
||||
pub use failure_signature::FailureSignature;
|
||||
pub use graph::{
|
||||
AttrValue, AttributeScope, ContextKeyAttr, Edge, Graph, KNOWN_HANDLER_TYPES, Node, OnFailure,
|
||||
|
|
@ -89,6 +95,10 @@ pub use input_scalar::{
|
|||
pub use interview::{
|
||||
InterviewQuestionRecord, QuestionType, ReviewTarget, ReviewTargetError, ReviewTargetKind,
|
||||
};
|
||||
pub use lithos_llm::catalog::{ModelHandle, ModelId, ProviderId};
|
||||
pub use lithos_llm::types::{
|
||||
FinishReason, Request, RequestBuildError, RequestBuilder, Response, ResponseFormat, StreamEvent,
|
||||
};
|
||||
pub use llm_backend::AgentBackend;
|
||||
pub use manifest_path::{ManifestPath, ManifestPathParseError};
|
||||
pub use mcp_store::{
|
||||
|
|
@ -96,6 +106,7 @@ pub use mcp_store::{
|
|||
McpServerRevisionParseError, McpServerValidationError, McpServerView, McpTransportView,
|
||||
validate_mcp_server_fields,
|
||||
};
|
||||
pub use model_test::ModelTestMode;
|
||||
pub use outcome::{
|
||||
FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageOutcome, StageState,
|
||||
};
|
||||
|
|
@ -192,8 +203,10 @@ pub use system_integrations::{
|
|||
pub use timing::{RunTiming, StageTiming};
|
||||
pub use todo::{TodoListKind, TodoListProjection, TodoPatch, TodoProjection, TodoStatus};
|
||||
pub use transcript::{
|
||||
AudioData, ContentPart, DocumentData, ImageData, Message, MessageId, MessageKind,
|
||||
MessageSource, PairMessageRef, Role, ThinkingData, ToolCall, ToolResult, TranscriptMessage,
|
||||
AudioContent, ContentPart, DocumentContent, ImageContent, MediaSource, Message, MessageId,
|
||||
MessageKind, MessageSource, PairMessageRef, ReasoningContent, Role, ToolCall, ToolCallKind,
|
||||
ToolChoice, ToolDefinition, ToolDefinitionKind, ToolInput, ToolResult, TranscriptMessage,
|
||||
text_of, tool_call_arguments, tool_result_from_json, tool_result_to_json,
|
||||
};
|
||||
pub use variable::{
|
||||
CreateVariableRequest, UpdateVariableRequest, Variable, VariableListResponse, is_env_style_name,
|
||||
|
|
|
|||
35
lib/foundation/fabro-types/src/model_test.rs
Normal file
35
lib/foundation/fabro-types/src/model_test.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//! Model probe modes exposed by `POST /models/{id}/test`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::{Display, EnumString, IntoStaticStr};
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Default,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Display,
|
||||
EnumString,
|
||||
IntoStaticStr,
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
pub enum ModelTestMode {
|
||||
#[default]
|
||||
Basic,
|
||||
Deep,
|
||||
}
|
||||
|
||||
impl ModelTestMode {
|
||||
#[must_use]
|
||||
pub const fn timeout_secs(self) -> u64 {
|
||||
match self {
|
||||
Self::Basic => 30,
|
||||
Self::Deep => 90,
|
||||
}
|
||||
}
|
||||
}
|
||||
27
lib/foundation/fabro-types/src/provider_ids.rs
Normal file
27
lib/foundation/fabro-types/src/provider_ids.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
//! Well-known provider identifiers.
|
||||
//!
|
||||
//! Provider identity is open-ended catalog data, so [`ProviderId`] is a plain
|
||||
//! string newtype. The three first-party providers are named here because
|
||||
//! code paths such as Codex login and the install flow refer to them
|
||||
//! directly.
|
||||
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
|
||||
pub const ANTHROPIC: &str = "anthropic";
|
||||
pub const OPENAI: &str = "openai";
|
||||
pub const GEMINI: &str = "gemini";
|
||||
|
||||
#[must_use]
|
||||
pub fn anthropic() -> ProviderId {
|
||||
ProviderId::new(ANTHROPIC)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn openai() -> ProviderId {
|
||||
ProviderId::new(OPENAI)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn gemini() -> ProviderId {
|
||||
ProviderId::new(GEMINI)
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
use fabro_model::{CostSource, ReasoningEffort, Speed};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use strum::{Display, EnumString, IntoStaticStr};
|
||||
|
|
@ -6,8 +5,9 @@ use strum::{Display, EnumString, IntoStaticStr};
|
|||
use super::{BilledTokenCounts, ExecOutputTail};
|
||||
use crate::transcript::{ToolCall, ToolResult, TranscriptMessage};
|
||||
use crate::{
|
||||
CommandTermination, MessageId, ModelRef, PairId, PairMessageId, PairSystemMessageKind,
|
||||
PermissionLevel, ReasoningOutput, StageContextWindowProjection, TurnId,
|
||||
CommandTermination, CostSource, MessageId, ModelRef, PairId, PairMessageId,
|
||||
PairSystemMessageKind, PermissionLevel, ReasoningEffort, ReasoningOutput, Speed,
|
||||
StageContextWindowProjection, TurnId,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -149,7 +149,7 @@ pub struct AgentToolStartedProps {
|
|||
pub tool_call_id: String,
|
||||
pub arguments: Value,
|
||||
pub visit: u32,
|
||||
/// Canonical tool call payload. Carries `tool_type`, `raw_arguments`, and
|
||||
/// Canonical tool call payload. Carries the typed input and
|
||||
/// `provider_metadata` (e.g. Gemini `thought_signature`) so tool actions
|
||||
/// can be replayed against the originating provider.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -526,14 +526,13 @@ mod tests {
|
|||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::transcript::{ContentPart, MessageKind, MessageSource, TranscriptMessage};
|
||||
use crate::provider_ids;
|
||||
use crate::transcript::{
|
||||
ContentPart, MessageKind, MessageSource, TranscriptMessage, tool_result_from_json,
|
||||
};
|
||||
|
||||
fn sample_model_ref() -> ModelRef {
|
||||
ModelRef {
|
||||
provider: fabro_model::ProviderId::openai(),
|
||||
model_id: "gpt-5".into(),
|
||||
speed: None,
|
||||
}
|
||||
ModelRef::new(provider_ids::openai(), "gpt-5".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -587,7 +586,9 @@ mod tests {
|
|||
#[test]
|
||||
fn agent_message_props_carries_canonical_transcript_message() {
|
||||
let msg = TranscriptMessage::new(MessageKind::Agent, MessageSource::ProviderAnswer, vec![
|
||||
ContentPart::text("ok"),
|
||||
ContentPart::Text {
|
||||
text: "ok".to_string(),
|
||||
},
|
||||
]);
|
||||
let props = AgentMessageProps {
|
||||
text: "ok".to_string(),
|
||||
|
|
@ -624,8 +625,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn agent_tool_started_props_carries_canonical_tool_call_and_linkage() {
|
||||
let mut tc = ToolCall::new("call_1", "Bash", json!({"cmd": "ls"}));
|
||||
tc.provider_metadata = Some(json!({"thought_signature": "sig"}));
|
||||
let mut tc = ToolCall::function("call_1", "Bash", json!({"cmd": "ls"}));
|
||||
tc.provider_metadata
|
||||
.insert("gemini".to_string(), json!({"thought_signature": "sig"}));
|
||||
let parent = MessageId::new();
|
||||
let turn = TurnId::new();
|
||||
let props = AgentToolStartedProps {
|
||||
|
|
@ -639,7 +641,7 @@ mod tests {
|
|||
};
|
||||
let v = serde_json::to_value(&props).unwrap();
|
||||
assert_eq!(
|
||||
v["tool_call"]["provider_metadata"]["thought_signature"],
|
||||
v["tool_call"]["provider_metadata"]["gemini"]["thought_signature"],
|
||||
"sig"
|
||||
);
|
||||
assert_eq!(v["turn_id"], turn.to_string());
|
||||
|
|
@ -667,7 +669,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn agent_tool_completed_props_carries_canonical_tool_result() {
|
||||
let tr = ToolResult::success("call_1", json!({"stdout": "ok"}));
|
||||
let tr = tool_result_from_json("call_1", json!({"stdout": "ok"}), false);
|
||||
let turn = TurnId::new();
|
||||
let props = AgentToolCompletedProps {
|
||||
tool_name: "Bash".to_string(),
|
||||
|
|
@ -682,7 +684,7 @@ mod tests {
|
|||
turn_id: Some(turn),
|
||||
};
|
||||
let v = serde_json::to_value(&props).unwrap();
|
||||
assert_eq!(v["tool_result"]["content"]["stdout"], "ok");
|
||||
assert_eq!(v["tool_result"]["content"][0]["value"]["stdout"], "ok");
|
||||
assert_eq!(v["output_bytes_observed"], 120);
|
||||
assert_eq!(v["output_bytes_retained"], 100);
|
||||
assert_eq!(v["output_bytes_omitted"], 20);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
use fabro_model::ReasoningEffort;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ExecOutputTail;
|
||||
use crate::{
|
||||
CommandTermination, ParallelBranchResult, PullRequestCreationId, PullRequestLink, ReviewTarget,
|
||||
StageId, StageOutcome,
|
||||
CommandTermination, ParallelBranchResult, PullRequestCreationId, PullRequestLink,
|
||||
ReasoningEffort, ReviewTarget, StageId, StageOutcome,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ pub mod todo;
|
|||
|
||||
pub use agent::*;
|
||||
use chrono::{DateTime, Utc};
|
||||
pub use fabro_model::BilledTokenCounts;
|
||||
pub use infra::*;
|
||||
pub use misc::*;
|
||||
pub use run::*;
|
||||
|
|
@ -20,7 +19,7 @@ pub use session::*;
|
|||
pub use stage::*;
|
||||
pub use todo::*;
|
||||
|
||||
use crate::{ParallelBranchId, Principal, RunId, StageId, UsdMicros};
|
||||
use crate::{BilledTokenCounts, ParallelBranchId, Principal, RunId, StageId};
|
||||
|
||||
/// Maximum accepted body size for `POST /runs/{id}/events`.
|
||||
///
|
||||
|
|
@ -920,8 +919,10 @@ impl RunEvent {
|
|||
}
|
||||
}
|
||||
|
||||
/// Upgrades historical wire shapes only in the value being decoded. Legacy
|
||||
/// importers still retain and compare the original stored JSON.
|
||||
/// Upgrades historical envelope shapes only in the value being decoded.
|
||||
///
|
||||
/// Event bodies carry no compatibility rewrites: Fabro is greenfield, so a
|
||||
/// stored body either matches the current schema or fails to decode.
|
||||
fn normalize_legacy_event(value: &mut Value) {
|
||||
let Some(event) = value
|
||||
.get("event")
|
||||
|
|
@ -941,81 +942,17 @@ fn normalize_legacy_event_properties(event: &str, properties: &mut Value) {
|
|||
return;
|
||||
};
|
||||
match event {
|
||||
"agent.message" => normalize_legacy_agent_message(object),
|
||||
"run.completed" => normalize_legacy_timing(object, false),
|
||||
"run.failed" => {
|
||||
normalize_legacy_run_failure(object);
|
||||
normalize_legacy_timing(object, false);
|
||||
}
|
||||
"stage.completed" => {
|
||||
normalize_legacy_usage_field(object);
|
||||
normalize_legacy_billing_field(object, "billing");
|
||||
normalize_legacy_timing(object, true);
|
||||
}
|
||||
"stage.failed" => normalize_legacy_billing_field(object, "billing"),
|
||||
"prompt.completed" => {
|
||||
normalize_legacy_usage_field(object);
|
||||
normalize_legacy_billing_field(object, "billing");
|
||||
}
|
||||
"checkpoint.completed" => normalize_legacy_checkpoint_billing(object),
|
||||
"stage.completed" => normalize_legacy_timing(object, true),
|
||||
"sandbox.initialized" => normalize_legacy_sandbox_id(object),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_legacy_agent_message(properties: &mut Map<String, Value>) {
|
||||
let speed = properties
|
||||
.get("usage")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|usage| usage.get("speed"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
if let Some(model_id) = properties.get("model").and_then(Value::as_str) {
|
||||
let mut model = Map::from_iter([
|
||||
(
|
||||
"provider".to_owned(),
|
||||
Value::String(legacy_provider_for_model(model_id).to_owned()),
|
||||
),
|
||||
("model_id".to_owned(), Value::String(model_id.to_owned())),
|
||||
]);
|
||||
if let Some(speed @ ("standard" | "fast")) = speed.as_deref() {
|
||||
model.insert("speed".to_owned(), Value::String(speed.to_owned()));
|
||||
}
|
||||
properties.insert("model".to_owned(), Value::Object(model));
|
||||
}
|
||||
if !properties.contains_key("billing") {
|
||||
if let Some(usage) = properties.remove("usage") {
|
||||
properties.insert("billing".to_owned(), usage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_legacy_usage_field(properties: &mut Map<String, Value>) {
|
||||
if !properties.contains_key("billing") {
|
||||
if let Some(usage) = properties.remove("usage") {
|
||||
properties.insert("billing".to_owned(), usage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_legacy_billing_field(properties: &mut Map<String, Value>, field: &str) {
|
||||
if let Some(billing) = properties.get_mut(field) {
|
||||
normalize_legacy_billing_values(billing);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_legacy_checkpoint_billing(properties: &mut Map<String, Value>) {
|
||||
let Some(outcomes) = properties
|
||||
.get_mut("node_outcomes")
|
||||
.and_then(Value::as_object_mut)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for outcome in outcomes.values_mut().filter_map(Value::as_object_mut) {
|
||||
normalize_legacy_billing_field(outcome, "usage");
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_legacy_timing(properties: &mut Map<String, Value>, stage: bool) {
|
||||
if properties.contains_key("timing") {
|
||||
return;
|
||||
|
|
@ -1076,135 +1013,6 @@ fn normalize_legacy_sandbox_id(properties: &mut Map<String, Value>) {
|
|||
properties.insert("id".to_owned(), Value::String(id.to_owned()));
|
||||
}
|
||||
|
||||
fn normalize_legacy_billing_values(value: &mut Value) {
|
||||
if legacy_stage_usage(value) {
|
||||
let legacy = std::mem::take(value);
|
||||
*value = normalized_legacy_stage_usage(&legacy);
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
normalize_legacy_billing_values(value);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
if let Some(facts) = object.get_mut("facts").and_then(Value::as_object_mut) {
|
||||
if !facts.contains_key("algorithm") {
|
||||
let provider = facts
|
||||
.get("provider")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
if let Some(provider) = provider {
|
||||
facts.remove("provider");
|
||||
facts.insert(
|
||||
"algorithm".to_owned(),
|
||||
Value::String(legacy_billing_algorithm(&provider).to_owned()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for value in object.values_mut() {
|
||||
normalize_legacy_billing_values(value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_stage_usage(value: &Value) -> bool {
|
||||
let Some(object) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
object.get("model").is_some_and(Value::is_string)
|
||||
&& object.get("input_tokens").is_some_and(Value::is_number)
|
||||
&& object.get("output_tokens").is_some_and(Value::is_number)
|
||||
}
|
||||
|
||||
fn normalized_legacy_stage_usage(legacy: &Value) -> Value {
|
||||
let object = legacy
|
||||
.as_object()
|
||||
.expect("legacy stage usage was validated as an object");
|
||||
let model_id = object
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.expect("legacy stage usage was validated with a string model");
|
||||
let provider = legacy_provider_for_model(model_id);
|
||||
let mut model = json!({
|
||||
"provider": provider,
|
||||
"model_id": model_id,
|
||||
});
|
||||
if let Some(speed @ ("standard" | "fast")) = object.get("speed").and_then(Value::as_str) {
|
||||
model["speed"] = Value::String(speed.to_owned());
|
||||
}
|
||||
let input_tokens = object
|
||||
.get("input_tokens")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = object
|
||||
.get("output_tokens")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0);
|
||||
let reasoning_tokens = object
|
||||
.get("reasoning_tokens")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0);
|
||||
let cache_read_tokens = object
|
||||
.get("cache_read_tokens")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0);
|
||||
let cache_write_tokens = object
|
||||
.get("cache_write_tokens")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0);
|
||||
let mut normalized = json!({
|
||||
"input": {
|
||||
"usage": {
|
||||
"model": model,
|
||||
"tokens": {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"reasoning_tokens": reasoning_tokens,
|
||||
"cache_read_tokens": cache_read_tokens,
|
||||
"cache_write_tokens": cache_write_tokens,
|
||||
}
|
||||
},
|
||||
"facts": {
|
||||
"algorithm": legacy_billing_algorithm(provider),
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(cost) = object.get("cost").and_then(Value::as_f64) {
|
||||
normalized["total_usd_micros"] = Value::from(UsdMicros::from_usd(cost).0);
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn legacy_provider_for_model(model_id: &str) -> &'static str {
|
||||
if model_id.starts_with("claude-") {
|
||||
"anthropic"
|
||||
} else if model_id.starts_with("gemini-") {
|
||||
"gemini"
|
||||
} else if model_id.starts_with("gpt-")
|
||||
|| model_id.starts_with("chatgpt-")
|
||||
|| model_id.starts_with("o1")
|
||||
|| model_id.starts_with("o3")
|
||||
|| model_id.starts_with("o4")
|
||||
{
|
||||
"openai"
|
||||
} else {
|
||||
"legacy"
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_billing_algorithm(provider: &str) -> &'static str {
|
||||
match provider {
|
||||
"anthropic" => "anthropic",
|
||||
"gemini" => "gemini",
|
||||
_ => "openai",
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for RunEvent {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
|
|
@ -1232,8 +1040,8 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
use crate::{
|
||||
AuthMethod, BlobHash, CommandTermination, Edge, Graph, IdpIdentity, Node, PendingReason,
|
||||
WorkflowSettings, fixtures, test_support,
|
||||
AuthMethod, BlobHash, CommandTermination, Edge, Graph, IdpIdentity, ModelRef, Node,
|
||||
PendingReason, WorkflowSettings, fixtures, provider_ids, test_support,
|
||||
};
|
||||
|
||||
fn user_principal(login: &str) -> Principal {
|
||||
|
|
@ -1377,138 +1185,6 @@ mod tests {
|
|||
assert_eq!(props.settings.run, WorkflowSettings::default().run);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_agent_message_accepts_string_model() {
|
||||
let line = stored_event(
|
||||
"agent.message",
|
||||
&json!({
|
||||
"text": "done",
|
||||
"model": "gemini-3.1-pro-preview",
|
||||
"billing": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5,
|
||||
"total_tokens": 15
|
||||
},
|
||||
"tool_call_count": 0,
|
||||
"visit": 1
|
||||
}),
|
||||
);
|
||||
|
||||
let parsed = RunEvent::from_value(line).unwrap();
|
||||
let normalized = parsed.to_value().unwrap();
|
||||
|
||||
assert_eq!(normalized["properties"]["model"]["provider"], "gemini");
|
||||
assert_eq!(
|
||||
normalized["properties"]["model"]["model_id"],
|
||||
"gemini-3.1-pro-preview"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_stage_usage_and_duration_are_upgraded() {
|
||||
let line = stored_event(
|
||||
"stage.completed",
|
||||
&json!({
|
||||
"index": 0,
|
||||
"duration_ms": 42,
|
||||
"status": "succeeded",
|
||||
"usage": {
|
||||
"model": "claude-sonnet-4-6",
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 20,
|
||||
"cache_read_tokens": 7,
|
||||
"cache_write_tokens": 3,
|
||||
"reasoning_tokens": 2,
|
||||
"speed": "fast",
|
||||
"cost": 0.012_345
|
||||
},
|
||||
"attempt": 1,
|
||||
"max_attempts": 1
|
||||
}),
|
||||
);
|
||||
|
||||
let parsed = RunEvent::from_value(line).unwrap();
|
||||
let normalized = parsed.to_value().unwrap();
|
||||
let properties = &normalized["properties"];
|
||||
|
||||
assert_eq!(properties["timing"]["wall_time_ms"], 42);
|
||||
assert_eq!(
|
||||
properties["billing"]["input"]["facts"]["algorithm"],
|
||||
"anthropic"
|
||||
);
|
||||
assert_eq!(
|
||||
properties["billing"]["input"]["usage"]["model"]["speed"],
|
||||
"fast"
|
||||
);
|
||||
assert_eq!(properties["billing"]["total_usd_micros"], 12_345);
|
||||
assert!(properties.get("duration_ms").is_none());
|
||||
assert!(properties.get("usage").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_billing_provider_tags_are_upgraded() {
|
||||
let legacy_billing = json!({
|
||||
"input": {
|
||||
"usage": {
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"model_id": "claude-sonnet-4-6"
|
||||
},
|
||||
"tokens": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 20,
|
||||
"reasoning_tokens": 0,
|
||||
"cache_read_tokens": 7,
|
||||
"cache_write_tokens": 3
|
||||
}
|
||||
},
|
||||
"facts": {
|
||||
"provider": "anthropic",
|
||||
"cache_write_5m_tokens": 3,
|
||||
"cache_write_1h_tokens": 0
|
||||
}
|
||||
},
|
||||
"total_usd_micros": 123
|
||||
});
|
||||
let prompt = stored_event(
|
||||
"prompt.completed",
|
||||
&json!({
|
||||
"response": "done",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"provider": "anthropic",
|
||||
"billing": legacy_billing.clone()
|
||||
}),
|
||||
);
|
||||
let checkpoint = stored_event(
|
||||
"checkpoint.completed",
|
||||
&json!({
|
||||
"status": "succeeded",
|
||||
"current_node": "build",
|
||||
"node_outcomes": {
|
||||
"build": {
|
||||
"status": "succeeded",
|
||||
"usage": legacy_billing
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let prompt = RunEvent::from_value(prompt).unwrap().to_value().unwrap();
|
||||
let checkpoint = RunEvent::from_value(checkpoint)
|
||||
.unwrap()
|
||||
.to_value()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prompt["properties"]["billing"]["input"]["facts"]["algorithm"],
|
||||
"anthropic"
|
||||
);
|
||||
assert_eq!(
|
||||
checkpoint["properties"]["node_outcomes"]["build"]["usage"]["input"]["facts"]["algorithm"],
|
||||
"anthropic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_terminal_and_sandbox_events_are_upgraded() {
|
||||
let completed = stored_event(
|
||||
|
|
@ -2688,11 +2364,7 @@ mod tests {
|
|||
fn agent_message_omits_context_window_when_absent() {
|
||||
let body = EventBody::AgentMessage(AgentMessageProps {
|
||||
text: "ok".to_string(),
|
||||
model: crate::ModelRef {
|
||||
provider: fabro_model::ProviderId::openai(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
},
|
||||
model: ModelRef::new(provider_ids::openai(), "gpt-5.4".into()),
|
||||
billing: BilledTokenCounts::default(),
|
||||
cost_source: None,
|
||||
tool_call_count: 0,
|
||||
|
|
@ -2719,11 +2391,7 @@ mod tests {
|
|||
fn agent_message_omits_reasoning_when_absent() {
|
||||
let body = EventBody::AgentMessage(AgentMessageProps {
|
||||
text: "ok".to_string(),
|
||||
model: crate::ModelRef {
|
||||
provider: fabro_model::ProviderId::openai(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
},
|
||||
model: ModelRef::new(provider_ids::openai(), "gpt-5.4".into()),
|
||||
billing: BilledTokenCounts::default(),
|
||||
cost_source: None,
|
||||
tool_call_count: 0,
|
||||
|
|
@ -2747,11 +2415,7 @@ mod tests {
|
|||
fn agent_message_carries_reasoning_through_canonical_json() {
|
||||
let body = EventBody::AgentMessage(AgentMessageProps {
|
||||
text: String::new(),
|
||||
model: crate::ModelRef {
|
||||
provider: fabro_model::ProviderId::openai(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
},
|
||||
model: ModelRef::new(provider_ids::openai(), "gpt-5.4".into()),
|
||||
billing: BilledTokenCounts::default(),
|
||||
cost_source: None,
|
||||
tool_call_count: 1,
|
||||
|
|
@ -2804,11 +2468,7 @@ mod tests {
|
|||
};
|
||||
let body = EventBody::AgentMessage(AgentMessageProps {
|
||||
text: "ok".to_string(),
|
||||
model: crate::ModelRef {
|
||||
provider: fabro_model::ProviderId::openai(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
},
|
||||
model: ModelRef::new(provider_ids::openai(), "gpt-5.4".into()),
|
||||
billing: BilledTokenCounts::default(),
|
||||
cost_source: None,
|
||||
tool_call_count: 0,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
use fabro_model::ProviderId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::TurnId;
|
||||
use crate::{ProviderId, TurnId};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RunSessionCreatedProps {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use fabro_model::{ReasoningEffort, Speed};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::ExecOutputTail;
|
||||
use crate::{
|
||||
BilledModelUsage, DiffSummary, FailureDetail, Outcome, StageId, StageOutcome, StageTiming,
|
||||
BilledModelUsage, DiffSummary, FailureDetail, Outcome, ReasoningEffort, Speed, StageId,
|
||||
StageOutcome, StageTiming,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|||
use std::num::NonZeroU32;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_model::{Catalog, ReasoningEffort, Speed};
|
||||
use strum::{Display, EnumString, IntoStaticStr};
|
||||
|
||||
use crate::run_event::{AgentSessionActivatedProps, StagePromptProps};
|
||||
|
|
@ -11,9 +10,9 @@ use crate::{
|
|||
AgentBackend, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary,
|
||||
AgentToolSummary, BilledTokenCounts, Checkpoint, Conclusion, InterviewQuestionRecord,
|
||||
InvalidTransition, LlmOutputKind, ModelRef, ParallelBranchId, PermissionLevel,
|
||||
PullRequestCreation, PullRequestLink, RunApproval, RunControlAction, RunDiff, RunId,
|
||||
RunSandbox, RunSpec, RunStatus, RunTiming, StageCompletion, StageHandler, StageId, StageState,
|
||||
StageTiming, StartRecord, TodoListProjection, timing,
|
||||
PullRequestCreation, PullRequestLink, ReasoningEffort, RunApproval, RunControlAction, RunDiff,
|
||||
RunId, RunSandbox, RunSpec, RunStatus, RunTiming, Speed, StageCompletion, StageHandler,
|
||||
StageId, StageState, StageTiming, StartRecord, TodoListProjection, timing,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
|
|
@ -605,29 +604,6 @@ impl StageProjection {
|
|||
self.state
|
||||
}
|
||||
|
||||
/// This stage's token counts with a cost attached.
|
||||
///
|
||||
/// A provider-reported cost always wins. Otherwise the catalog prices the
|
||||
/// recorded tokens for the stage's model. The stored counts pass through
|
||||
/// untouched when there is no catalog, no model, or no price for that
|
||||
/// model. Empty usage also passes through untouched. These cases leave
|
||||
/// `total_usd_micros` as `None` rather than zero.
|
||||
#[must_use]
|
||||
pub fn billed_usage(&self, catalog: Option<&Catalog>) -> Cow<'_, BilledTokenCounts> {
|
||||
if self.usage.total_usd_micros.is_some() || self.usage.is_zero() {
|
||||
return Cow::Borrowed(&self.usage);
|
||||
}
|
||||
let (Some(catalog), Some(model)) = (catalog, self.model.as_ref()) else {
|
||||
return Cow::Borrowed(&self.usage);
|
||||
};
|
||||
let Some(total_usd_micros) = catalog.price_tokens(model, &self.usage.token_counts()) else {
|
||||
return Cow::Borrowed(&self.usage);
|
||||
};
|
||||
let mut usage = self.usage.clone();
|
||||
usage.total_usd_micros = Some(total_usd_micros);
|
||||
Cow::Owned(usage)
|
||||
}
|
||||
|
||||
/// Live wall-clock time in milliseconds.
|
||||
///
|
||||
/// While the stage is non-terminal (`Pending`, `Running`, or `Retrying`),
|
||||
|
|
@ -1119,11 +1095,10 @@ mod iter_stages_tests {
|
|||
use std::num::NonZeroU32;
|
||||
|
||||
use chrono::Utc;
|
||||
use fabro_model::{Catalog, ModelRef, ProviderId};
|
||||
use serde_json::json;
|
||||
|
||||
use super::RunProjection;
|
||||
use crate::{AgentControlState, BilledTokenCounts, StageProjection, test_support};
|
||||
use crate::{AgentControlState, StageProjection, test_support};
|
||||
|
||||
fn seq(n: u32) -> NonZeroU32 {
|
||||
NonZeroU32::new(n).unwrap()
|
||||
|
|
@ -1230,77 +1205,6 @@ mod iter_stages_tests {
|
|||
assert_eq!(order, vec!["build@1", "verify@1", "verify@2"]);
|
||||
}
|
||||
}
|
||||
|
||||
fn priced_stage(total_usd_micros: Option<i64>) -> StageProjection {
|
||||
let mut stage = StageProjection::new(seq(1));
|
||||
stage.usage = BilledTokenCounts {
|
||||
input_tokens: 500_000,
|
||||
output_tokens: 125_000,
|
||||
total_tokens: 625_000,
|
||||
total_usd_micros,
|
||||
..BilledTokenCounts::default()
|
||||
};
|
||||
stage.model = Some(ModelRef {
|
||||
provider: ProviderId::openai(),
|
||||
model_id: "gpt-5.4".into(),
|
||||
speed: None,
|
||||
});
|
||||
stage
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billed_usage_prices_uncosted_tokens_from_the_catalog() {
|
||||
let stage = priced_stage(None);
|
||||
|
||||
assert_eq!(stage.billed_usage(None).total_usd_micros, None);
|
||||
let priced = stage.billed_usage(Some(Catalog::builtin()));
|
||||
assert!(
|
||||
priced.total_usd_micros.is_some_and(|cost| cost > 0),
|
||||
"expected a catalog price, got {:?}",
|
||||
priced.total_usd_micros
|
||||
);
|
||||
// Pricing only fills in the cost; the token buckets pass through.
|
||||
assert_eq!(priced.input_tokens, 500_000);
|
||||
assert_eq!(priced.output_tokens, 125_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billed_usage_keeps_a_provider_reported_cost_over_the_catalog_estimate() {
|
||||
let stage = priced_stage(Some(42));
|
||||
|
||||
assert_eq!(
|
||||
stage
|
||||
.billed_usage(Some(Catalog::builtin()))
|
||||
.total_usd_micros,
|
||||
Some(42)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billed_usage_leaves_a_modelless_stage_uncosted() {
|
||||
let mut stage = priced_stage(None);
|
||||
stage.model = None;
|
||||
|
||||
assert_eq!(
|
||||
stage
|
||||
.billed_usage(Some(Catalog::builtin()))
|
||||
.total_usd_micros,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billed_usage_leaves_zero_tokens_uncosted() {
|
||||
let mut stage = priced_stage(None);
|
||||
stage.usage = BilledTokenCounts::default();
|
||||
|
||||
assert_eq!(
|
||||
stage
|
||||
.billed_usage(Some(Catalog::builtin()))
|
||||
.total_usd_micros,
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -1334,11 +1238,7 @@ mod live_timing_tests {
|
|||
StageInferenceProjection {
|
||||
session_id: "session-1".to_string(),
|
||||
started_at,
|
||||
requested_model: ModelRef {
|
||||
provider: "anthropic".parse().unwrap(),
|
||||
model_id: "claude-sonnet-5".into(),
|
||||
speed: None,
|
||||
},
|
||||
requested_model: ModelRef::new("anthropic".into(), "claude-sonnet-5".into()),
|
||||
first_output_at: None,
|
||||
first_output_kind: None,
|
||||
retries: 0,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use fabro_model::ProviderId;
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::{Display, EnumString, IntoStaticStr};
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use lithos_llm::catalog::Catalog;
|
||||
use serde::de::{self, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
|
|
@ -228,14 +229,14 @@ impl ModelRef {
|
|||
}
|
||||
}
|
||||
|
||||
impl ModelRegistry for fabro_model::Catalog {
|
||||
impl ModelRegistry for Catalog {
|
||||
fn is_provider(&self, token: &str) -> bool {
|
||||
self.provider(&fabro_model::ProviderId::from(token))
|
||||
.is_some()
|
||||
self.provider(token).is_ok()
|
||||
}
|
||||
|
||||
fn is_model(&self, token: &str) -> bool {
|
||||
self.is_model_selector(token)
|
||||
self.providers()
|
||||
.any(|provider| provider.model(token).is_some())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
//! Canonical provider-neutral transcript primitives.
|
||||
//! Canonical transcript primitives.
|
||||
//!
|
||||
//! These types are the durable replay shapes for agent sessions. They were
|
||||
//! promoted from `fabro-llm` so the Fabro event stream, API responses, and
|
||||
//! runtime history can share one canonical Rust model rather than ferrying
|
||||
//! parallel DTOs between layers. `fabro-llm::types` re-exports these so
|
||||
//! existing imports keep working.
|
||||
//! The message vocabulary (`Message`, `ContentPart`, `ToolCall`, `ToolResult`,
|
||||
//! and friends) is lithos's, re-exported here so the event stream, API
|
||||
//! responses, and runtime history share one Rust model. [`TranscriptMessage`]
|
||||
//! is Fabro's durable replay record: identity, provenance, and usage wrapped
|
||||
//! around lithos content parts.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_model::{ModelRef, TokenCounts};
|
||||
use serde::{Deserialize, Serialize, de};
|
||||
pub use lithos_llm::types::{
|
||||
AudioContent, ContentPart, DocumentContent, ImageContent, MediaSource, Message,
|
||||
ReasoningContent, Role, TokenCounts, ToolArgumentError, ToolArguments, ToolCall, ToolCallKind,
|
||||
ToolChoice, ToolDefinition, ToolDefinitionKind, ToolInput, ToolResult, UnknownContent,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::{Display, EnumString, IntoStaticStr};
|
||||
|
||||
use crate::billing::ModelRef;
|
||||
use crate::id::ulid_id;
|
||||
use crate::pair::{PairId, PairMessageId};
|
||||
use crate::principal::Principal;
|
||||
|
|
@ -18,336 +23,68 @@ use crate::session::TurnId;
|
|||
|
||||
ulid_id!(MessageId);
|
||||
|
||||
// --- Content data structures -------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImageData {
|
||||
pub url: Option<String>,
|
||||
pub data: Option<Vec<u8>>,
|
||||
pub media_type: Option<String>,
|
||||
pub detail: Option<String>,
|
||||
/// Concatenates the text parts of a message or response.
|
||||
#[must_use]
|
||||
pub fn text_of(parts: &[ContentPart]) -> String {
|
||||
parts
|
||||
.iter()
|
||||
.filter_map(|part| match part {
|
||||
ContentPart::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AudioData {
|
||||
pub url: Option<String>,
|
||||
pub data: Option<Vec<u8>>,
|
||||
pub media_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DocumentData {
|
||||
pub url: Option<String>,
|
||||
pub data: Option<Vec<u8>>,
|
||||
pub media_type: Option<String>,
|
||||
pub file_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ThinkingData {
|
||||
pub text: String,
|
||||
pub signature: Option<String>,
|
||||
pub redacted: bool,
|
||||
}
|
||||
|
||||
// --- Tool call / tool result -------------------------------------------------
|
||||
|
||||
fn default_tool_type() -> String {
|
||||
"function".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type", default = "default_tool_type")]
|
||||
pub tool_type: String,
|
||||
pub arguments: serde_json::Value,
|
||||
pub raw_arguments: Option<String>,
|
||||
/// Opaque provider-specific metadata (e.g. Gemini `thought_signature`).
|
||||
/// Preserved across round-trips so the provider can include it when
|
||||
/// sending conversation history back to the API.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub provider_metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
arguments: serde_json::Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
tool_type: "function".to_string(),
|
||||
arguments,
|
||||
raw_arguments: None,
|
||||
provider_metadata: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolResult {
|
||||
pub tool_call_id: String,
|
||||
pub content: serde_json::Value,
|
||||
pub is_error: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_data: Option<Vec<u8>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_media_type: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
pub fn success(id: impl Into<String>, content: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_call_id: id.into(),
|
||||
content,
|
||||
is_error: false,
|
||||
image_data: None,
|
||||
image_media_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(id: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_call_id: id.into(),
|
||||
content: serde_json::Value::String(message.into()),
|
||||
is_error: true,
|
||||
image_data: None,
|
||||
image_media_type: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ContentPart -------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ContentPart {
|
||||
Text(String),
|
||||
Image(ImageData),
|
||||
Audio(AudioData),
|
||||
Document(DocumentData),
|
||||
ToolCall(ToolCall),
|
||||
ToolResult(ToolResult),
|
||||
Thinking(ThinkingData),
|
||||
Other {
|
||||
kind: String,
|
||||
data: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
impl Serialize for ContentPart {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeMap;
|
||||
let mut map = serializer.serialize_map(Some(2))?;
|
||||
match self {
|
||||
Self::Text(v) => {
|
||||
map.serialize_entry("kind", "text")?;
|
||||
map.serialize_entry("data", v)?;
|
||||
}
|
||||
Self::Image(v) => {
|
||||
map.serialize_entry("kind", "image")?;
|
||||
map.serialize_entry("data", v)?;
|
||||
}
|
||||
Self::Audio(v) => {
|
||||
map.serialize_entry("kind", "audio")?;
|
||||
map.serialize_entry("data", v)?;
|
||||
}
|
||||
Self::Document(v) => {
|
||||
map.serialize_entry("kind", "document")?;
|
||||
map.serialize_entry("data", v)?;
|
||||
}
|
||||
Self::ToolCall(v) => {
|
||||
map.serialize_entry("kind", "tool_call")?;
|
||||
map.serialize_entry("data", v)?;
|
||||
}
|
||||
Self::ToolResult(v) => {
|
||||
map.serialize_entry("kind", "tool_result")?;
|
||||
map.serialize_entry("data", v)?;
|
||||
}
|
||||
Self::Thinking(v) => {
|
||||
let kind = if v.redacted {
|
||||
"redacted_thinking"
|
||||
} else {
|
||||
"thinking"
|
||||
};
|
||||
map.serialize_entry("kind", kind)?;
|
||||
map.serialize_entry("data", v)?;
|
||||
}
|
||||
Self::Other { kind, data } => {
|
||||
map.serialize_entry("kind", kind)?;
|
||||
map.serialize_entry("data", data)?;
|
||||
}
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ContentPart {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
let kind = value
|
||||
.get("kind")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| de::Error::missing_field("kind"))?;
|
||||
let data = value
|
||||
.get("data")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
match kind {
|
||||
"text" => serde_json::from_value(data)
|
||||
.map(Self::Text)
|
||||
.map_err(de::Error::custom),
|
||||
"image" => serde_json::from_value(data)
|
||||
.map(Self::Image)
|
||||
.map_err(de::Error::custom),
|
||||
"audio" => serde_json::from_value(data)
|
||||
.map(Self::Audio)
|
||||
.map_err(de::Error::custom),
|
||||
"document" => serde_json::from_value(data)
|
||||
.map(Self::Document)
|
||||
.map_err(de::Error::custom),
|
||||
"tool_call" => serde_json::from_value(data)
|
||||
.map(Self::ToolCall)
|
||||
.map_err(de::Error::custom),
|
||||
"tool_result" => serde_json::from_value(data)
|
||||
.map(Self::ToolResult)
|
||||
.map_err(de::Error::custom),
|
||||
"thinking" => serde_json::from_value(data)
|
||||
.map(Self::Thinking)
|
||||
.map_err(de::Error::custom),
|
||||
"redacted_thinking" => serde_json::from_value::<ThinkingData>(data)
|
||||
.map(|mut td| {
|
||||
td.redacted = true;
|
||||
Self::Thinking(td)
|
||||
})
|
||||
.map_err(de::Error::custom),
|
||||
other => Ok(Self::Other {
|
||||
kind: other.to_string(),
|
||||
data,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContentPart {
|
||||
/// Kind string for opaque OpenAI reasoning output items.
|
||||
pub const OPENAI_REASONING: &str = "openai_reasoning";
|
||||
/// Kind string for opaque OpenAI message output items.
|
||||
pub const OPENAI_MESSAGE: &str = "openai_message";
|
||||
/// Kind string for opaque OpenAI-compatible `reasoning_details` entries.
|
||||
/// The data is the received array of detail objects, preserved verbatim
|
||||
/// so encrypted entries survive for future provider-aware replay. Only
|
||||
/// known readable members are ever normalized out of it.
|
||||
pub const OPENAI_COMPAT_REASONING_DETAILS: &str = "openai_compat_reasoning_details";
|
||||
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
Self::Text(text.into())
|
||||
}
|
||||
|
||||
/// Returns `true` if this is an opaque OpenAI item (reasoning or message)
|
||||
/// that should be round-tripped verbatim through the API.
|
||||
pub fn is_opaque_openai(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Other { kind, .. }
|
||||
if kind == Self::OPENAI_REASONING || kind == Self::OPENAI_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Role / Message
|
||||
// -----------------------------------------------------------
|
||||
|
||||
/// Author role of a chat [`Message`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Role {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
Developer,
|
||||
}
|
||||
|
||||
/// Provider-neutral chat message exchanged with an LLM.
|
||||
/// Builds a tool result whose content is one JSON value.
|
||||
///
|
||||
/// This is the request/response message shape shared by `fabro-llm`
|
||||
/// requests and the completions API wire contract. The durable
|
||||
/// session-transcript record is [`TranscriptMessage`], which carries
|
||||
/// identity, provenance, and usage on top of the same [`ContentPart`]
|
||||
/// vocabulary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
pub role: Role,
|
||||
pub content: Vec<ContentPart>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// Plain strings become a text part so providers render them as text; every
|
||||
/// other value is carried as structured JSON.
|
||||
#[must_use]
|
||||
pub fn tool_result_from_json(
|
||||
tool_call_id: impl Into<String>,
|
||||
content: serde_json::Value,
|
||||
is_error: bool,
|
||||
) -> ToolResult {
|
||||
let part = match content {
|
||||
serde_json::Value::String(text) => ContentPart::Text { text },
|
||||
value => ContentPart::Json { value },
|
||||
};
|
||||
ToolResult {
|
||||
tool_call_id: tool_call_id.into(),
|
||||
name: None,
|
||||
content: vec![part],
|
||||
is_error,
|
||||
}
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn system(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: Role::System,
|
||||
content: vec![ContentPart::text(text)],
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
}
|
||||
/// Projects a tool result back to one JSON value, the inverse of
|
||||
/// [`tool_result_from_json`].
|
||||
///
|
||||
/// A lone text part becomes a string and a lone JSON part its value. Any
|
||||
/// other shape is carried as the array of serialized parts.
|
||||
#[must_use]
|
||||
pub fn tool_result_to_json(result: &ToolResult) -> serde_json::Value {
|
||||
match result.content.as_slice() {
|
||||
[ContentPart::Text { text }] => serde_json::Value::String(text.clone()),
|
||||
[ContentPart::Json { value }] => value.clone(),
|
||||
parts => serde_json::Value::Array(
|
||||
parts
|
||||
.iter()
|
||||
.map(|part| serde_json::to_value(part).unwrap_or(serde_json::Value::Null))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: Role::User,
|
||||
content: vec![ContentPart::text(text)],
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assistant(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentPart::text(text)],
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_result(
|
||||
tool_call_id: impl Into<String>,
|
||||
content: serde_json::Value,
|
||||
is_error: bool,
|
||||
) -> Self {
|
||||
let id = tool_call_id.into();
|
||||
Self {
|
||||
role: Role::Tool,
|
||||
content: vec![ContentPart::ToolResult(ToolResult {
|
||||
tool_call_id: id.clone(),
|
||||
content,
|
||||
is_error,
|
||||
image_data: None,
|
||||
image_media_type: None,
|
||||
})],
|
||||
name: None,
|
||||
tool_call_id: Some(id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Concatenates text from all text content parts.
|
||||
#[must_use]
|
||||
pub fn text(&self) -> String {
|
||||
self.content
|
||||
.iter()
|
||||
.filter_map(|part| match part {
|
||||
ContentPart::Text(text) => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The arguments of a tool call as one JSON value.
|
||||
///
|
||||
/// Function arguments are the parsed JSON object; malformed arguments and
|
||||
/// custom free-form input are carried as their raw text.
|
||||
#[must_use]
|
||||
pub fn tool_call_arguments(call: &ToolCall) -> serde_json::Value {
|
||||
call.input
|
||||
.to_value()
|
||||
.unwrap_or_else(|_| serde_json::Value::String(call.input.raw().to_string()))
|
||||
}
|
||||
|
||||
// --- TranscriptMessage ------------------------------------------------------
|
||||
|
|
@ -423,7 +160,7 @@ pub struct PairMessageRef {
|
|||
/// Canonical durable transcript message.
|
||||
///
|
||||
/// Named `TranscriptMessage` rather than `Message` to avoid import ambiguity
|
||||
/// with `fabro_agent::Message` and `fabro_llm::types::Message`.
|
||||
/// with `fabro_agent::Message` and the lithos request [`Message`].
|
||||
///
|
||||
/// `kind` captures provider/model-role semantics for replay; `source`
|
||||
/// captures audit/UI provenance. Both are required to faithfully reconstruct
|
||||
|
|
@ -480,59 +217,30 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn content_part_text_roundtrips() {
|
||||
let part = ContentPart::text("hello");
|
||||
let v = serde_json::to_value(&part).unwrap();
|
||||
assert_eq!(v, json!({"kind": "text", "data": "hello"}));
|
||||
let back: ContentPart = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, part);
|
||||
fn text_of_concatenates_text_parts_only() {
|
||||
let parts = vec![
|
||||
ContentPart::Text {
|
||||
text: "hello ".to_string(),
|
||||
},
|
||||
ContentPart::Json { value: json!(1) },
|
||||
ContentPart::Text {
|
||||
text: "world".to_string(),
|
||||
},
|
||||
];
|
||||
assert_eq!(text_of(&parts), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_part_thinking_preserves_signature_and_redaction() {
|
||||
let part = ContentPart::Thinking(ThinkingData {
|
||||
text: "private thought".to_string(),
|
||||
signature: Some("sig_abc".to_string()),
|
||||
redacted: true,
|
||||
});
|
||||
let v = serde_json::to_value(&part).unwrap();
|
||||
assert_eq!(v["kind"], "redacted_thinking");
|
||||
assert_eq!(v["data"]["signature"], "sig_abc");
|
||||
let back: ContentPart = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, part);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_part_other_preserves_provider_kind() {
|
||||
let part = ContentPart::Other {
|
||||
kind: ContentPart::OPENAI_REASONING.to_string(),
|
||||
data: json!({"item_id": "rs_1", "encrypted": "x"}),
|
||||
};
|
||||
assert!(part.is_opaque_openai());
|
||||
let v = serde_json::to_value(&part).unwrap();
|
||||
let back: ContentPart = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, part);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_preserves_provider_metadata() {
|
||||
let mut tc = ToolCall::new("call_1", "Bash", json!({"cmd": "ls"}));
|
||||
tc.provider_metadata = Some(json!({"thought_signature": "sig"}));
|
||||
tc.raw_arguments = Some("{\"cmd\":\"ls\"}".to_string());
|
||||
let v = serde_json::to_value(&tc).unwrap();
|
||||
assert_eq!(v["provider_metadata"]["thought_signature"], "sig");
|
||||
let back: ToolCall = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, tc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_round_trips_with_default_image_fields() {
|
||||
let tr = ToolResult::success("call_1", json!({"ok": true}));
|
||||
let v = serde_json::to_value(&tr).unwrap();
|
||||
// Optional image fields are omitted on serialize.
|
||||
assert!(v.get("image_data").is_none());
|
||||
let back: ToolResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, tr);
|
||||
fn tool_result_from_json_keeps_strings_as_text() {
|
||||
let result = tool_result_from_json("call_1", json!("ok"), false);
|
||||
assert_eq!(result.content, vec![ContentPart::Text {
|
||||
text: "ok".to_string(),
|
||||
}]);
|
||||
let result = tool_result_from_json("call_1", json!({"ok": true}), true);
|
||||
assert!(result.is_error);
|
||||
assert_eq!(result.content, vec![ContentPart::Json {
|
||||
value: json!({"ok": true}),
|
||||
}]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -544,7 +252,9 @@ mod tests {
|
|||
source: MessageSource::Steer,
|
||||
actor: None,
|
||||
pair: None,
|
||||
content: vec![ContentPart::text("please continue")],
|
||||
content: vec![ContentPart::Text {
|
||||
text: "please continue".to_string(),
|
||||
}],
|
||||
model: None,
|
||||
response_id: None,
|
||||
usage: None,
|
||||
|
|
@ -553,6 +263,10 @@ mod tests {
|
|||
let v = serde_json::to_value(&msg).unwrap();
|
||||
assert_eq!(v["kind"], "user");
|
||||
assert_eq!(v["source"], "steer");
|
||||
assert_eq!(
|
||||
v["content"][0],
|
||||
json!({"type": "text", "text": "please continue"})
|
||||
);
|
||||
let back: TranscriptMessage = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, msg);
|
||||
}
|
||||
|
|
@ -560,7 +274,9 @@ mod tests {
|
|||
#[test]
|
||||
fn transcript_message_drops_optional_fields_on_serialize() {
|
||||
let msg = TranscriptMessage::new(MessageKind::Agent, MessageSource::ProviderAnswer, vec![
|
||||
ContentPart::text("done"),
|
||||
ContentPart::Text {
|
||||
text: "done".to_string(),
|
||||
},
|
||||
]);
|
||||
let v = serde_json::to_value(&msg).unwrap();
|
||||
let obj = v.as_object().unwrap();
|
||||
|
|
@ -574,6 +290,22 @@ mod tests {
|
|||
assert!(!obj.contains_key("created_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_message_usage_uses_lithos_buckets() {
|
||||
let mut msg =
|
||||
TranscriptMessage::new(MessageKind::Agent, MessageSource::ProviderAnswer, vec![]);
|
||||
msg.usage = Some(TokenCounts {
|
||||
input: 10,
|
||||
output: 2,
|
||||
..TokenCounts::default()
|
||||
});
|
||||
let v = serde_json::to_value(&msg).unwrap();
|
||||
assert_eq!(
|
||||
v["usage"],
|
||||
json!({"input": 10, "output": 2, "reasoning": 0, "cache_read": 0, "cache_write": 0})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_message_ref_skips_empty_client_id() {
|
||||
let r = PairMessageRef {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue