From 2ebddb9ad5ade9b2bc7049d9fc04f9711d32047e Mon Sep 17 00:00:00 2001 From: Anaximandro Souza Date: Thu, 2 Apr 2026 21:50:31 -0300 Subject: [PATCH 1/4] feat: add support for custom OpenAI endpoints (OPENAI_BASE_URL) This commit adds support for custom OpenAI-compatible endpoints, allowing users to use providers like OpenCode Zen, Azure OpenAI, or self-hosted models with Memory Bank. Changes: - Add DEFAULT_OPENAI_URL constant in memory-bank-app - Add openai_url field to ServerSettings struct - Add base_url field to LlmProviderConfig::OpenAi variant - Support OPENAI_BASE_URL environment variable - Update OpenAI client builder to use custom base_url when provided - Update tests to include base_url field - Add documentation to README.md Example usage: export OPENAI_BASE_URL=https://opencode.ai/zen/v1 export OPENAI_API_KEY=your-key mb service restart Closes: feelingsonice/MemoryBank# --- README.md | 21 +++++++++++++++++++++ memory-bank-app/src/lib.rs | 4 ++++ memory-bank-server/src/config.rs | 17 ++++++++++++++--- memory-bank-server/src/llm.rs | 21 +++++++++++++++++---- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0a4277d..c25882d 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,27 @@ 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): + +**Using environment variables:** +```bash +export OPENAI_BASE_URL=https://opencode.ai/zen/v1 +export OPENAI_API_KEY=your-api-key +mb service restart +``` + +**Using settings.toml:** +```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`. + ## Advanced If you want to build from source instead of downloading a release, use: diff --git a/memory-bank-app/src/lib.rs b/memory-bank-app/src/lib.rs index 3ac9556..a832dfb 100644 --- a/memory-bank-app/src/lib.rs +++ b/memory-bank-app/src/lib.rs @@ -15,6 +15,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"; @@ -314,6 +315,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 +337,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() diff --git a/memory-bank-server/src/config.rs b/memory-bank-server/src/config.rs index 75b7648..19af2da 100644 --- a/memory-bank-server/src/config.rs +++ b/memory-bank-server/src/config.rs @@ -3,7 +3,7 @@ use clap::ValueEnum; 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, + DEFAULT_OLLAMA_URL, DEFAULT_OPENAI_MODEL, DEFAULT_OPENAI_URL, Namespace, OLLAMA_HISTORY_WINDOW_SIZE, ServerSettings, }; use std::env; @@ -58,7 +58,7 @@ impl std::str::FromStr for LlmProviderType { pub enum LlmProviderConfig { Gemini { api_key: String, model: String }, Anthropic { api_key: String, model: String }, - OpenAi { api_key: String, model: String }, + OpenAi { api_key: String, model: String, base_url: String }, Ollama { url: String, model: String }, } @@ -91,6 +91,11 @@ impl LlmProviderConfig { settings.and_then(|s| s.llm_model.as_deref()), DEFAULT_OPENAI_MODEL, ), + base_url: env_setting_or_default( + "OPENAI_BASE_URL", + settings.and_then(|s| s.openai_url.as_deref()), + DEFAULT_OPENAI_URL, + ), }), LlmProviderType::Ollama => Ok(Self::Ollama { url: env_setting_or_default( @@ -122,7 +127,13 @@ 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 } => { + if base_url == DEFAULT_OPENAI_URL { + write!(f, "OpenAi::{model}") + } else { + write!(f, "OpenAi::{model}@{base_url}") + } + } Self::Ollama { model, url } => write!(f, "Ollama::{model}@{url}"), } } diff --git a/memory-bank-server/src/llm.rs b/memory-bank-server/src/llm.rs index 278c656..ae1cdd1 100644 --- a/memory-bank-server/src/llm.rs +++ b/memory-bank-server/src/llm.rs @@ -299,7 +299,7 @@ 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,9 +325,19 @@ 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 { + use memory_bank_app::DEFAULT_OPENAI_URL; + + 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)) } @@ -467,6 +477,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 +607,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 +619,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"); From 4badc817e4c7a78d82f4689494650147ca81acdd Mon Sep 17 00:00:00 2001 From: Anaximandro Souza Date: Fri, 3 Apr 2026 10:25:22 -0300 Subject: [PATCH 2/4] fix: add missing field pattern in Display impl for OpenAi Rust pattern matching requires all struct fields to be mentioned or explicitly ignored with '..' or '_'. Added '..' to ignore api_key in the OpenAi variant pattern. --- memory-bank-server/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/memory-bank-server/src/config.rs b/memory-bank-server/src/config.rs index 19af2da..f2b90f0 100644 --- a/memory-bank-server/src/config.rs +++ b/memory-bank-server/src/config.rs @@ -127,7 +127,7 @@ impl fmt::Display for LlmProviderConfig { match self { Self::Gemini { model, .. } => write!(f, "Gemini::{model}"), Self::Anthropic { model, .. } => write!(f, "Anthropic::{model}"), - Self::OpenAi { model, base_url } => { + Self::OpenAi { model, base_url, .. } => { if base_url == DEFAULT_OPENAI_URL { write!(f, "OpenAi::{model}") } else { From dc499eba5813db2d0428fa674ef26657e6776dff Mon Sep 17 00:00:00 2001 From: B <42384776+feelingsonice@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:52:25 -0700 Subject: [PATCH 3/4] readme, tests, logging, config management, wiring from the cli, health check --- Cargo.lock | 1 + README.md | 26 ++++- docs/troubleshooting.md | 15 +++ memory-bank-app/Cargo.toml | 1 + memory-bank-app/src/lib.rs | 115 +++++++++++++++++++ memory-bank-cli/src/cli_help.rs | 9 +- memory-bank-cli/src/config.rs | 137 ++++++++++++++++++++++- memory-bank-cli/src/operations.rs | 62 +++++++++- memory-bank-cli/src/operations/render.rs | 25 ++++- memory-bank-cli/src/service.rs | 90 +++++++++++++++ memory-bank-cli/src/service/launch.rs | 27 ++++- memory-bank-cli/src/setup/plan.rs | 61 +++++++++- memory-bank-cli/src/setup/prompts.rs | 37 +++++- memory-bank-cli/src/setup/render.rs | 16 ++- memory-bank-server/src/config.rs | 126 ++++++++++++++++++--- memory-bank-server/src/http_server.rs | 16 ++- memory-bank-server/src/lib.rs | 2 + memory-bank-server/src/llm.rs | 39 ++++++- memory-bank-server/src/llm_eval.rs | 19 +++- 19 files changed, 777 insertions(+), 47 deletions(-) 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 c25882d..f1d5693 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,21 @@ Important: the agent you use directly is separate from the internal provider Mem Memory Bank supports custom OpenAI-compatible endpoints (such as OpenCode Zen, Azure OpenAI, or self-hosted models): -**Using environment variables:** +**Supported managed-service path:** ```bash -export OPENAI_BASE_URL=https://opencode.ai/zen/v1 -export OPENAI_API_KEY=your-api-key +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 ``` -**Using settings.toml:** +That writes the same saved setting shown below: ```toml [server] llm_provider = "open-ai" @@ -126,6 +133,17 @@ 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 a832dfb..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"; @@ -55,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); @@ -600,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..6efac7a 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,15 @@ 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", - }, + secret_choice: SecretChoice::KeepStored { 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 +151,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 f2b90f0..cff199c 100644 --- a/memory-bank-server/src/config.rs +++ b/memory-bank-server/src/config.rs @@ -3,8 +3,8 @@ use clap::ValueEnum; 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, DEFAULT_OPENAI_URL, Namespace, OLLAMA_HISTORY_WINDOW_SIZE, - ServerSettings, + DEFAULT_OLLAMA_URL, DEFAULT_OPENAI_MODEL, Namespace, OLLAMA_HISTORY_WINDOW_SIZE, + 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, base_url: 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,11 +104,12 @@ impl LlmProviderConfig { settings.and_then(|s| s.llm_model.as_deref()), DEFAULT_OPENAI_MODEL, ), - base_url: env_setting_or_default( + base_url: normalize_openai_url(&env_setting_or_default( "OPENAI_BASE_URL", settings.and_then(|s| s.openai_url.as_deref()), - DEFAULT_OPENAI_URL, - ), + 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( @@ -127,12 +141,10 @@ impl fmt::Display for LlmProviderConfig { match self { Self::Gemini { model, .. } => write!(f, "Gemini::{model}"), Self::Anthropic { model, .. } => write!(f, "Anthropic::{model}"), - Self::OpenAi { model, base_url, .. } => { - if base_url == DEFAULT_OPENAI_URL { - write!(f, "OpenAi::{model}") - } else { - write!(f, "OpenAi::{model}@{base_url}") - } + Self::OpenAi { + model, base_url, .. + } => { + write!(f, "{}", format_openai_model_id(model, base_url)) } Self::Ollama { model, url } => write!(f, "Ollama::{model}@{url}"), } @@ -460,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 ae1cdd1..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, base_url } => build_openai_llm(&api_key, &model, &base_url), + LlmProviderConfig::OpenAi { + api_key, + model, + base_url, + } => build_openai_llm(&api_key, &model, &base_url), LlmProviderConfig::Ollama { url, model } => build_ollama_llm(&url, &model), } } @@ -326,8 +331,6 @@ fn build_anthropic_llm(api_key: &str, model: &str) -> Result Result { - use memory_bank_app::DEFAULT_OPENAI_URL; - let client = if base_url == DEFAULT_OPENAI_URL { rig::providers::openai::Client::new(api_key) .map_err(|e| llm_initialization_error(e.to_string()))? @@ -339,7 +342,11 @@ fn build_openai_llm(api_key: &str, model: &str, base_url: &str) -> Result Result { @@ -357,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, )) } @@ -627,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 }, } } From 5c8e2f66bbe0449e0cc949905055d47a2156d523 Mon Sep 17 00:00:00 2001 From: B <42384776+feelingsonice@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:02:01 -0700 Subject: [PATCH 4/4] format --- memory-bank-cli/src/setup/render.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/memory-bank-cli/src/setup/render.rs b/memory-bank-cli/src/setup/render.rs index 6efac7a..dfd1a5e 100644 --- a/memory-bank-cli/src/setup/render.rs +++ b/memory-bank-cli/src/setup/render.rs @@ -139,7 +139,9 @@ mod tests { ollama_url: None, autostart: true, selected_agents: vec![AgentKind::Codex], - secret_choice: SecretChoice::KeepStored { key: "OPENAI_API_KEY" }, + secret_choice: SecretChoice::KeepStored { + key: "OPENAI_API_KEY", + }, advanced: AdvancedSettings { port: memory_bank_app::DEFAULT_PORT, openai_url: Some("https://opencode.ai/zen/v1".to_string()),