use std::sync::Arc;
use promptforge_core::client::GatewayClient;
use promptforge_core::model::{
CompletionError, CompletionErrorKind, ModelCatalog, fetch_model_catalog,
};
use promptforge_core::tools::{Tool, WebSearch};
use promptforge_tool_picker::{
Catalog, Config as PickerConfig, ToolDescriptor, ToolId as PickerToolId, ToolPicker,
};
use promptforge_webfetch::WebFetch;
use crate::config::{Config, GatewayConfig};
use crate::error::PreparedToolsError;
#[non_exhaustive]
pub struct PreparedTools {
live: Vec<Arc<dyn Tool>>,
picker: ToolPicker,
models: ModelCatalog,
}
const _: fn() = || {
fn assert_send_sync_static<T: Send + Sync + 'static>() {}
assert_send_sync_static::<PreparedTools>();
};
impl std::fmt::Debug for PreparedTools {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PreparedTools")
.field(
"ids",
&self.live.iter().map(|tool| tool.id()).collect::<Vec<_>>(),
)
.field("picker", &self.picker)
.field("models", &self.models)
.finish()
}
}
impl PreparedTools {
pub async fn load(config: &Config) -> Result<Self, PreparedToolsError> {
let gateway = &config.gateway;
let models = match fetch_model_catalog(gateway.url.as_str(), gateway.key.expose()).await {
Ok(catalog) => catalog,
Err(error) if is_transient(&error) => {
let kind = error.kind();
tracing::warn!(
?kind,
%error,
"gateway model catalog unavailable; serving without it"
);
ModelCatalog::empty()
}
Err(error) => {
let kind = error.kind();
tracing::error!(
?kind,
%error,
"gateway model catalog could not be loaded; refusing to serve an empty catalog"
);
return Err(PreparedToolsError::tools(error));
}
};
Self::new(gateway, models)
}
pub(crate) fn new(
gateway: &GatewayConfig,
models: ModelCatalog,
) -> Result<Self, PreparedToolsError> {
let live = live_tools(gateway).map_err(PreparedToolsError::tools)?;
let catalog = catalog(&live);
let picker = ToolPicker::build(catalog, PickerConfig::default())
.map_err(PreparedToolsError::picker)?;
Ok(Self {
live,
picker,
models,
})
}
#[cfg(test)]
pub(crate) fn rebuild(&self, gateway: &GatewayConfig) -> Result<Self, PreparedToolsError> {
let live = live_tools(gateway).map_err(PreparedToolsError::tools)?;
let picker = self
.picker
.rebuild(catalog(&live))
.map_err(PreparedToolsError::index)?;
Ok(Self {
live,
picker,
models: self.models.clone(),
})
}
#[must_use]
pub(crate) fn tools(&self) -> &[Arc<dyn Tool>] {
&self.live
}
#[must_use]
pub(crate) fn picker(&self) -> &ToolPicker {
&self.picker
}
#[must_use]
pub(crate) fn models(&self) -> &ModelCatalog {
&self.models
}
}
fn is_transient(error: &CompletionError) -> bool {
match error.kind() {
CompletionErrorKind::Transport => true,
CompletionErrorKind::Backend => error.status().is_some_and(|status| status >= 500),
_ => false,
}
}
fn live_tools(
gateway: &GatewayConfig,
) -> Result<Vec<Arc<dyn Tool>>, promptforge_core::tools::ToolError> {
Ok(vec![
Arc::new(WebFetch::new()),
Arc::new(WebSearch::new(gateway.url.as_str(), gateway.key.expose())?),
])
}
fn catalog(live: &[Arc<dyn Tool>]) -> Catalog {
Catalog::new(live.iter().map(|tool| descriptor(tool.as_ref())).collect())
}
pub(super) fn gateway_client(
gateway: &GatewayConfig,
) -> Result<GatewayClient, promptforge_core::model::CompletionError> {
let endpoint = promptforge_core::client::GatewayEndpoint::new(gateway.url.as_str())?;
let key = promptforge_core::client::SecretString::new(gateway.key.expose())?;
Ok(GatewayClient::new(endpoint, key))
}
fn descriptor(tool: &dyn Tool) -> ToolDescriptor {
let id = tool.id();
ToolDescriptor::new(
PickerToolId::new(id.server(), id.name()),
tool.description(),
tool.parameters_schema(),
)
}
#[cfg(test)]
mod tests {
use std::fs;
use std::num::NonZeroU32;
use std::path::Path;
use promptforge_core::execute::{self, ResolutionContext, RunConfig};
use promptforge_core::model::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode};
use promptforge_core::observe::NullObserver;
use promptforge_core::parser::Prompt;
use promptforge_core::store::StoreRef;
use promptforge_tool_picker::Outcome;
use axum::Router;
use axum::http::StatusCode;
use axum::routing::get;
use super::{PreparedTools, gateway_client};
use crate::config::Config;
fn gateway(extra: &str) -> Config {
Config::from_toml_str(&format!(
"[server]\ntoken = \"t\"\n\n[gateway]\nurl = \"http://127.0.0.1:8081/v1/\"\nkey = \"gw\"\n{extra}"
))
.expect("the fixture configuration parses")
}
fn collect_markdown(directory: &Path, files: &mut Vec<std::path::PathBuf>) {
for entry in fs::read_dir(directory).expect("read repository prompt directory") {
let path = entry.expect("read repository prompt entry").path();
if path.is_dir() {
collect_markdown(&path, files);
} else if path.extension().is_some_and(|extension| extension == "md") {
files.push(path);
}
}
}
#[test]
fn complete_live_registry_contains_both_canonical_tools() {
let config = gateway("");
let tools = PreparedTools::new(
&config.gateway,
promptforge_core::model::ModelCatalog::empty(),
)
.expect("prepare fixture tools");
let registry_ids = tools
.tools()
.iter()
.map(|tool| {
let id = tool.id();
(id.server().to_owned(), id.name().to_owned())
})
.collect::<Vec<_>>();
assert_eq!(
registry_ids,
[
("promptforge".to_owned(), "web_fetch".to_owned()),
("promptforge".to_owned(), "web_search".to_owned()),
]
);
}
#[test]
fn a_capability_binds_to_the_matching_live_tool() {
let config = gateway("");
let tools = PreparedTools::new(
&config.gateway,
promptforge_core::model::ModelCatalog::empty(),
)
.expect("prepare fixture tools");
let outcome = tools
.picker()
.resolve("Fetch a web page and return its main content as markdown.")
.expect("resolve available capability");
assert!(matches!(outcome, Outcome::Bind(tool) if tool.name() == "web_fetch"));
}
#[tokio::test]
async fn every_repository_prompt_parses_and_resolves_live_h1() {
let config = gateway("");
let models = ModelCatalog::new([ModelDescriptor::new(
ModelId::gateway("claude-sonnet-4-6").expect("the test model alias is valid"),
"A model suited for careful analysis, coding, and general assistance",
NonZeroU32::new(200_000).expect("200000 is non-zero"),
ThinkingMode::Never,
)])
.expect("the test catalog has a single unique model");
let tools = PreparedTools::new(&config.gateway, models).expect("prepare repository tools");
let prompts = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../prompts");
let mut files = Vec::new();
collect_markdown(&prompts, &mut files);
files.sort();
assert_eq!(files.len(), 5, "every shipped markdown prompt is covered");
for path in files {
let source = fs::read_to_string(&path).expect("read repository prompt");
assert!(
!source.contains("web_search") && !source.contains("web_fetch"),
"{} must not depend on concrete tool names",
path.display()
);
let first_section = source
.find(
"
## ",
)
.unwrap_or_else(|| panic!("{} must have a section", path.display()));
let mut probe = source[..first_section].to_owned();
probe.push_str(
"
## Resolution Probe
```lua
return 'resolved'
```
",
);
let mut prompt = Prompt::parse(&probe, "test-run", &NullObserver::default())
.unwrap_or_else(|error| {
panic!("{} must parse: {error}", path.display());
});
prompt.strip_h1_prose();
let result = execute::run(
&prompt,
"",
ResolutionContext::new(tools.picker(), tools.models()),
tools.tools(),
&StoreRef::memory(),
RunConfig::new("test-run"),
)
.await
.unwrap_or_else(|error| panic!("{} must resolve live H1: {error}", path.display()));
assert_eq!(result, "resolved");
}
}
fn config_for(addr: &str) -> Config {
Config::from_toml_str(&format!(
"[server]\ntoken = \"t\"\n\n[gateway]\nurl = \"http://{addr}/v1/\"\nkey = \"gw\"\n"
))
.expect("the fixture configuration parses")
}
async fn spawn_gateway(
router: axum::Router,
) -> (
String,
tokio::sync::oneshot::Sender<()>,
tokio::task::JoinHandle<std::io::Result<()>>,
) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind an ephemeral port");
let addr = listener
.local_addr()
.expect("read the bound address")
.to_string();
let (stop, shutdown) = tokio::sync::oneshot::channel::<()>();
let serving = tokio::spawn(async move {
axum::serve(listener, router)
.with_graceful_shutdown(async move {
let _ = shutdown.await;
})
.await
});
(addr, stop, serving)
}
async fn stop_gateway(
stop: tokio::sync::oneshot::Sender<()>,
serving: tokio::task::JoinHandle<std::io::Result<()>>,
) {
let _ = stop.send(());
serving
.await
.expect("the gateway task joins")
.expect("the gateway served without error");
}
#[tokio::test]
async fn load_populates_the_model_catalog_from_a_reachable_gateway() {
async fn models() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({
"data": [{
"id": "claude-sonnet-4-6",
"description": "A model suited for careful analysis, coding, and general assistance",
"context": 200_000,
"thinking": "never"
}]
}))
}
let router = Router::new().route("/v1/models", get(models));
let (addr, stop, serving) = spawn_gateway(router).await;
let prepared = PreparedTools::load(&config_for(&addr))
.await
.expect("a reachable gateway loads");
assert!(
!prepared.models().is_empty(),
"the successful fetch path populates the model catalog"
);
assert_eq!(
prepared.models().models().len(),
1,
"the one fetched model is present in the catalog"
);
stop_gateway(stop, serving).await;
}
#[tokio::test]
async fn a_transient_gateway_failure_falls_back_to_an_empty_catalog() {
let router = Router::new().route(
"/v1/models",
get(|| async { StatusCode::SERVICE_UNAVAILABLE }),
);
let (addr, stop, serving) = spawn_gateway(router).await;
let prepared = PreparedTools::load(&config_for(&addr))
.await
.expect("a transient gateway failure still loads");
assert!(
prepared.models().is_empty(),
"a transient failure leaves the catalog empty rather than refusing to boot"
);
stop_gateway(stop, serving).await;
}
#[tokio::test]
async fn a_fatal_gateway_failure_does_not_silently_fall_back() {
let router = Router::new().route("/v1/models", get(|| async { StatusCode::UNAUTHORIZED }));
let (addr, stop, serving) = spawn_gateway(router).await;
let error = PreparedTools::load(&config_for(&addr))
.await
.expect_err("a fatal gateway failure refuses to serve an empty catalog");
assert!(
std::error::Error::source(&error).is_some(),
"the gateway failure is preserved as the error's source"
);
stop_gateway(stop, serving).await;
}
#[test]
fn gateway_client_is_built_from_url_and_key_without_leaking_the_key() {
let config = gateway("");
let client = gateway_client(&config.gateway).expect("the fixture gateway URL is valid");
let rendered = format!("{client:?}");
assert!(
!rendered.contains("gw"),
"the bearer key must never appear in Debug output, got: {rendered}"
);
assert!(
rendered.contains("http://127.0.0.1:8081/v1") && rendered.contains("<redacted>"),
"the client Debug must keep the base URL and redact the key, got: {rendered}"
);
}
}