diff --git a/Cargo.lock b/Cargo.lock index 935c3d9..08769d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2169,6 +2169,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "url", ] [[package]] diff --git a/README.md b/README.md index 0a4277d..f1d5693 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,45 @@ If the setup is working, the agent should call `retrieve_memory` and answer usin Important: the agent you use directly is separate from the internal provider Memory Bank uses for memory analysis. For example, you can use Claude Code or OpenClaw while Memory Bank runs on Gemini, OpenAI, Anthropic, or Ollama. +### Custom OpenAI Endpoints + +Memory Bank supports custom OpenAI-compatible endpoints (such as OpenCode Zen, Azure OpenAI, or self-hosted models): + +**Supported managed-service path:** +```bash +mb setup +``` + +Choose `open-ai` as the provider, then open `Advanced settings` and set the `OpenAI base URL override`. + +**Or set it directly in managed config:** +```bash +mb config set server.llm_provider open-ai +mb config set server.openai_url https://opencode.ai/zen/v1 +mb service restart +``` + +That writes the same saved setting shown below: +```toml +[server] +llm_provider = "open-ai" +llm_model = "qwen3.6-plus-free" +openai_url = "https://opencode.ai/zen/v1" +``` + +When a custom `openai_url` is configured, Memory Bank will route all OpenAI API requests to that endpoint instead of the default `https://api.openai.com/v1`. + +Important: custom OpenAI-compatible endpoints often require a provider-specific model ID as well. If the default OpenAI model does not exist on your endpoint, set `server.llm_model` to the exact model string your endpoint expects. + +**Lower-level direct server path:** +```bash +OPENAI_API_KEY=your-api-key \ +OPENAI_BASE_URL=https://opencode.ai/zen/v1 \ +cargo run -p memory-bank-server -- --llm-provider open-ai +``` + +Use the direct env-var path only when you are running `memory-bank-server` yourself instead of the managed `mb` service. + ## Advanced If you want to build from source instead of downloading a release, use: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f8c84b6..f75d991 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -140,6 +140,21 @@ When a turn reaches that cap, Memory Bank marks it `exhausted`. That turn will n If you are using a slow local provider such as Ollama, lowering prompt size or increasing model throughput is still the best fix. The retry cap is a guardrail, not a substitute for a healthy provider setup. +## Custom OpenAI Endpoint Is Not Being Used + +For managed installs, configure custom OpenAI-compatible endpoints through: + +- `mb setup` -> Advanced settings -> `OpenAI base URL override` +- or `mb config set server.openai_url ` + +Then restart the service: + +- `mb service restart` + +If your endpoint is OpenAI-compatible but does not expose OpenAI's default model IDs, also set the exact model string your endpoint expects: + +- `mb config set server.llm_model ` + ## Server Fails To Open After Upgrading With An Ingest Schema Error The ingest retry-cap change adds a new `exhausted` turn status to the SQLite schema. diff --git a/memory-bank-app/Cargo.toml b/memory-bank-app/Cargo.toml index ee80deb..a176d30 100644 --- a/memory-bank-app/Cargo.toml +++ b/memory-bank-app/Cargo.toml @@ -10,6 +10,7 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true toml.workspace = true +url.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/memory-bank-app/src/lib.rs b/memory-bank-app/src/lib.rs index 3ac9556..2b2c4aa 100644 --- a/memory-bank-app/src/lib.rs +++ b/memory-bank-app/src/lib.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; +use url::Url; pub const APP_DIR_NAME: &str = ".memory_bank"; pub const DEFAULT_NAMESPACE_NAME: &str = "default"; @@ -15,6 +16,7 @@ pub const SETTINGS_FILE_NAME: &str = "settings.toml"; pub const DEFAULT_GEMINI_MODEL: &str = "gemini-3-flash-preview"; pub const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6"; pub const DEFAULT_OPENAI_MODEL: &str = "gpt-5-mini"; +pub const DEFAULT_OPENAI_URL: &str = "https://api.openai.com/v1"; pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434"; pub const DEFAULT_OLLAMA_MODEL: &str = "qwen3"; pub const DEFAULT_FASTEMBED_MODEL: &str = "jinaai/jina-embeddings-v2-base-code"; @@ -54,6 +56,62 @@ pub enum AppConfigError { UnsupportedSchemaVersion(u32), } +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum OpenAiUrlError { + #[error("OpenAI URL cannot be empty")] + Empty, + #[error("OpenAI URL must be a valid absolute URL: {0}")] + InvalidUrl(String), + #[error("OpenAI URL must use http or https: {0}")] + InvalidScheme(String), + #[error("OpenAI URL must include a host: {0}")] + MissingHost(String), + #[error("OpenAI URL must not include query parameters or fragments: {0}")] + QueryOrFragment(String), +} + +pub fn normalize_openai_url(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(OpenAiUrlError::Empty); + } + + let normalized = trimmed.trim_end_matches('/'); + let parsed = + Url::parse(normalized).map_err(|_| OpenAiUrlError::InvalidUrl(trimmed.to_string()))?; + + if !matches!(parsed.scheme(), "http" | "https") { + return Err(OpenAiUrlError::InvalidScheme(trimmed.to_string())); + } + + if parsed.host_str().is_none() { + return Err(OpenAiUrlError::MissingHost(trimmed.to_string())); + } + + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(OpenAiUrlError::QueryOrFragment(trimmed.to_string())); + } + + Ok(normalized.to_string()) +} + +pub fn normalize_openai_url_override(value: &str) -> Result, OpenAiUrlError> { + let normalized = normalize_openai_url(value)?; + if normalized == DEFAULT_OPENAI_URL { + Ok(None) + } else { + Ok(Some(normalized)) + } +} + +pub fn format_openai_model_id(model: &str, base_url: &str) -> String { + if base_url == DEFAULT_OPENAI_URL { + format!("OpenAi::{model}") + } else { + format!("OpenAi::{model}@{base_url}") + } +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Namespace(String); @@ -314,6 +372,8 @@ pub struct ServerSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub ollama_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub openai_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub encoder_provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub fastembed_model: Option, @@ -334,6 +394,7 @@ impl ServerSettings { self.llm_provider.is_none() && self.llm_model.is_none() && self.ollama_url.is_none() + && self.openai_url.is_none() && self.encoder_provider.is_none() && self.fastembed_model.is_none() && self.history_window_size.is_none() @@ -596,6 +657,64 @@ mod tests { assert_eq!(settings.resolved_port(), DEFAULT_PORT); } + #[test] + fn openai_url_normalization_trims_and_strips_trailing_slash() { + let normalized = + normalize_openai_url(" https://api.openai.com/v1/ ").expect("normalize url"); + + assert_eq!(normalized, DEFAULT_OPENAI_URL); + assert_eq!( + normalize_openai_url_override("https://api.openai.com/v1/") + .expect("normalize override"), + None + ); + } + + #[test] + fn openai_url_normalization_allows_custom_paths() { + let normalized = + normalize_openai_url("https://opencode.ai/zen/v1/").expect("normalize custom path"); + + assert_eq!(normalized, "https://opencode.ai/zen/v1"); + assert_eq!( + normalize_openai_url_override("https://opencode.ai/zen/v1") + .expect("normalize override"), + Some("https://opencode.ai/zen/v1".to_string()) + ); + } + + #[test] + fn openai_url_normalization_rejects_invalid_shapes() { + assert!(matches!( + normalize_openai_url(" "), + Err(OpenAiUrlError::Empty) + )); + assert!(matches!( + normalize_openai_url("ftp://example.com/v1"), + Err(OpenAiUrlError::InvalidScheme(_)) + )); + assert!(matches!( + normalize_openai_url("https://example.com/v1?foo=bar"), + Err(OpenAiUrlError::QueryOrFragment(_)) + )); + assert!(matches!( + normalize_openai_url("https://example.com/v1#frag"), + Err(OpenAiUrlError::QueryOrFragment(_)) + )); + } + + #[test] + fn openai_model_id_format_hides_default_endpoint() { + assert_eq!( + format_openai_model_id("gpt-5-mini", DEFAULT_OPENAI_URL), + "OpenAi::gpt-5-mini" + ); + assert_eq!( + format_openai_model_id("qwen3.6-plus-free", "https://opencode.ai/zen/v1"), + "OpenAi::qwen3.6-plus-free@https://opencode.ai/zen/v1" + ); + } + #[test] fn settings_load_defaults_from_comment_only_toml() { let temp = TempDir::new().expect("tempdir"); diff --git a/memory-bank-cli/src/cli_help.rs b/memory-bank-cli/src/cli_help.rs index b953cc4..527d30b 100644 --- a/memory-bank-cli/src/cli_help.rs +++ b/memory-bank-cli/src/cli_help.rs @@ -113,6 +113,7 @@ Supported keys: server.llm_provider server.llm_model server.ollama_url + server.openai_url server.encoder_provider server.fastembed_model server.history_window_size @@ -137,6 +138,7 @@ Examples: mb config get server.llm_provider mb config set service.port 4545 mb config set server.llm_provider gemini + mb config set server.openai_url https://opencode.ai/zen/v1 mb config set --yes server.fastembed_model custom/embed-model"; pub(crate) const CONFIG_SET_AFTER_HELP: &str = "\ @@ -147,6 +149,7 @@ Supported keys: server.llm_provider server.llm_model server.ollama_url + server.openai_url server.encoder_provider server.fastembed_model server.history_window_size @@ -169,10 +172,11 @@ Examples: mb config set server.llm_provider gemini mb config set active_namespace work-project mb config set server.llm_model \"\" + mb config set server.openai_url https://opencode.ai/zen/v1 mb config set --yes server.fastembed_model custom/embed-model Use an empty string to clear optional string overrides such as -`server.llm_model` or `server.ollama_url`. +`server.llm_model`, `server.ollama_url`, or `server.openai_url`. Changing `server.fastembed_model` requires confirmation because the next server start will rebuild the vector index and re-encode existing memories for that @@ -185,10 +189,13 @@ mod tests { #[test] fn config_help_catalog_mentions_supported_keys_and_examples() { assert!(CONFIG_AFTER_HELP.contains("server.llm_provider")); + assert!(CONFIG_AFTER_HELP.contains("server.openai_url")); assert!(CONFIG_AFTER_HELP.contains("server.fastembed_model")); assert!(CONFIG_AFTER_HELP.contains("integrations.openclaw.configured")); assert!(CONFIG_AFTER_HELP.contains("integrations.codex.configured")); + assert!(CONFIG_SET_AFTER_HELP.contains("server.openai_url")); assert!(CONFIG_SET_AFTER_HELP.contains("mb config set service.port 4545")); + assert!(CONFIG_SET_AFTER_HELP.contains("mb config set server.openai_url")); assert!(CONFIG_SET_AFTER_HELP.contains("Use `--yes` in automation.")); } } diff --git a/memory-bank-cli/src/config.rs b/memory-bank-cli/src/config.rs index 5d88b5d..e161472 100644 --- a/memory-bank-cli/src/config.rs +++ b/memory-bank-cli/src/config.rs @@ -9,7 +9,8 @@ use crate::domain::{ use crate::models::default_model_for_provider; use memory_bank_app::{ AppSettings, DEFAULT_FASTEMBED_MODEL, DEFAULT_NAMESPACE_NAME, DEFAULT_OLLAMA_URL, - IntegrationsSettings, Namespace, ServerSettings, ServiceSettings, + DEFAULT_OPENAI_URL, IntegrationsSettings, Namespace, ServerSettings, ServiceSettings, + format_openai_model_id, normalize_openai_url, normalize_openai_url_override, }; use std::str::FromStr; @@ -22,6 +23,7 @@ enum ConfigKey { ServerLlmProvider, ServerLlmModel, ServerOllamaUrl, + ServerOpenAiUrl, ServerEncoderProvider, ServerFastembedModel, ServerHistoryWindowSize, @@ -50,6 +52,7 @@ impl FromStr for ConfigKey { "server.llm_provider" => Ok(Self::ServerLlmProvider), "server.llm_model" => Ok(Self::ServerLlmModel), "server.ollama_url" => Ok(Self::ServerOllamaUrl), + "server.openai_url" => Ok(Self::ServerOpenAiUrl), "server.encoder_provider" => Ok(Self::ServerEncoderProvider), "server.fastembed_model" => Ok(Self::ServerFastembedModel), "server.history_window_size" => Ok(Self::ServerHistoryWindowSize), @@ -89,6 +92,12 @@ pub(crate) fn get_config_value(settings: &AppSettings, key: &str) -> Result Ok(resolved_openai_url( + settings + .server + .as_ref() + .and_then(|server| server.openai_url.as_deref()), + )?), ConfigKey::ServerEncoderProvider => Ok(resolved_encoder_provider(settings).to_string()), ConfigKey::ServerFastembedModel => Ok(settings .server @@ -175,6 +184,9 @@ pub(crate) fn set_config_value( if provider != ProviderId::Ollama { server.ollama_url = None; } + if provider != ProviderId::OpenAi { + server.openai_url = None; + } set_server(settings, server); } ConfigKey::ServerLlmModel => { @@ -201,6 +213,18 @@ pub(crate) fn set_config_value( }); set_server(settings, server); } + ConfigKey::ServerOpenAiUrl => { + let mut server = settings.server.clone().unwrap_or_default(); + server.openai_url = normalize_optional_string(value) + .map(|value| { + normalize_openai_url_override(&value).map_err(|error| { + AppError::InvalidConfigValue(key.to_string(), error.to_string()) + }) + }) + .transpose()? + .flatten(); + set_server(settings, server); + } ConfigKey::ServerEncoderProvider => { let provider = validate_encoder_provider(value.trim(), key)?; let mut server = settings.server.clone().unwrap_or_default(); @@ -315,6 +339,32 @@ pub(crate) fn resolved_llm_model(settings: &AppSettings) -> String { .unwrap_or_else(|| default_model_for_provider(llm_provider_value(settings)).to_string()) } +pub(crate) fn resolved_llm_model_id(settings: &AppSettings) -> Result { + let model = resolved_llm_model(settings); + Ok(match llm_provider(settings) { + ProviderId::Anthropic => format!("Anthropic::{model}"), + ProviderId::Gemini => format!("Gemini::{model}"), + ProviderId::OpenAi => format_openai_model_id( + &model, + &resolved_openai_url( + settings + .server + .as_ref() + .and_then(|server| server.openai_url.as_deref()), + )?, + ), + ProviderId::Ollama => format!( + "Ollama::{model}@{}", + resolved_ollama_url( + settings + .server + .as_ref() + .and_then(|server| server.ollama_url.as_deref()), + ) + ), + }) +} + pub(crate) fn resolved_fastembed_model(settings: &AppSettings) -> String { settings .server @@ -349,6 +399,15 @@ pub(crate) fn resolved_ollama_url(current: Option<&str>) -> String { .unwrap_or_else(|| DEFAULT_OLLAMA_URL.to_string()) } +pub(crate) fn resolved_openai_url(current: Option<&str>) -> Result { + match current { + Some(value) => normalize_openai_url(value).map_err(|error| { + AppError::InvalidConfigValue("server.openai_url".to_string(), error.to_string()) + }), + None => Ok(DEFAULT_OPENAI_URL.to_string()), + } +} + pub(crate) fn normalize_ollama_url(value: &str) -> String { let trimmed = value.trim().trim_end_matches('/'); if trimmed.is_empty() { @@ -502,6 +561,46 @@ mod tests { assert_eq!(server.ollama_url, None); } + #[test] + fn config_get_uses_default_openai_url_when_unset() { + let settings = AppSettings::default(); + + let openai_url = get_config_value(&settings, "server.openai_url").expect("openai url"); + + assert_eq!(openai_url, DEFAULT_OPENAI_URL); + } + + #[test] + fn config_set_round_trips_openai_url_and_clears_default() { + let mut settings = AppSettings::default(); + + set_config_value( + &mut settings, + "server.openai_url", + " https://opencode.ai/zen/v1/ ", + ) + .expect("set openai url"); + assert_eq!( + get_config_value(&settings, "server.openai_url").expect("get openai url"), + "https://opencode.ai/zen/v1" + ); + assert_eq!( + settings + .server + .as_ref() + .and_then(|server| server.openai_url.as_deref()), + Some("https://opencode.ai/zen/v1") + ); + + set_config_value(&mut settings, "server.openai_url", DEFAULT_OPENAI_URL) + .expect("reset openai url"); + assert_eq!( + get_config_value(&settings, "server.openai_url").expect("get default openai url"), + DEFAULT_OPENAI_URL + ); + assert!(settings.server.is_none()); + } + #[test] fn config_set_default_values_clear_overrides_and_sections() { let mut settings = AppSettings::default(); @@ -531,6 +630,42 @@ mod tests { assert!(settings.server.is_none()); } + #[test] + fn config_set_switching_away_from_openai_clears_saved_openai_url() { + let mut settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("open-ai".to_string()), + openai_url: Some("https://opencode.ai/zen/v1".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + + set_config_value(&mut settings, "server.llm_provider", "anthropic").expect("set provider"); + + assert!(settings.server.is_none()); + } + + #[test] + fn resolved_llm_model_id_includes_custom_openai_endpoint() { + let settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("open-ai".to_string()), + llm_model: Some("qwen3.6-plus-free".to_string()), + openai_url: Some("https://opencode.ai/zen/v1".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + + let model_id = resolved_llm_model_id(&settings).expect("llm model id"); + + assert_eq!( + model_id, + "OpenAi::qwen3.6-plus-free@https://opencode.ai/zen/v1" + ); + } + #[test] fn config_set_integration_flags_round_trip() { let mut settings = AppSettings::default(); diff --git a/memory-bank-cli/src/operations.rs b/memory-bank-cli/src/operations.rs index 3f6534c..aa81494 100644 --- a/memory-bank-cli/src/operations.rs +++ b/memory-bank-cli/src/operations.rs @@ -7,7 +7,8 @@ use crate::cli::{ConfigCommand, NamespaceCommand, ServiceCommand}; use crate::command_utils::yes_no; use crate::config::{ FastEmbedReindexChange, fastembed_reindex_change, get_config_value, llm_provider_value, - resolved_encoder_provider, resolved_llm_model, resolved_ollama_url, set_config_value, + resolved_encoder_provider, resolved_llm_model, resolved_llm_model_id, resolved_ollama_url, + resolved_openai_url, set_config_value, }; use crate::output::{ print_action_start, print_key_value, styled_command, styled_failure, styled_section, @@ -37,6 +38,7 @@ pub(crate) fn run_status() -> Result<(), AppError> { let runtime = service_runtime_summary(&paths, &settings)?; let provider = llm_provider_value(&settings); let model = resolved_llm_model(&settings); + let llm_model_id = resolved_llm_model_id(&settings)?; let encoder = resolved_encoder_provider(&settings); println!("{}", styled_title("Memory Bank")); @@ -58,6 +60,16 @@ pub(crate) fn run_status() -> Result<(), AppError> { .and_then(|server| server.ollama_url.as_deref()), ), ); + } else if provider == "open-ai" { + print_key_value( + "OpenAI URL", + resolved_openai_url( + settings + .server + .as_ref() + .and_then(|server| server.openai_url.as_deref()), + )?, + ); } print_key_value("Log file", paths.log_file.display()); @@ -73,7 +85,8 @@ pub(crate) fn run_status() -> Result<(), AppError> { } if let Some(health) = runtime.health.as_ref() { - let mismatch_fields = runtime_mismatch_fields(&settings, provider, encoder, health); + let mismatch_fields = + runtime_mismatch_fields(&settings, provider, encoder, &llm_model_id, health); if !mismatch_fields.is_empty() { println!(); println!( @@ -706,14 +719,57 @@ mod tests { port: 3737, llm_provider: "gemini".to_string(), encoder_provider: "remote-api".to_string(), + llm_model_id: None, + encoder_model_id: None, version: "test".to_string(), }; - let fields = runtime_mismatch_fields(&settings, "anthropic", "fast-embed", &health); + let fields = runtime_mismatch_fields( + &settings, + "anthropic", + "fast-embed", + "Anthropic::claude-sonnet-4-6", + &health, + ); assert_eq!(fields, vec!["namespace", "port", "provider", "encoder"]); } + #[test] + fn runtime_mismatch_fields_prefers_llm_model_id_when_available() { + let settings = AppSettings { + server: Some(memory_bank_app::ServerSettings { + llm_provider: Some("open-ai".to_string()), + llm_model: Some("qwen3.6-plus-free".to_string()), + openai_url: Some("https://opencode.ai/zen/v1".to_string()), + ..memory_bank_app::ServerSettings::default() + }), + ..AppSettings::default() + }; + let health = HealthCheck { + ok: true, + namespace: "default".to_string(), + port: memory_bank_app::DEFAULT_PORT, + llm_provider: "open-ai".to_string(), + encoder_provider: "fast-embed".to_string(), + llm_model_id: Some( + "OpenAi::qwen3.6-plus-free@https://other.example.com/v1".to_string(), + ), + encoder_model_id: None, + version: "test".to_string(), + }; + + let fields = runtime_mismatch_fields( + &settings, + "open-ai", + "fast-embed", + "OpenAi::qwen3.6-plus-free@https://opencode.ai/zen/v1", + &health, + ); + + assert_eq!(fields, vec!["llm model"]); + } + #[test] fn config_change_hint_depends_on_key_and_service_state() { let active_service = ServiceStatus { diff --git a/memory-bank-cli/src/operations/render.rs b/memory-bank-cli/src/operations/render.rs index b1d80b8..1a870e4 100644 --- a/memory-bank-cli/src/operations/render.rs +++ b/memory-bank-cli/src/operations/render.rs @@ -11,6 +11,7 @@ pub(super) fn runtime_mismatch_fields<'a>( settings: &'a AppSettings, provider: &'a str, encoder: &'a str, + llm_model_id: &'a str, health: &'a HealthCheck, ) -> Vec<&'static str> { let mut fields = Vec::new(); @@ -20,8 +21,16 @@ pub(super) fn runtime_mismatch_fields<'a>( if health.port != settings.resolved_port() { fields.push("port"); } - if health.llm_provider != provider { - fields.push("provider"); + match health.llm_model_id.as_deref() { + Some(runtime_model_id) => { + if runtime_model_id != llm_model_id { + fields.push("llm model"); + } + } + None if health.llm_provider != provider => { + fields.push("provider"); + } + None => {} } if health.encoder_provider != encoder { fields.push("encoder"); @@ -79,6 +88,12 @@ pub(super) fn print_live_runtime_section(runtime: &ServiceRuntimeSummary) { print_key_value("Port", health.port); print_key_value("Provider", &health.llm_provider); print_key_value("Encoder", &health.encoder_provider); + if let Some(model_id) = health.llm_model_id.as_deref() { + print_key_value("LLM model ID", model_id); + } + if let Some(model_id) = health.encoder_model_id.as_deref() { + print_key_value("Encoder model ID", model_id); + } print_key_value("Version", &health.version); } None => { @@ -162,6 +177,12 @@ pub(super) fn print_start_or_restart_result(report: &ServiceActionReport) { print_key_value("Port", health.port); print_key_value("Provider", &health.llm_provider); print_key_value("Encoder", &health.encoder_provider); + if let Some(model_id) = health.llm_model_id.as_deref() { + print_key_value("LLM model ID", model_id); + } + if let Some(model_id) = health.encoder_model_id.as_deref() { + print_key_value("Encoder model ID", model_id); + } print_key_value("Version", &health.version); } None if report.active_after => { diff --git a/memory-bank-cli/src/service.rs b/memory-bank-cli/src/service.rs index c303dc7..fab5f86 100644 --- a/memory-bank-cli/src/service.rs +++ b/memory-bank-cli/src/service.rs @@ -42,6 +42,10 @@ pub(crate) struct HealthCheck { pub(crate) port: u16, pub(crate) llm_provider: String, pub(crate) encoder_provider: String, + #[serde(default)] + pub(crate) llm_model_id: Option, + #[serde(default)] + pub(crate) encoder_model_id: Option, pub(crate) version: String, } @@ -943,6 +947,92 @@ mod tests { assert!(!spec.args.contains(&"99".to_string())); } + #[test] + fn launch_spec_includes_custom_openai_endpoint_environment() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("open-ai".to_string()), + llm_model: Some("qwen3.6-plus-free".to_string()), + openai_url: Some("https://opencode.ai/zen/v1/".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + let mut secrets = SecretStore::default(); + secrets.set("OPENAI_API_KEY", "openai-secret"); + + fs::create_dir_all(&paths.bin_dir).expect("bin dir"); + fs::write(paths.binary_path(SERVER_BINARY_NAME), "").expect("server placeholder"); + #[cfg(unix)] + make_runnable(&paths.binary_path(SERVER_BINARY_NAME)); + + let spec = build_server_launch_spec(&paths, &settings, &secrets).expect("spec"); + + assert_eq!( + spec.env.get("OPENAI_API_KEY").map(String::as_str), + Some("openai-secret") + ); + assert_eq!( + spec.env.get("OPENAI_BASE_URL").map(String::as_str), + Some("https://opencode.ai/zen/v1") + ); + assert!(spec.remove_env.contains(&"OPENAI_BASE_URL")); + } + + #[test] + fn launch_spec_omits_default_openai_endpoint_environment() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("open-ai".to_string()), + openai_url: Some(memory_bank_app::DEFAULT_OPENAI_URL.to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + let mut secrets = SecretStore::default(); + secrets.set("OPENAI_API_KEY", "openai-secret"); + + fs::create_dir_all(&paths.bin_dir).expect("bin dir"); + fs::write(paths.binary_path(SERVER_BINARY_NAME), "").expect("server placeholder"); + #[cfg(unix)] + make_runnable(&paths.binary_path(SERVER_BINARY_NAME)); + + let spec = build_server_launch_spec(&paths, &settings, &secrets).expect("spec"); + + assert!(!spec.env.contains_key("OPENAI_BASE_URL")); + assert!(spec.remove_env.contains(&"OPENAI_BASE_URL")); + } + + #[test] + fn launch_spec_rejects_invalid_openai_url() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("open-ai".to_string()), + openai_url: Some("https://opencode.ai/zen/v1?foo=bar".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + let mut secrets = SecretStore::default(); + secrets.set("OPENAI_API_KEY", "openai-secret"); + + fs::create_dir_all(&paths.bin_dir).expect("bin dir"); + fs::write(paths.binary_path(SERVER_BINARY_NAME), "").expect("server placeholder"); + #[cfg(unix)] + make_runnable(&paths.binary_path(SERVER_BINARY_NAME)); + + let error = build_server_launch_spec(&paths, &settings, &secrets) + .expect_err("invalid openai url should fail"); + + assert!(error.to_string().contains("server.openai_url")); + } + #[test] fn launch_spec_rejects_invalid_nearest_neighbor_count() { let temp = TempDir::new().expect("tempdir"); diff --git a/memory-bank-cli/src/service/launch.rs b/memory-bank-cli/src/service/launch.rs index aeb9115..3aefbb2 100644 --- a/memory-bank-cli/src/service/launch.rs +++ b/memory-bank-cli/src/service/launch.rs @@ -1,7 +1,8 @@ use crate::AppError; use crate::assets::{ExposureCheck, inspect_cli_exposure}; use crate::config::{ - llm_provider_value, normalize_ollama_url, validate_encoder_provider, validate_llm_provider, + llm_provider_value, normalize_ollama_url, resolved_openai_url, validate_encoder_provider, + validate_llm_provider, }; use crate::constants::{ DEFAULT_HISTORY_WINDOW_SIZE, DEFAULT_MAX_PROCESSING_ATTEMPTS, HOOK_BINARY_NAME, MB_BINARY_NAME, @@ -118,6 +119,17 @@ pub(crate) fn build_server_launch_spec( ); } } + ProviderId::OpenAi => { + if let Some(model) = server_settings.llm_model.clone() { + env.insert("MEMORY_BANK_LLM_MODEL".to_string(), model); + } + if let Some(url) = server_settings.openai_url.as_deref() { + let normalized = resolved_openai_url(Some(url))?; + if normalized != memory_bank_app::DEFAULT_OPENAI_URL { + env.insert("OPENAI_BASE_URL".to_string(), normalized); + } + } + } _ => { if let Some(model) = server_settings.llm_model.clone() { env.insert("MEMORY_BANK_LLM_MODEL".to_string(), model); @@ -164,6 +176,7 @@ pub(crate) fn build_server_launch_spec( "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", + "OPENAI_BASE_URL", "MEMORY_BANK_LLM_MODEL", "MEMORY_BANK_FASTEMBED_MODEL", "MEMORY_BANK_LOCAL_ENCODER_URL", @@ -216,6 +229,18 @@ pub(crate) fn collect_doctor_issues( issues.push(format!("missing {env_key} in ~/.memory_bank/secrets.env")); } + if matches!( + ProviderId::from_config_value(Some(llm_provider_value(settings))), + ProviderId::OpenAi + ) && let Some(url) = settings + .server + .as_ref() + .and_then(|server| server.openai_url.as_deref()) + && let Err(error) = resolved_openai_url(Some(url)) + { + issues.push(error.to_string()); + } + match settings .server .as_ref() diff --git a/memory-bank-cli/src/setup/plan.rs b/memory-bank-cli/src/setup/plan.rs index 14d82bc..814cb6b 100644 --- a/memory-bank-cli/src/setup/plan.rs +++ b/memory-bank-cli/src/setup/plan.rs @@ -6,7 +6,7 @@ use crate::constants::{ use crate::domain::{ProviderId, integration_configured, set_integration_configured}; use memory_bank_app::{ AppSettings, DEFAULT_FASTEMBED_MODEL, DEFAULT_NAMESPACE_NAME, DEFAULT_OLLAMA_URL, DEFAULT_PORT, - Namespace, SETTINGS_SCHEMA_VERSION, SecretStore, + Namespace, SETTINGS_SCHEMA_VERSION, SecretStore, normalize_openai_url_override, }; #[derive(Debug, Clone)] @@ -24,6 +24,7 @@ pub(super) struct SetupPlan { #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct AdvancedSettings { pub(super) port: u16, + pub(super) openai_url: Option, pub(super) fastembed_model: String, pub(super) history_window_size: u32, pub(super) nearest_neighbor_count: i32, @@ -43,6 +44,7 @@ impl AdvancedSettings { let server = settings.server.as_ref(); Self { port: settings.resolved_port(), + openai_url: server.and_then(|server| server.openai_url.clone()), fastembed_model: server .and_then(|server| server.fastembed_model.clone()) .unwrap_or_else(|| DEFAULT_FASTEMBED_MODEL.to_string()), @@ -60,6 +62,7 @@ impl AdvancedSettings { pub(super) fn has_overrides(&self) -> bool { self.port != DEFAULT_PORT + || self.openai_url.is_some() || self.fastembed_model != DEFAULT_FASTEMBED_MODEL || self.history_window_size != DEFAULT_HISTORY_WINDOW_SIZE || self.nearest_neighbor_count != DEFAULT_NEAREST_NEIGHBOR_COUNT @@ -71,6 +74,9 @@ impl AdvancedSettings { if self.port != DEFAULT_PORT { lines.push(format!("Port: {}", self.port)); } + if self.openai_url.is_some() { + lines.push("OpenAI URL override configured".to_string()); + } if self.fastembed_model != DEFAULT_FASTEMBED_MODEL { lines.push(format!("FastEmbed model: {}", self.fastembed_model)); } @@ -147,6 +153,18 @@ pub(super) fn build_settings_for_plan( } else { None }; + server.openai_url = if plan.provider == ProviderId::OpenAi { + plan.advanced + .openai_url + .as_deref() + .map(|url| { + normalize_openai_url_override(url) + .expect("OpenAI URL should be validated by the setup prompt") + }) + .unwrap_or(None) + } else { + None + }; server.fastembed_model = if plan.advanced.fastembed_model == DEFAULT_FASTEMBED_MODEL { None } else { @@ -218,6 +236,7 @@ mod tests { secret_choice: SecretChoice::NotRequired, advanced: AdvancedSettings { port: 4545, + openai_url: None, fastembed_model: "custom/embed-model".to_string(), history_window_size: 25, nearest_neighbor_count: 15, @@ -408,6 +427,7 @@ mod tests { fn advanced_override_lines_include_max_processing_attempts() { let advanced = AdvancedSettings { port: DEFAULT_PORT, + openai_url: Some("https://opencode.ai/zen/v1".to_string()), fastembed_model: DEFAULT_FASTEMBED_MODEL.to_string(), history_window_size: DEFAULT_HISTORY_WINDOW_SIZE, nearest_neighbor_count: DEFAULT_NEAREST_NEIGHBOR_COUNT, @@ -416,7 +436,44 @@ mod tests { assert_eq!( advanced.override_lines(), - vec!["Max processing attempts: 14".to_string()] + vec![ + "OpenAI URL override configured".to_string(), + "Max processing attempts: 14".to_string() + ] + ); + } + + #[test] + fn build_settings_for_openai_plan_persists_non_default_url() { + let plan = SetupPlan { + namespace: Namespace::new("default"), + provider: ProviderId::OpenAi, + model: "qwen3.6-plus-free".to_string(), + ollama_url: None, + autostart: false, + selected_agents: Vec::new(), + secret_choice: SecretChoice::ManualEntry { + key: "OPENAI_API_KEY", + value: "secret".to_string(), + }, + advanced: AdvancedSettings { + port: DEFAULT_PORT, + openai_url: Some("https://opencode.ai/zen/v1/".to_string()), + fastembed_model: DEFAULT_FASTEMBED_MODEL.to_string(), + history_window_size: DEFAULT_HISTORY_WINDOW_SIZE, + nearest_neighbor_count: DEFAULT_NEAREST_NEIGHBOR_COUNT, + max_processing_attempts: DEFAULT_MAX_PROCESSING_ATTEMPTS, + }, + }; + + let settings = build_settings_for_plan(&AppSettings::default(), &plan, &[]); + let server = settings.server.expect("server settings"); + + assert_eq!(server.llm_provider.as_deref(), Some("open-ai")); + assert_eq!(server.llm_model.as_deref(), Some("qwen3.6-plus-free")); + assert_eq!( + server.openai_url.as_deref(), + Some("https://opencode.ai/zen/v1") ); } } diff --git a/memory-bank-cli/src/setup/prompts.rs b/memory-bank-cli/src/setup/prompts.rs index 1b71787..a9b3e53 100644 --- a/memory-bank-cli/src/setup/prompts.rs +++ b/memory-bank-cli/src/setup/prompts.rs @@ -10,7 +10,10 @@ use crate::output::{no_color_requested, styled_subtle, styled_warning}; use inquire::ui::{Attributes, Color, RenderConfig, StyleSheet, Styled}; use inquire::validator::Validation; use inquire::{Confirm, CustomType, MultiSelect, Select, Text, set_global_render_config}; -use memory_bank_app::{AppSettings, DEFAULT_OLLAMA_URL, Namespace, SecretStore}; +use memory_bank_app::{ + AppSettings, DEFAULT_OLLAMA_URL, DEFAULT_OPENAI_URL, Namespace, SecretStore, + normalize_openai_url, +}; use std::io::{self, IsTerminal}; use super::plan::{AdvancedSettings, SecretChoice, SetupPlan}; @@ -160,7 +163,8 @@ pub(super) fn collect_setup_plan( .into_result()?; if configure_advanced { - advanced = prompt_advanced_settings(settings).and_then(WizardStep::into_result)?; + advanced = + prompt_advanced_settings(settings, provider).and_then(WizardStep::into_result)?; } Ok(SetupPlan { @@ -243,6 +247,27 @@ fn prompt_ollama_url(current: Option<&str>) -> Result, AppErr )) } +fn prompt_openai_url(current: Option<&str>) -> Result, AppError> { + Ok(WizardStep::from_option( + Text::new("OpenAI base URL override") + .with_default(current.unwrap_or(DEFAULT_OPENAI_URL)) + .with_help_message( + "Leave this at the default unless you are routing Memory Bank through an OpenAI-compatible endpoint. Custom endpoints may also require a custom model string chosen earlier.", + ) + .with_placeholder("https://api.openai.com/v1") + .with_validator(|value: &str| { + Ok(match normalize_openai_url(value) { + Ok(_) => Validation::Valid, + Err(error) => Validation::Invalid(error.to_string().into()), + }) + }) + .prompt_skippable()? + .map(|value| { + normalize_openai_url(&value).expect("validator should normalize OpenAI URL") + }), + )) +} + fn prompt_model( provider: ProviderId, current: Option<&str>, @@ -511,6 +536,7 @@ fn manual_secret_choice(secret_key: &'static str) -> Result Result, AppError> { let current = AdvancedSettings::from_settings(settings); @@ -529,6 +555,12 @@ fn prompt_advanced_settings( ) .into_result()?; + let openai_url = if provider == ProviderId::OpenAi { + Some(prompt_openai_url(current.openai_url.as_deref()).and_then(WizardStep::into_result)?) + } else { + None + }; + let fastembed_model = WizardStep::from_option( Text::new("FastEmbed model override") .with_default(current.fastembed_model.as_str()) @@ -592,6 +624,7 @@ fn prompt_advanced_settings( Ok(WizardStep::Continue(AdvancedSettings { port, + openai_url, fastembed_model, history_window_size, nearest_neighbor_count, diff --git a/memory-bank-cli/src/setup/render.rs b/memory-bank-cli/src/setup/render.rs index 1a6039a..dfd1a5e 100644 --- a/memory-bank-cli/src/setup/render.rs +++ b/memory-bank-cli/src/setup/render.rs @@ -48,6 +48,11 @@ pub(super) fn render_review_summary(plan: &SetupPlan) -> String { if let Some(url) = plan.ollama_url.as_deref() { lines.insert(6, format!(" Ollama URL: {url}")); } + if plan.provider == crate::domain::ProviderId::OpenAi + && let Some(url) = plan.advanced.openai_url.as_deref() + { + lines.insert(6, format!(" OpenAI URL: {url}")); + } let overrides = plan.advanced.override_lines(); lines.push(String::new()); @@ -129,16 +134,17 @@ mod tests { fn render_review_summary_includes_max_processing_attempts_override() { let plan = SetupPlan { namespace: Namespace::new("default"), - provider: ProviderId::Anthropic, - model: memory_bank_app::DEFAULT_ANTHROPIC_MODEL.to_string(), + provider: ProviderId::OpenAi, + model: "qwen3.6-plus-free".to_string(), ollama_url: None, autostart: true, selected_agents: vec![AgentKind::Codex], secret_choice: SecretChoice::KeepStored { - key: "ANTHROPIC_API_KEY", + key: "OPENAI_API_KEY", }, advanced: AdvancedSettings { port: memory_bank_app::DEFAULT_PORT, + openai_url: Some("https://opencode.ai/zen/v1".to_string()), fastembed_model: memory_bank_app::DEFAULT_FASTEMBED_MODEL.to_string(), history_window_size: memory_bank_app::DEFAULT_HISTORY_WINDOW_SIZE, nearest_neighbor_count: crate::constants::DEFAULT_NEAREST_NEIGHBOR_COUNT, @@ -147,6 +153,8 @@ mod tests { }; let summary = render_review_summary(&plan); + assert!(summary.contains("OpenAI URL: https://opencode.ai/zen/v1")); + assert!(summary.contains("OpenAI URL override configured")); assert!(summary.contains("Max processing attempts: 12")); } diff --git a/memory-bank-server/src/config.rs b/memory-bank-server/src/config.rs index 75b7648..cff199c 100644 --- a/memory-bank-server/src/config.rs +++ b/memory-bank-server/src/config.rs @@ -4,7 +4,7 @@ use memory_bank_app::{ AppPaths, AppSettings, DEFAULT_ANTHROPIC_MODEL, DEFAULT_FASTEMBED_MODEL, DEFAULT_GEMINI_MODEL, DEFAULT_HISTORY_WINDOW_SIZE, DEFAULT_MAX_PROCESSING_ATTEMPTS, DEFAULT_OLLAMA_MODEL, DEFAULT_OLLAMA_URL, DEFAULT_OPENAI_MODEL, Namespace, OLLAMA_HISTORY_WINDOW_SIZE, - ServerSettings, + ServerSettings, format_openai_model_id, normalize_openai_url, }; use std::env; use std::fmt; @@ -56,10 +56,23 @@ impl std::str::FromStr for LlmProviderType { #[derive(Debug, Clone)] pub enum LlmProviderConfig { - Gemini { api_key: String, model: String }, - Anthropic { api_key: String, model: String }, - OpenAi { api_key: String, model: String }, - Ollama { url: String, model: String }, + Gemini { + api_key: String, + model: String, + }, + Anthropic { + api_key: String, + model: String, + }, + OpenAi { + api_key: String, + model: String, + base_url: String, + }, + Ollama { + url: String, + model: String, + }, } impl LlmProviderConfig { @@ -91,6 +104,12 @@ impl LlmProviderConfig { settings.and_then(|s| s.llm_model.as_deref()), DEFAULT_OPENAI_MODEL, ), + base_url: normalize_openai_url(&env_setting_or_default( + "OPENAI_BASE_URL", + settings.and_then(|s| s.openai_url.as_deref()), + memory_bank_app::DEFAULT_OPENAI_URL, + )) + .map_err(|error| crate::error::AppError::Config(error.to_string()))?, }), LlmProviderType::Ollama => Ok(Self::Ollama { url: env_setting_or_default( @@ -122,7 +141,11 @@ impl fmt::Display for LlmProviderConfig { match self { Self::Gemini { model, .. } => write!(f, "Gemini::{model}"), Self::Anthropic { model, .. } => write!(f, "Anthropic::{model}"), - Self::OpenAi { model, .. } => write!(f, "OpenAi::{model}"), + Self::OpenAi { + model, base_url, .. + } => { + write!(f, "{}", format_openai_model_id(model, base_url)) + } Self::Ollama { model, url } => write!(f, "Ollama::{model}@{url}"), } } @@ -449,6 +472,90 @@ mod tests { )); } + #[test] + fn openai_provider_uses_default_url() { + let _lock = ENV_LOCK.lock().expect("env lock"); + let _guard = EnvVarGuard::new(&["OPENAI_API_KEY", "OPENAI_BASE_URL"]); + unsafe { + env::set_var("OPENAI_API_KEY", "secret"); + } + + let config = + LlmProviderConfig::from_resolved(LlmProviderType::OpenAi, None).expect("openai"); + + assert!(matches!( + config, + LlmProviderConfig::OpenAi { ref base_url, .. } if base_url == memory_bank_app::DEFAULT_OPENAI_URL + )); + assert_eq!(config.to_string(), "OpenAi::gpt-5-mini"); + } + + #[test] + fn openai_provider_normalizes_settings_override() { + let _lock = ENV_LOCK.lock().expect("env lock"); + let _guard = EnvVarGuard::new(&["OPENAI_API_KEY", "OPENAI_BASE_URL"]); + unsafe { + env::set_var("OPENAI_API_KEY", "secret"); + } + + let config = LlmProviderConfig::from_resolved( + LlmProviderType::OpenAi, + Some(&ServerSettings { + openai_url: Some(" https://opencode.ai/zen/v1/ ".to_string()), + ..ServerSettings::default() + }), + ) + .expect("openai"); + + assert!(matches!( + config, + LlmProviderConfig::OpenAi { ref base_url, .. } if base_url == "https://opencode.ai/zen/v1" + )); + assert_eq!( + config.to_string(), + "OpenAi::gpt-5-mini@https://opencode.ai/zen/v1" + ); + } + + #[test] + fn openai_provider_env_override_takes_precedence() { + let _lock = ENV_LOCK.lock().expect("env lock"); + let _guard = EnvVarGuard::new(&["OPENAI_API_KEY", "OPENAI_BASE_URL"]); + unsafe { + env::set_var("OPENAI_API_KEY", "secret"); + env::set_var("OPENAI_BASE_URL", "https://env.example.com/v1/"); + } + + let config = LlmProviderConfig::from_resolved( + LlmProviderType::OpenAi, + Some(&ServerSettings { + openai_url: Some("https://settings.example.com/v1".to_string()), + ..ServerSettings::default() + }), + ) + .expect("openai"); + + assert!(matches!( + config, + LlmProviderConfig::OpenAi { ref base_url, .. } if base_url == "https://env.example.com/v1" + )); + } + + #[test] + fn openai_provider_rejects_invalid_base_url() { + let _lock = ENV_LOCK.lock().expect("env lock"); + let _guard = EnvVarGuard::new(&["OPENAI_API_KEY", "OPENAI_BASE_URL"]); + unsafe { + env::set_var("OPENAI_API_KEY", "secret"); + env::set_var("OPENAI_BASE_URL", "https://example.com/v1?foo=bar"); + } + + let error = LlmProviderConfig::from_resolved(LlmProviderType::OpenAi, None) + .expect_err("invalid openai url should fail"); + + assert!(error.to_string().contains("query parameters or fragments")); + } + #[test] fn serve_config_uses_settings_defaults_when_flags_are_omitted() { let _lock = ENV_LOCK.lock().expect("env lock"); diff --git a/memory-bank-server/src/http_server.rs b/memory-bank-server/src/http_server.rs index bf38a76..d6416f5 100644 --- a/memory-bank-server/src/http_server.rs +++ b/memory-bank-server/src/http_server.rs @@ -176,6 +176,10 @@ pub struct HealthResponse { pub port: u16, pub llm_provider: String, pub encoder_provider: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub llm_model_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encoder_model_id: Option, pub version: &'static str, } @@ -197,7 +201,7 @@ mod tests { use crate::actor::{MemoryHandle, TestStoreTurnRequest}; use crate::db::SqliteRuntime; use crate::ingest::IngestService; - use axum::body::Body; + use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use memory_bank_protocol::{ ConversationFragment, ConversationScope, FragmentBody, INGEST_PROTOCOL_VERSION, @@ -620,6 +624,12 @@ mod tests { .expect("response"); assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("health body"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("health json"); + assert_eq!(json["llm_model_id"], "Anthropic::claude-sonnet-4-6"); + assert_eq!(json["encoder_model_id"], "FastEmbed::default"); } async fn app() -> axum::Router { @@ -635,6 +645,8 @@ mod tests { port: 3737, llm_provider: "anthropic".to_string(), encoder_provider: "fast-embed".to_string(), + llm_model_id: Some("Anthropic::claude-sonnet-4-6".to_string()), + encoder_model_id: Some("FastEmbed::default".to_string()), version: "test", }; build_app(health, memory, ingest, log_tx, &shutdown) @@ -859,6 +871,8 @@ mod tests { port: 3737, llm_provider: "anthropic".to_string(), encoder_provider: "fast-embed".to_string(), + llm_model_id: Some("Anthropic::claude-sonnet-4-6".to_string()), + encoder_model_id: Some("FastEmbed::default".to_string()), version: "test", } } diff --git a/memory-bank-server/src/lib.rs b/memory-bank-server/src/lib.rs index 07d0950..6eaefb5 100644 --- a/memory-bank-server/src/lib.rs +++ b/memory-bank-server/src/lib.rs @@ -111,6 +111,8 @@ pub async fn run(config: ServeConfig) -> Result<(), error::AppError> { port, llm_provider: llm_provider_name, encoder_provider: encoder_provider_name, + llm_model_id: Some(llm.model_id.clone()), + encoder_model_id: Some(encoder.model_id.clone()), version: env!("CARGO_PKG_VERSION"), }; diff --git a/memory-bank-server/src/llm.rs b/memory-bank-server/src/llm.rs index 278c656..6e6af85 100644 --- a/memory-bank-server/src/llm.rs +++ b/memory-bank-server/src/llm.rs @@ -9,6 +9,7 @@ use url::Url; use crate::config::LlmProviderConfig; use crate::error::{AppError, LlmError}; +use memory_bank_app::{DEFAULT_OPENAI_URL, format_openai_model_id}; #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default)] pub struct ExtractedMemoryAnalysis { @@ -299,7 +300,11 @@ fn llm_client_from_config(config: LlmProviderConfig) -> Result build_gemini_llm(&api_key, &model), LlmProviderConfig::Anthropic { api_key, model } => build_anthropic_llm(&api_key, &model), - LlmProviderConfig::OpenAi { api_key, model } => build_openai_llm(&api_key, &model), + LlmProviderConfig::OpenAi { + api_key, + model, + base_url, + } => build_openai_llm(&api_key, &model, &base_url), LlmProviderConfig::Ollama { url, model } => build_ollama_llm(&url, &model), } } @@ -325,11 +330,23 @@ fn build_anthropic_llm(api_key: &str, model: &str) -> Result Result { - let client = rig::providers::openai::Client::new(api_key) - .map_err(|e| llm_initialization_error(e.to_string()))?; +fn build_openai_llm(api_key: &str, model: &str, base_url: &str) -> Result { + let client = if base_url == DEFAULT_OPENAI_URL { + rig::providers::openai::Client::new(api_key) + .map_err(|e| llm_initialization_error(e.to_string()))? + } else { + rig::providers::openai::Client::builder() + .api_key(api_key) + .base_url(base_url) + .build() + .map_err(|e| llm_initialization_error(e.to_string()))? + }; // OpenAI prompt caching is automatic on supported models. - Ok(build_openai_responses_llm(&client, model)) + Ok(build_openai_responses_llm( + &client, + model, + &format_openai_model_id(model, base_url), + )) } fn build_ollama_llm(url: &str, model: &str) -> Result { @@ -347,10 +364,14 @@ fn build_ollama_llm(url: &str, model: &str) -> Result { ))) } -fn build_openai_responses_llm(client: &rig::providers::openai::Client, model: &str) -> LlmClient { +fn build_openai_responses_llm( + client: &rig::providers::openai::Client, + model: &str, + model_label: &str, +) -> LlmClient { LlmClient::OpenAi(build_rig_structured_llm( client.completion_model(model), - format!("OpenAi::{model}"), + model_label, )) } @@ -467,6 +488,7 @@ mod tests { }; use crate::config::LlmProviderConfig; use chrono::Utc; + use memory_bank_app::DEFAULT_OPENAI_URL; use rig::providers::anthropic::Client; use std::io::{Read, Write}; use std::net::TcpListener; @@ -596,6 +618,7 @@ mod tests { llm_client_from_config(LlmProviderConfig::OpenAi { api_key: "test-key".to_string(), model: "gpt-5-mini".to_string(), + base_url: DEFAULT_OPENAI_URL.to_string(), }) .expect("openai client"), LlmClient::OpenAi(_) @@ -607,6 +630,7 @@ mod tests { let InitializedLlm { client, model_id } = super::initialize(LlmProviderConfig::OpenAi { api_key: "test-key".to_string(), model: "gpt-5-mini".to_string(), + base_url: DEFAULT_OPENAI_URL.to_string(), }) .expect("initialize openai"); @@ -614,6 +638,22 @@ mod tests { assert_eq!(model_id, "OpenAi::gpt-5-mini"); } + #[tokio::test] + async fn initialize_includes_custom_openai_endpoint_in_model_id() { + let InitializedLlm { client, model_id } = super::initialize(LlmProviderConfig::OpenAi { + api_key: "test-key".to_string(), + model: "qwen3.6-plus-free".to_string(), + base_url: "https://opencode.ai/zen/v1".to_string(), + }) + .expect("initialize custom openai"); + + assert!(matches!(client, LlmClient::OpenAi(_))); + assert_eq!( + model_id, + "OpenAi::qwen3.6-plus-free@https://opencode.ai/zen/v1" + ); + } + #[test] fn ollama_url_rejects_openai_compat_suffix() { let error = validate_ollama_url("http://localhost:11434/v1") diff --git a/memory-bank-server/src/llm_eval.rs b/memory-bank-server/src/llm_eval.rs index 38ecf4b..141d5dd 100644 --- a/memory-bank-server/src/llm_eval.rs +++ b/memory-bank-server/src/llm_eval.rs @@ -8,7 +8,8 @@ use crate::retrieval_eval::real_eval_lock; use chrono::Utc; use memory_bank_app::{ AppPaths, AppSettings, DEFAULT_ANTHROPIC_MODEL, DEFAULT_FASTEMBED_MODEL, DEFAULT_GEMINI_MODEL, - DEFAULT_OLLAMA_MODEL, DEFAULT_OLLAMA_URL, DEFAULT_OPENAI_MODEL, + DEFAULT_OLLAMA_MODEL, DEFAULT_OLLAMA_URL, DEFAULT_OPENAI_MODEL, DEFAULT_OPENAI_URL, + normalize_openai_url, }; use serde::Serialize; use sqlx::Row; @@ -200,6 +201,12 @@ fn resolve_llm_provider_config( settings.and_then(|value| value.llm_model.as_deref()), DEFAULT_OPENAI_MODEL, ), + base_url: normalize_openai_url(&env_setting_or_default( + "OPENAI_BASE_URL", + settings.and_then(|value| value.openai_url.as_deref()), + DEFAULT_OPENAI_URL, + )) + .map_err(|error| crate::error::AppError::Config(error.to_string()))?, }), LlmProviderType::Ollama => Ok(LlmProviderConfig::Ollama { url: env_setting_or_default( @@ -254,9 +261,13 @@ impl TestLlmConfigExt for LlmProviderConfig { LlmProviderConfig::Anthropic { api_key, .. } => { LlmProviderConfig::Anthropic { api_key, model } } - LlmProviderConfig::OpenAi { api_key, .. } => { - LlmProviderConfig::OpenAi { api_key, model } - } + LlmProviderConfig::OpenAi { + api_key, base_url, .. + } => LlmProviderConfig::OpenAi { + api_key, + model, + base_url, + }, LlmProviderConfig::Ollama { url, .. } => LlmProviderConfig::Ollama { url, model }, } }