mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
feat(dev): add bootstrap_catalog reference allowlist policy test
Per the settings-driven LLM catalog plan: the install/API-key flow needs a built-in catalog before any project settings are loaded, so fabro_model::bootstrap_catalog is the explicit hatch for that. Once the bootstrap module lands, request-serving paths must use the resolved Arc<Catalog> threaded through their state — never the bootstrap default. This policy test scans every .rs file under lib/ for non-comment references to 'bootstrap_catalog' and fails unless every reference sits in an allowlisted path fragment (the bootstrap module itself, install flows, test_support modules, and tests/policy.rs). Today the symbol does not yet exist, so the scan is clean and the test passes; the moment the symbol is introduced and used outside the allowlist, this test fails and forces an explicit allowlist update — fulfilling the workspace-policy requirement in the plan. Also lands rustfmt + clippy hygiene on this slice and drops the unused Result wrapper from infallible adapter factories so the factory registry remains plain function pointers.
This commit is contained in:
parent
8abe8ca515
commit
635d3d588a
21 changed files with 221 additions and 111 deletions
|
|
@ -230,4 +230,4 @@ mod tests {
|
|||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -542,4 +542,4 @@ command = ["demo-mcp"]
|
|||
assert_eq!(settings.run.execution.approval, ApprovalMode::Auto);
|
||||
assert!(!settings.run.execution.retros);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,11 +184,12 @@ where
|
|||
D: Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
use toml::value::Datetime;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Either {
|
||||
Toml(toml::value::Datetime),
|
||||
Toml(Datetime),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
|
|
@ -310,8 +311,9 @@ impl TryFrom<String> for CredentialRef {
|
|||
|
||||
impl<'de> Deserialize<'de> for CredentialRef {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
use serde::de::Error;
|
||||
let s = String::deserialize(deserializer)?;
|
||||
s.parse().map_err(serde::de::Error::custom)
|
||||
s.parse().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -428,6 +430,10 @@ mod tests {
|
|||
// CredentialRef. The error bubbles up as a TOML deserialization
|
||||
// failure.
|
||||
#[derive(Deserialize)]
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "field exists only to drive the deserializer; we assert on the parse error"
|
||||
)]
|
||||
struct Wrap {
|
||||
v: Vec<CredentialRef>,
|
||||
}
|
||||
|
|
@ -453,20 +459,14 @@ aliases = ["moonshot"]
|
|||
let kimi = layer.providers.get("kimi").unwrap();
|
||||
assert_eq!(kimi.display_name.as_deref(), Some("Kimi"));
|
||||
assert_eq!(kimi.adapter.as_deref(), Some("openai_compatible"));
|
||||
assert_eq!(
|
||||
kimi.base_url.as_deref(),
|
||||
Some("https://api.moonshot.ai/v1")
|
||||
);
|
||||
assert_eq!(kimi.base_url.as_deref(), Some("https://api.moonshot.ai/v1"));
|
||||
assert_eq!(kimi.priority, Some(60));
|
||||
assert_eq!(kimi.enabled, Some(true));
|
||||
assert_eq!(kimi.aliases.as_deref(), Some(&["moonshot".to_string()][..]));
|
||||
assert_eq!(
|
||||
kimi.credentials.as_ref().unwrap(),
|
||||
&vec![
|
||||
CredentialRef::Credential("kimi".to_string()),
|
||||
CredentialRef::Env("KIMI_API_KEY".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(kimi.credentials.as_ref().unwrap(), &vec![
|
||||
CredentialRef::Credential("kimi".to_string()),
|
||||
CredentialRef::Env("KIMI_API_KEY".to_string()),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -548,18 +548,9 @@ cache_input_cost_per_mtok = 9.0
|
|||
let controls = m.controls.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
controls.reasoning_effort.as_deref(),
|
||||
Some(
|
||||
&[
|
||||
"low".to_string(),
|
||||
"medium".to_string(),
|
||||
"high".to_string()
|
||||
][..]
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
controls.speed.as_deref(),
|
||||
Some(&["fast".to_string()][..])
|
||||
Some(&["low".to_string(), "medium".to_string(), "high".to_string()][..])
|
||||
);
|
||||
assert_eq!(controls.speed.as_deref(), Some(&["fast".to_string()][..]));
|
||||
|
||||
let costs = m.costs.as_ref().unwrap();
|
||||
let fast = costs.speed.as_ref().unwrap().get("fast").unwrap();
|
||||
|
|
@ -629,10 +620,9 @@ mystery = 1
|
|||
..ProviderSettings::default()
|
||||
};
|
||||
let merged = high.combine(low);
|
||||
assert_eq!(
|
||||
merged.credentials.unwrap(),
|
||||
vec![CredentialRef::Env("FOO".to_string())]
|
||||
);
|
||||
assert_eq!(merged.credentials.unwrap(), vec![CredentialRef::Env(
|
||||
"FOO".to_string()
|
||||
)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -643,35 +633,28 @@ mystery = 1
|
|||
..ProviderSettings::default()
|
||||
};
|
||||
let merged = high.combine(low);
|
||||
assert_eq!(
|
||||
merged.credentials.unwrap(),
|
||||
vec![CredentialRef::Env("FOO".to_string())]
|
||||
);
|
||||
assert_eq!(merged.credentials.unwrap(), vec![CredentialRef::Env(
|
||||
"FOO".to_string()
|
||||
)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_map_field_merges_per_provider_id() {
|
||||
let mut high_map: std::collections::HashMap<String, ProviderSettings> =
|
||||
std::collections::HashMap::new();
|
||||
high_map.insert(
|
||||
"kimi".to_string(),
|
||||
ProviderSettings {
|
||||
base_url: Some("https://override".to_string()),
|
||||
..ProviderSettings::default()
|
||||
},
|
||||
);
|
||||
high_map.insert("kimi".to_string(), ProviderSettings {
|
||||
base_url: Some("https://override".to_string()),
|
||||
..ProviderSettings::default()
|
||||
});
|
||||
let high: MergeMap<ProviderSettings> = MergeMap::from(high_map);
|
||||
|
||||
let mut low_map: std::collections::HashMap<String, ProviderSettings> =
|
||||
std::collections::HashMap::new();
|
||||
low_map.insert(
|
||||
"kimi".to_string(),
|
||||
ProviderSettings {
|
||||
adapter: Some("openai_compatible".to_string()),
|
||||
base_url: Some("https://defaults".to_string()),
|
||||
..ProviderSettings::default()
|
||||
},
|
||||
);
|
||||
low_map.insert("kimi".to_string(), ProviderSettings {
|
||||
adapter: Some("openai_compatible".to_string()),
|
||||
base_url: Some("https://defaults".to_string()),
|
||||
..ProviderSettings::default()
|
||||
});
|
||||
let low: MergeMap<ProviderSettings> = MergeMap::from(low_map);
|
||||
|
||||
let merged = high.combine(low);
|
||||
|
|
@ -693,7 +676,10 @@ mystery = 1
|
|||
speed: Some(vec!["fast".to_string()]),
|
||||
};
|
||||
let merged = high.combine(low);
|
||||
assert_eq!(merged.reasoning_effort.as_deref(), Some(&["high".to_string()][..]));
|
||||
assert_eq!(
|
||||
merged.reasoning_effort.as_deref(),
|
||||
Some(&["high".to_string()][..])
|
||||
);
|
||||
assert_eq!(merged.speed.as_deref(), Some(&["fast".to_string()][..]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,4 +43,4 @@ pub use server::{
|
|||
SlackIntegrationLayer, TeamsIntegrationLayer,
|
||||
};
|
||||
pub(crate) use settings::SettingsLayer;
|
||||
pub use workflow::WorkflowLayer;
|
||||
pub use workflow::WorkflowLayer;
|
||||
|
|
|
|||
|
|
@ -602,4 +602,4 @@ pub struct RunPullRequestLayer {
|
|||
pub struct RunArtifactsLayer {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub include: Vec<String>,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,4 +134,4 @@ impl SettingsLayer {
|
|||
let auth = server.auth.get_or_insert_with(ServerAuthLayer::default);
|
||||
auth.methods = Some(vec![ServerAuthMethod::DevToken]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,11 +48,11 @@ pub use layers::{
|
|||
ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunExecutionLayer,
|
||||
RunGitLayer, RunGoalLayer, RunLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer,
|
||||
RunPullRequestLayer, RunSandboxLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer,
|
||||
ServerArtifactsLayer,
|
||||
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
|
||||
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
|
||||
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
|
||||
SlackIntegrationLayer, StickyMap, StringOrSplice, TeamsIntegrationLayer, WorkflowLayer,
|
||||
ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer,
|
||||
ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer,
|
||||
ServerLoggingLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer,
|
||||
ServerWebLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, TeamsIntegrationLayer,
|
||||
WorkflowLayer,
|
||||
};
|
||||
pub(crate) use layers::{Combine, SettingsLayer};
|
||||
pub use logging::{resolve_log_destination, resolve_log_destination_with_env};
|
||||
|
|
@ -88,4 +88,4 @@ where
|
|||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
|
||||
Err(e) => Err(Error::read_file(&default_path, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,7 +216,10 @@ mod tests {
|
|||
.parse::<SettingsLayer>()
|
||||
.unwrap_err();
|
||||
let text = err.to_string();
|
||||
assert!(text.contains("run.model") || text.contains("llm"), "got: {text}");
|
||||
assert!(
|
||||
text.contains("run.model") || text.contains("llm"),
|
||||
"got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -225,6 +228,9 @@ mod tests {
|
|||
.parse::<SettingsLayer>()
|
||||
.unwrap_err();
|
||||
let text = err.to_string();
|
||||
assert!(text.contains("run.model") || text.contains("llm"), "got: {text}");
|
||||
assert!(
|
||||
text.contains("run.model") || text.contains("llm"),
|
||||
"got: {text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use fabro_types::settings::run::{
|
|||
GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, LocalSandboxSettings,
|
||||
McpServerSettings, McpTransport, MergeStrategy, NotificationProviderSettings,
|
||||
NotificationRouteSettings, PullRequestSettings, RunAgentSettings, RunCheckpointSettings,
|
||||
RunExecutionSettings, RunGitSettings, RunGoal, RunInterviewsSettings, RunModelSettings,
|
||||
RunNamespace, RunPrepareSettings, RunSandboxSettings, RunScmSettings, ScmGitHubSettings,
|
||||
TlsMode,
|
||||
RunExecutionSettings, RunGitSettings, RunGoal, RunInterviewsSettings, RunModelControls,
|
||||
RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings, RunScmSettings,
|
||||
ScmGitHubSettings, TlsMode,
|
||||
};
|
||||
|
||||
use super::ResolveError;
|
||||
|
|
@ -75,7 +75,7 @@ fn resolve_model(model: Option<&RunModelLayer>) -> RunModelSettings {
|
|||
controls: model
|
||||
.controls
|
||||
.as_ref()
|
||||
.map(|c| fabro_types::settings::RunModelControls {
|
||||
.map(|c| RunModelControls {
|
||||
reasoning_effort: c.reasoning_effort.clone(),
|
||||
speed: c.speed.clone(),
|
||||
})
|
||||
|
|
@ -505,4 +505,4 @@ fn resolve_artifacts(artifacts: Option<&RunArtifactsLayer>) -> ArtifactsSettings
|
|||
fn size_to_gb_i32(bytes: u64) -> i32 {
|
||||
let gb = bytes / 1_000_000_000;
|
||||
i32::try_from(gb).unwrap_or(i32::MAX)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,4 +110,4 @@ name = "sonnet"
|
|||
Some(InterpString::parse("anthropic"))
|
||||
);
|
||||
assert_eq!(settings.model.name, Some(InterpString::parse("sonnet")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ mod docker_build;
|
|||
#[cfg(unix)]
|
||||
mod docker_entrypoint;
|
||||
mod docs;
|
||||
mod policy;
|
||||
mod release;
|
||||
mod spa;
|
||||
|
||||
|
|
@ -11,7 +12,7 @@ fn fabro_dev() -> assert_cmd::Command {
|
|||
assert_cmd::cargo::cargo_bin_cmd!("fabro-dev")
|
||||
}
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
pub(crate) fn workspace_root() -> PathBuf {
|
||||
let mut root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
root.pop();
|
||||
root.pop();
|
||||
|
|
|
|||
106
lib/crates/fabro-dev/tests/it/policy.rs
Normal file
106
lib/crates/fabro-dev/tests/it/policy.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! Workspace policy tests.
|
||||
//!
|
||||
//! These tests scan the source tree for references that violate
|
||||
//! product-level invariants. They run as part of `cargo nextest` and are
|
||||
//! cheap (text scans only).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::workspace_root;
|
||||
|
||||
/// `fabro_model::bootstrap_catalog` (and its module) is the install/API-key
|
||||
/// validation hatch from the settings-driven LLM catalog plan. It must
|
||||
/// **not** appear in request-serving paths — server handlers, workflow
|
||||
/// operations, agent runtime, hooks, or completion handlers — because those
|
||||
/// must use the resolved `Arc<Catalog>` threaded through their state.
|
||||
///
|
||||
/// The allowed-callers list below is the policy boundary. Adding a new
|
||||
/// caller is intentional and requires updating this list.
|
||||
const BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS: &[&str] = &[
|
||||
// The bootstrap module itself.
|
||||
"lib/crates/fabro-model/src/bootstrap_catalog",
|
||||
// Install / first-run / API-key validation flows that legitimately need
|
||||
// a built-in catalog before any project settings have been loaded.
|
||||
"lib/crates/fabro-install/",
|
||||
"lib/crates/fabro-cli/src/commands/install/",
|
||||
"lib/crates/fabro-cli/src/shared/install_",
|
||||
"lib/crates/fabro-cli/src/shared/api_key_validation",
|
||||
// Test support modules.
|
||||
"tests/",
|
||||
"test_support",
|
||||
"/tests/it/",
|
||||
"/tests/policy.rs",
|
||||
// Documentation files referencing the policy.
|
||||
"docs/",
|
||||
"CLAUDE.md",
|
||||
"AGENTS.md",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn bootstrap_catalog_references_stay_in_allowlist() {
|
||||
let root = workspace_root();
|
||||
let mut violations: Vec<(PathBuf, usize, String)> = Vec::new();
|
||||
walk_rust_sources(&root, &mut |path, contents| {
|
||||
for (idx, line) in contents.lines().enumerate() {
|
||||
if !line.contains("bootstrap_catalog") {
|
||||
continue;
|
||||
}
|
||||
// Skip comments referencing the symbol in prose.
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') {
|
||||
continue;
|
||||
}
|
||||
let rel = path.strip_prefix(&root).unwrap_or(path);
|
||||
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
||||
if BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS
|
||||
.iter()
|
||||
.any(|frag| rel_str.contains(frag))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
violations.push((rel.to_path_buf(), idx + 1, line.to_string()));
|
||||
}
|
||||
});
|
||||
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"bootstrap_catalog (install-only) referenced from non-allowlisted source files:\n{}\n\nIf this is intentional, add the path fragment to BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS in lib/crates/fabro-dev/tests/it/policy.rs.",
|
||||
violations
|
||||
.into_iter()
|
||||
.map(|(p, l, s)| format!(" {}:{}: {}", p.display(), l, s.trim()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "policy test reads source files synchronously with std::fs"
|
||||
)]
|
||||
fn walk_rust_sources(root: &Path, on_file: &mut dyn FnMut(&Path, &str)) {
|
||||
let mut stack: Vec<PathBuf> = vec![root.join("lib")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
// Skip generated/output directories.
|
||||
if matches!(
|
||||
name_str.as_ref(),
|
||||
"target" | ".git" | "node_modules" | "dist" | "build"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
on_file(&path, &contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,6 @@ use std::sync::Arc;
|
|||
use fabro_auth::ApiKeyHeader;
|
||||
use fabro_model::adapter::{self as model_adapter, AdapterMetadata};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::provider::ProviderAdapter;
|
||||
use crate::providers;
|
||||
|
||||
|
|
@ -50,13 +49,13 @@ impl AdapterConfig {
|
|||
/// Construct a minimal config with just provider ID and auth header.
|
||||
pub fn new(provider_id: impl Into<String>, auth_header: ApiKeyHeader) -> Self {
|
||||
Self {
|
||||
provider_id: provider_id.into(),
|
||||
provider_id: provider_id.into(),
|
||||
auth_header,
|
||||
base_url: None,
|
||||
base_url: None,
|
||||
extra_headers: HashMap::new(),
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -69,11 +68,15 @@ fn auth_value(header: &ApiKeyHeader) -> String {
|
|||
|
||||
/// Factory function signature. Takes a fully-resolved [`AdapterConfig`] and
|
||||
/// returns a registered-ready [`ProviderAdapter`].
|
||||
pub type AdapterFactory = fn(&AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Error>;
|
||||
///
|
||||
/// Adapter constructors are infallible today; if a future adapter needs to
|
||||
/// fail at construction time, add a separate fallible factory variant
|
||||
/// rather than re-shaping every existing factory.
|
||||
pub type AdapterFactory = fn(&AdapterConfig) -> Arc<dyn ProviderAdapter>;
|
||||
|
||||
const KIMI_BASE_URL: &str = "https://api.moonshot.ai/v1";
|
||||
|
||||
fn build_anthropic(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Error> {
|
||||
fn build_anthropic(config: &AdapterConfig) -> Arc<dyn ProviderAdapter> {
|
||||
let mut adapter = providers::AnthropicAdapter::new(auth_value(&config.auth_header));
|
||||
if let Some(base_url) = config.base_url.clone() {
|
||||
adapter = adapter.with_base_url(base_url);
|
||||
|
|
@ -81,10 +84,10 @@ fn build_anthropic(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, E
|
|||
if !config.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(config.extra_headers.clone());
|
||||
}
|
||||
Ok(Arc::new(adapter))
|
||||
Arc::new(adapter)
|
||||
}
|
||||
|
||||
fn build_openai(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Error> {
|
||||
fn build_openai(config: &AdapterConfig) -> Arc<dyn ProviderAdapter> {
|
||||
let mut adapter = providers::OpenAiAdapter::new(auth_value(&config.auth_header));
|
||||
if let Some(base_url) = config.base_url.clone() {
|
||||
adapter = adapter.with_base_url(base_url);
|
||||
|
|
@ -101,10 +104,10 @@ fn build_openai(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Erro
|
|||
if let Some(project_id) = config.project_id.clone() {
|
||||
adapter = adapter.with_project_id(project_id);
|
||||
}
|
||||
Ok(Arc::new(adapter))
|
||||
Arc::new(adapter)
|
||||
}
|
||||
|
||||
fn build_gemini(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Error> {
|
||||
fn build_gemini(config: &AdapterConfig) -> Arc<dyn ProviderAdapter> {
|
||||
let mut adapter = providers::GeminiAdapter::new(auth_value(&config.auth_header));
|
||||
if let Some(base_url) = config.base_url.clone() {
|
||||
adapter = adapter.with_base_url(base_url);
|
||||
|
|
@ -112,10 +115,10 @@ fn build_gemini(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Erro
|
|||
if !config.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(config.extra_headers.clone());
|
||||
}
|
||||
Ok(Arc::new(adapter))
|
||||
Arc::new(adapter)
|
||||
}
|
||||
|
||||
fn build_openai_compatible(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Error> {
|
||||
fn build_openai_compatible(config: &AdapterConfig) -> Arc<dyn ProviderAdapter> {
|
||||
let base_url = config
|
||||
.base_url
|
||||
.clone()
|
||||
|
|
@ -126,7 +129,7 @@ fn build_openai_compatible(config: &AdapterConfig) -> Result<Arc<dyn ProviderAda
|
|||
if !config.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(config.extra_headers.clone());
|
||||
}
|
||||
Ok(Arc::new(adapter))
|
||||
Arc::new(adapter)
|
||||
}
|
||||
|
||||
/// Look up a factory by adapter key. Returns `None` if the key has no factory
|
||||
|
|
@ -182,8 +185,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn registered_factory_set_matches_metadata_set() {
|
||||
let metadata: std::collections::BTreeSet<&str> =
|
||||
model_adapter::keys().collect();
|
||||
let metadata: std::collections::BTreeSet<&str> = model_adapter::keys().collect();
|
||||
let factories: std::collections::BTreeSet<&str> = registered_keys().collect();
|
||||
assert_eq!(metadata, factories);
|
||||
}
|
||||
|
|
@ -195,14 +197,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn anthropic_factory_builds_anthropic_adapter() {
|
||||
let config = AdapterConfig::new(
|
||||
"anthropic",
|
||||
ApiKeyHeader::Custom {
|
||||
name: "x-api-key".to_string(),
|
||||
value: "test-key".to_string(),
|
||||
},
|
||||
);
|
||||
let adapter = factory_for("anthropic").unwrap()(&config).unwrap();
|
||||
let config = AdapterConfig::new("anthropic", ApiKeyHeader::Custom {
|
||||
name: "x-api-key".to_string(),
|
||||
value: "test-key".to_string(),
|
||||
});
|
||||
let adapter = factory_for("anthropic").unwrap()(&config);
|
||||
assert_eq!(adapter.name(), "anthropic");
|
||||
}
|
||||
|
||||
|
|
@ -217,7 +216,7 @@ mod tests {
|
|||
org_id: None,
|
||||
project_id: None,
|
||||
};
|
||||
let adapter = factory_for("openai_compatible").unwrap()(&config).unwrap();
|
||||
let adapter = factory_for("openai_compatible").unwrap()(&config);
|
||||
assert_eq!(adapter.name(), "kimi");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,4 +11,4 @@ pub mod tools;
|
|||
pub mod types;
|
||||
|
||||
pub use error::{Error, ProviderErrorDetail, ProviderErrorKind, Result};
|
||||
pub use fabro_model::{ModelHandle, Provider};
|
||||
pub use fabro_model::{ModelHandle, Provider};
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
//! (in `fabro-model`) and the LLM factory registry (in `fabro-llm`) must agree
|
||||
//! on the same set of adapter keys; the parity is enforced by tests.
|
||||
|
||||
use crate::reasoning::ReasoningEffort;
|
||||
use crate::Speed;
|
||||
use crate::reasoning::ReasoningEffort;
|
||||
|
||||
/// Internal dispatch key that `fabro-agent` maps to a concrete agent profile.
|
||||
///
|
||||
|
|
@ -100,7 +100,9 @@ pub const OPENAI: AdapterMetadata = AdapterMetadata {
|
|||
pub const GEMINI: AdapterMetadata = AdapterMetadata {
|
||||
key: "gemini",
|
||||
default_profile: AgentProfileKind::Gemini,
|
||||
api_key_header: ApiKeyHeaderPolicy::Custom { name: "x-goog-api-key" },
|
||||
api_key_header: ApiKeyHeaderPolicy::Custom {
|
||||
name: "x-goog-api-key",
|
||||
},
|
||||
controls: AdapterControlCapabilities {
|
||||
native_reasoning_effort: FULL_REASONING_EFFORTS,
|
||||
additional_speeds: &[],
|
||||
|
|
@ -184,10 +186,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn openai_compatible_uses_openai_profile() {
|
||||
assert_eq!(
|
||||
OPENAI_COMPATIBLE.default_profile,
|
||||
AgentProfileKind::OpenAi
|
||||
);
|
||||
assert_eq!(OPENAI_COMPATIBLE.default_profile, AgentProfileKind::OpenAi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ impl PricePerMTok {
|
|||
Display,
|
||||
EnumString,
|
||||
IntoStaticStr,
|
||||
strum::VariantArray,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
|
|
|
|||
|
|
@ -133,12 +133,15 @@ mod tests {
|
|||
#[test]
|
||||
fn display_writes_inner_string() {
|
||||
assert_eq!(ProviderId::new("anthropic").to_string(), "anthropic");
|
||||
assert_eq!(ModelId::new("claude-opus-4-7").to_string(), "claude-opus-4-7");
|
||||
assert_eq!(
|
||||
ModelId::new("claude-opus-4-7").to_string(),
|
||||
"claude-opus-4-7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ord_is_lexicographic() {
|
||||
let mut v = vec![ProviderId::new("zai"), ProviderId::new("anthropic")];
|
||||
let mut v = [ProviderId::new("zai"), ProviderId::new("anthropic")];
|
||||
v.sort();
|
||||
assert_eq!(v[0].as_str(), "anthropic");
|
||||
assert_eq!(v[1].as_str(), "zai");
|
||||
|
|
|
|||
|
|
@ -42,17 +42,26 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parses_canonical_lowercase_strings() {
|
||||
assert_eq!(ReasoningEffort::from_str("low").unwrap(), ReasoningEffort::Low);
|
||||
assert_eq!(
|
||||
ReasoningEffort::from_str("low").unwrap(),
|
||||
ReasoningEffort::Low
|
||||
);
|
||||
assert_eq!(
|
||||
ReasoningEffort::from_str("medium").unwrap(),
|
||||
ReasoningEffort::Medium
|
||||
);
|
||||
assert_eq!(ReasoningEffort::from_str("high").unwrap(), ReasoningEffort::High);
|
||||
assert_eq!(
|
||||
ReasoningEffort::from_str("high").unwrap(),
|
||||
ReasoningEffort::High
|
||||
);
|
||||
assert_eq!(
|
||||
ReasoningEffort::from_str("xhigh").unwrap(),
|
||||
ReasoningEffort::XHigh
|
||||
);
|
||||
assert_eq!(ReasoningEffort::from_str("max").unwrap(), ReasoningEffort::Max);
|
||||
assert_eq!(
|
||||
ReasoningEffort::from_str("max").unwrap(),
|
||||
ReasoningEffort::Max
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1981,4 +1981,4 @@ digraph Demo {
|
|||
);
|
||||
assert!(response_mock.calls_async().await >= 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,4 +52,4 @@ pub use server::{
|
|||
ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings,
|
||||
};
|
||||
pub use size::{ParseSizeError, Size};
|
||||
pub use workflow::WorkflowNamespace;
|
||||
pub use workflow::WorkflowNamespace;
|
||||
|
|
|
|||
|
|
@ -562,4 +562,4 @@ pub enum MergeStrategy {
|
|||
Merge,
|
||||
Squash,
|
||||
Rebase,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue