Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <MODEL_ID>`

## 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.
Expand Down
1 change: 1 addition & 0 deletions memory-bank-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
119 changes: 119 additions & 0 deletions memory-bank-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<String, OpenAiUrlError> {
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<Option<String>, 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);

Expand Down Expand Up @@ -314,6 +372,8 @@ pub struct ServerSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ollama_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openai_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encoder_provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fastembed_model: Option<String>,
Expand All @@ -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()
Expand Down Expand Up @@ -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");
Expand Down
9 changes: 8 additions & 1 deletion memory-bank-cli/src/cli_help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = "\
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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."));
}
}
Loading