feat(ollama): align provider defaults with codex semantics

This commit is contained in:
2025-10-26 01:21:17 +02:00
parent 6980640324
commit 5d9ecec82c
3 changed files with 187 additions and 150 deletions

6
agents.md Normal file
View File

@@ -0,0 +1,6 @@
# Agents Upgrade Plan
- test: rebuild ollama integration suite to exercise local, cloud, search, and quota error flows
- refactor: simplify provider/session coupling so compute_web_search_settings shares normalization utilities
- docs: clarify cloud vs local env configuration (api key precedence, insecure toggle) in configuration guide
- ci: add targeted regression job for ollama providers covering chat, streaming, and tool execution paths

View File

@@ -2053,11 +2053,15 @@ fn normalize_base_url(
} }
let path = url.path().trim_end_matches('/'); let path = url.path().trim_end_matches('/');
if path == "/api" { match path {
"" | "/" => {}
"/api" | "/v1" => {
url.set_path("/"); url.set_path("/");
} else if !path.is_empty() && path != "/" { }
_ => {
return Err("Ollama base URLs must not include additional path segments".to_string()); return Err("Ollama base URLs must not include additional path segments".to_string());
} }
}
if mode_hint == OllamaMode::Cloud { if mode_hint == OllamaMode::Cloud {
if let Some(host) = url.host_str() { if let Some(host) = url.host_str() {
@@ -2163,6 +2167,18 @@ mod tests {
assert_eq!(url, "https://ollama.com"); assert_eq!(url, "https://ollama.com");
} }
#[test]
fn normalize_base_url_accepts_v1_path_for_local() {
let url = normalize_base_url(Some("http://localhost:11434/v1"), OllamaMode::Local).unwrap();
assert_eq!(url, "http://localhost:11434");
}
#[test]
fn normalize_base_url_accepts_v1_path_for_cloud() {
let url = normalize_base_url(Some("https://api.ollama.com/v1"), OllamaMode::Cloud).unwrap();
assert_eq!(url, "https://ollama.com");
}
#[test] #[test]
fn normalize_base_url_canonicalises_api_hostname() { fn normalize_base_url_canonicalises_api_hostname() {
let url = normalize_base_url(Some("https://api.ollama.com"), OllamaMode::Cloud).unwrap(); let url = normalize_base_url(Some("https://api.ollama.com"), OllamaMode::Cloud).unwrap();

View File

@@ -1,7 +1,7 @@
use std::sync::Arc; use std::{sync::Arc, time::Duration};
use owlen_core::tools::WEB_SEARCH_TOOL_NAME; use owlen_core::tools::WEB_SEARCH_TOOL_NAME;
use owlen_core::types::{ChatParameters, Role}; use owlen_core::types::{ChatParameters, ChatResponse, Role};
use owlen_core::{ use owlen_core::{
Config, Provider, Config, Provider,
providers::OllamaProvider, providers::OllamaProvider,
@@ -84,6 +84,19 @@ async fn create_session(
(session, temp_dir) (session, temp_dir)
} }
async fn send_prompt(session: &mut SessionController, message: &str) -> ChatResponse {
match session
.send_message(message.to_string(), ChatParameters::default())
.await
{
Ok(SessionOutcome::Complete(response)) => response,
Ok(SessionOutcome::Streaming { .. }) => {
panic!("expected complete outcome, got streaming response")
}
Err(err) => panic!("send_message failed: {err:?}"),
}
}
fn configure_local(base_url: &str) -> Config { fn configure_local(base_url: &str) -> Config {
let mut config = Config::default(); let mut config = Config::default();
config.general.default_provider = "ollama_local".into(); config.general.default_provider = "ollama_local".into();
@@ -100,7 +113,7 @@ fn configure_local(base_url: &str) -> Config {
config config
} }
fn configure_cloud(base_url: &str) -> Config { fn configure_cloud(base_url: &str, search_endpoint: &str) -> Config {
let mut config = Config::default(); let mut config = Config::default();
config.general.default_provider = "ollama_cloud".into(); config.general.default_provider = "ollama_cloud".into();
config.general.default_model = Some("llama3:8b-cloud".into()); config.general.default_model = Some("llama3:8b-cloud".into());
@@ -119,7 +132,7 @@ fn configure_cloud(base_url: &str) -> Config {
cloud.api_key = Some("test-key".into()); cloud.api_key = Some("test-key".into());
cloud.extra.insert( cloud.extra.insert(
"web_search_endpoint".into(), "web_search_endpoint".into(),
Value::String("/v1/web/search".into()), Value::String(search_endpoint.into()),
); );
cloud.extra.insert( cloud.extra.insert(
owlen_core::config::OLLAMA_CLOUD_ENDPOINT_KEY.into(), owlen_core::config::OLLAMA_CLOUD_ENDPOINT_KEY.into(),
@@ -130,47 +143,32 @@ fn configure_cloud(base_url: &str) -> Config {
config config
} }
#[tokio::test(flavor = "multi_thread")] async fn run_local_completion(base_suffix: &str) {
async fn local_provider_happy_path_records_usage() {
let server = MockServer::start().await; let server = MockServer::start().await;
let raw_base = format!("{}{}", server.uri(), base_suffix);
let tags = load_fixture("ollama_tags"); let tags = load_fixture("ollama_tags");
let completion = load_fixture("ollama_local_completion"); let completion = load_fixture("ollama_local_completion");
let base_url = server.uri();
let tags_template = ResponseTemplate::new(200).set_body_json(tags);
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/api/tags")) .and(path("/api/tags"))
.respond_with(tags_template.clone()) .respond_with(ResponseTemplate::new(200).set_body_json(tags))
.mount(&server) .mount(&server)
.await; .await;
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/chat")) .and(path("/api/chat"))
.respond_with(ResponseTemplate::new(200).set_body_json(completion)) .respond_with(ResponseTemplate::new(200).set_body_json(completion))
.expect(1)
.mount(&server) .mount(&server)
.await; .await;
let config = configure_local(&base_url); let config = configure_local(&raw_base);
let provider: Arc<dyn Provider> = let provider: Arc<dyn Provider> =
Arc::new(OllamaProvider::new(base_url.clone()).expect("local provider")); Arc::new(OllamaProvider::new(raw_base.clone()).expect("local provider"));
let (mut session, _tmp) = create_session(provider, config).await; let (mut session, _tmp) = create_session(provider, config).await;
let response = send_prompt(&mut session, "Summarise the local status.").await;
let outcome = session
.send_message(
"Summarise the local status.".to_string(),
ChatParameters::default(),
)
.await
.expect("local completion");
let response = match outcome {
SessionOutcome::Complete(response) => response,
_ => panic!("expected complete outcome"),
};
assert_eq!(response.message.content, "Local response complete."); assert_eq!(response.message.content, "Local response complete.");
let snapshot = session let snapshot = session
@@ -182,34 +180,41 @@ async fn local_provider_happy_path_records_usage() {
assert_eq!(snapshot.weekly.total_tokens, 36); assert_eq!(snapshot.weekly.total_tokens, 36);
} }
#[tokio::test(flavor = "multi_thread")] async fn run_cloud_tool_flow(base_suffix: &str) {
async fn cloud_tool_call_flows_through_web_search() {
let server = MockServer::start().await; let server = MockServer::start().await;
let raw_base = format!("{}{}", server.uri(), base_suffix);
let search_endpoint = if base_suffix.is_empty() {
"/v1/web/search"
} else {
"/web/search"
};
let tags = load_fixture("ollama_tags"); let tags = load_fixture("ollama_tags");
let tool_call = load_fixture("ollama_cloud_tool_call"); let tool_call = load_fixture("ollama_cloud_tool_call");
let final_chunk = load_fixture("ollama_cloud_final"); let final_chunk = load_fixture("ollama_cloud_final");
let base_url = server.uri();
let tags_template = ResponseTemplate::new(200).set_body_json(tags);
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/api/tags")) .and(path("/api/tags"))
.respond_with(tags_template.clone()) .and(header("authorization", "Bearer test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(tags))
.mount(&server) .mount(&server)
.await; .await;
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/chat")) .and(path("/api/chat"))
.and(header("authorization", "Bearer test-key"))
.and(BodySubstringMatcher::not_contains("\"role\":\"tool\"")) .and(BodySubstringMatcher::not_contains("\"role\":\"tool\""))
.respond_with(ResponseTemplate::new(200).set_body_json(tool_call)) .respond_with(ResponseTemplate::new(200).set_body_json(tool_call))
.expect(1)
.mount(&server) .mount(&server)
.await; .await;
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/api/chat")) .and(path("/api/chat"))
.and(header("authorization", "Bearer test-key"))
.and(BodySubstringMatcher::contains("\"role\":\"tool\"")) .and(BodySubstringMatcher::contains("\"role\":\"tool\""))
.respond_with(ResponseTemplate::new(200).set_body_json(final_chunk)) .respond_with(ResponseTemplate::new(200).set_body_json(final_chunk))
.expect(1)
.mount(&server) .mount(&server)
.await; .await;
@@ -229,38 +234,36 @@ async fn cloud_tool_call_flows_through_web_search() {
.mount(&server) .mount(&server)
.await; .await;
let config = configure_cloud(&base_url); let config = configure_cloud(&raw_base, search_endpoint);
let cloud_cfg = config let cloud_cfg = config
.providers .providers
.get("ollama_cloud") .get("ollama_cloud")
.expect("cloud provider config") .expect("cloud provider config")
.clone(); .clone();
assert_eq!(cloud_cfg.api_key.as_deref(), Some("test-key")); assert_eq!(cloud_cfg.api_key.as_deref(), Some("test-key"));
assert_eq!(cloud_cfg.base_url.as_deref(), Some(base_url.as_str())); assert_eq!(cloud_cfg.base_url.as_deref(), Some(raw_base.as_str()));
assert_eq!(cloud_cfg.api_key.as_deref(), Some("test-key"));
assert_eq!(cloud_cfg.base_url.as_deref(), Some(raw_base.as_str()));
let provider: Arc<dyn Provider> = Arc::new( let provider: Arc<dyn Provider> = Arc::new(
OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general)) OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general))
.expect("cloud provider"), .expect("cloud provider"),
); );
let (mut session, _tmp) = create_session(provider, config).await; let (mut session, _tmp) = create_session(provider, config).await;
let search_url = format!(
"{}/{}",
raw_base.trim_end_matches('/'),
search_endpoint.trim_start_matches('/')
);
session.grant_consent( session.grant_consent(
WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_NAME,
vec!["network".into()], vec!["network".into()],
vec![format!("{}/v1/web/search", base_url)], vec![search_url],
); );
let outcome = session let response = send_prompt(&mut session, "What is new in Rust today?").await;
.send_message(
"What is new in Rust today?".to_string(),
ChatParameters::default(),
)
.await;
let response = match outcome {
Ok(SessionOutcome::Complete(response)) => response,
Ok(_) => panic!("expected complete outcome"),
Err(err) => panic!("cloud completion: {err:?}"),
};
assert_eq!( assert_eq!(
response.message.content, response.message.content,
"Rust 1.85 shipped today. Summarising the highlights now." "Rust 1.85 shipped today. Summarising the highlights now."
@@ -283,114 +286,126 @@ async fn cloud_tool_call_flows_through_web_search() {
.await .await
.expect("usage snapshot"); .expect("usage snapshot");
assert_eq!(snapshot.provider, "ollama_cloud"); assert_eq!(snapshot.provider, "ollama_cloud");
assert_eq!(snapshot.hourly.total_tokens, 112); // 64 prompt + 48 completion assert_eq!(snapshot.hourly.total_tokens, 112);
assert_eq!(snapshot.weekly.total_tokens, 112); assert_eq!(snapshot.weekly.total_tokens, 112);
} }
async fn run_cloud_error(
base_suffix: &str,
status: u16,
error_body: Value,
prompt: &str,
) -> String {
let server = MockServer::start().await;
let raw_base = format!("{}{}", server.uri(), base_suffix);
let search_endpoint = if base_suffix.is_empty() {
"/v1/web/search"
} else {
"/web/search"
};
let tags = load_fixture("ollama_tags");
Mock::given(method("GET"))
.and(path("/api/tags"))
.and(header("authorization", "Bearer test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(tags))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.and(header("authorization", "Bearer test-key"))
.respond_with(
ResponseTemplate::new(status)
.set_body_json(error_body)
.set_delay(Duration::from_millis(5)),
)
.expect(1)
.mount(&server)
.await;
let config = configure_cloud(&raw_base, search_endpoint);
let cloud_cfg = config
.providers
.get("ollama_cloud")
.expect("cloud provider config")
.clone();
let provider: Arc<dyn Provider> = Arc::new(
OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general))
.expect("cloud provider"),
);
let (mut session, _tmp) = create_session(provider, config).await;
let err_text = match session
.send_message(prompt.to_string(), ChatParameters::default())
.await
{
Ok(_) => panic!("expected error status {status} but request succeeded"),
Err(err) => err.to_string(),
};
let snapshot = session
.current_usage_snapshot()
.await
.expect("usage snapshot");
assert_eq!(snapshot.hourly.total_tokens, 0);
assert_eq!(snapshot.weekly.total_tokens, 0);
err_text
}
#[tokio::test(flavor = "multi_thread")]
async fn local_provider_happy_path_records_usage() {
run_local_completion("").await;
}
#[tokio::test(flavor = "multi_thread")]
async fn local_provider_accepts_v1_base_url() {
run_local_completion("/v1").await;
}
#[tokio::test(flavor = "multi_thread")]
async fn cloud_tool_call_flows_through_web_search() {
run_cloud_tool_flow("").await;
}
#[tokio::test(flavor = "multi_thread")]
async fn cloud_tool_call_accepts_v1_base_url() {
run_cloud_tool_flow("/v1").await;
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn cloud_unauthorized_degrades_without_usage() { async fn cloud_unauthorized_degrades_without_usage() {
let server = MockServer::start().await; for suffix in ["", "/v1"] {
let err_text = run_cloud_error(
let tags = load_fixture("ollama_tags"); suffix,
let tags_template = ResponseTemplate::new(200).set_body_json(tags); 401,
let base_url = server.uri(); json!({ "error": "unauthorized" }),
Mock::given(method("GET")) "Switch to cloud",
.and(path("/api/tags")) )
.respond_with(tags_template.clone())
.mount(&server)
.await; .await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": "unauthorized"
})))
.mount(&server)
.await;
let config = configure_cloud(&base_url);
let cloud_cfg = config
.providers
.get("ollama_cloud")
.expect("cloud provider config")
.clone();
let provider: Arc<dyn Provider> = Arc::new(
OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general))
.expect("cloud provider"),
);
let (mut session, _tmp) = create_session(provider, config).await;
let err_text = match session
.send_message("Switch to cloud".to_string(), ChatParameters::default())
.await
{
Ok(_) => panic!("expected unauthorized error, but request succeeded"),
Err(err) => err.to_string(),
};
assert!( assert!(
err_text.contains("unauthorized") || err_text.contains("API key"), err_text.contains("unauthorized") || err_text.contains("API key"),
"error should surface unauthorized detail, got: {err_text}" "error should surface unauthorized detail for base '{suffix}', got: {err_text}"
); );
}
let snapshot = session
.current_usage_snapshot()
.await
.expect("usage snapshot");
assert_eq!(snapshot.hourly.total_tokens, 0);
assert_eq!(snapshot.weekly.total_tokens, 0);
} }
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn cloud_rate_limit_returns_error_without_usage() { async fn cloud_rate_limit_returns_error_without_usage() {
let server = MockServer::start().await; for suffix in ["", "/v1"] {
let err_text = run_cloud_error(
let tags = load_fixture("ollama_tags"); suffix,
let tags_template = ResponseTemplate::new(200).set_body_json(tags); 429,
let base_url = server.uri(); json!({ "error": "too many requests" }),
Mock::given(method("GET")) "Hit rate limit",
.and(path("/api/tags")) )
.respond_with(tags_template.clone())
.mount(&server)
.await; .await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(429).set_body_json(json!({
"error": "too many requests"
})))
.mount(&server)
.await;
let config = configure_cloud(&base_url);
let cloud_cfg = config
.providers
.get("ollama_cloud")
.expect("cloud provider config")
.clone();
let provider: Arc<dyn Provider> = Arc::new(
OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general))
.expect("cloud provider"),
);
let (mut session, _tmp) = create_session(provider, config).await;
let err_text = match session
.send_message("Hit rate limit".to_string(), ChatParameters::default())
.await
{
Ok(_) => panic!("expected rate-limit error, but request succeeded"),
Err(err) => err.to_string(),
};
assert!( assert!(
err_text.contains("rate limited") || err_text.contains("429"), err_text.contains("rate limited") || err_text.contains("429"),
"error should mention rate limiting, got: {err_text}" "error should mention rate limiting for base '{suffix}', got: {err_text}"
); );
}
let snapshot = session
.current_usage_snapshot()
.await
.expect("usage snapshot");
assert_eq!(snapshot.hourly.total_tokens, 0);
assert_eq!(snapshot.weekly.total_tokens, 0);
} }