#1251·openfang

Embedding driver: support OpenAI-compatible servers with non-/v1 base paths and non-table embedding dimensions

Author: jlacour-gitCreated Jun 16, 2026Updated Jun 16, 2026

Summary

First off — thanks for OpenFang. While wiring agent-memory embeddings to a local OpenAI-compatible server (OpenVINO Model Server / OVMS, which serves its API under /v3 rather than /v1), I ran into two small but related rough edges in the embedding driver (crates/openfang-runtime/src/embedding.rs). Both are reasonable MVP shortcuts — they work perfectly for OpenAI / Ollama / vLLM, which all serve /v1 — so this is just about widening support for OpenAI-compatible servers that don't fit that exact shape. Line numbers are from current main.


1. Embedding base_url is force-appended with /v1, with no way to opt out

When a custom embedding base_url is supplied via [provider_urls], the driver appends /v1 for a fixed set of providers unless the URL already ends in /v1 (embedding.rs, around line 208):

rust
let needs_v1 = matches!(
    provider,
    "openai"
        | "groq"
        | "together"
        | "fireworks"
        | "mistral"
        | "ollama"
        | "vllm"
        | "lmstudio"
);
if needs_v1 && !trimmed.ends_with("/v1") {
    format!("{trimmed}/v1")
} else {
    trimmed.to_string()
}

This means there's no base_url value that produces a /v3 (or any non-/v1) path:

  • a bare host → /v1 appended,
  • http://my-server:8004/v3 → becomes http://my-server:8004/v3/v1 (broken),
  • http://my-server:8004/v1 → reaches the server but OVMS rejects it (it serves /v3).

So an OpenAI-compatible server on a different base path can't be reached without an external reverse proxy that rewrites /v1/embeddings/v3/embeddings.

Suggested fix (small): only append /v1 when the URL has no /vN segment already — respect any version the user explicitly provided. Something like:

rust
// append /v1 only if the user didn't already specify a /vN path
let has_version = trimmed.rsplit('/').next().map_or(false, |seg| {
    seg.len() > 1 && seg.starts_with('v') && seg[1..].chars().all(|c| c.is_ascii_digit())
});
if needs_v1 && !has_version {
    format!("{trimmed}/v1")
} else {
    trimmed.to_string()
}

Then [provider_urls].openai = "http://my-server:8004/v3" would work directly, and /v1 behavior is unchanged for everyone already on /v1.


2. Embedding dimensions are inferred from a hardcoded model-name table

Dimensions come from infer_dimensions(), a match on the model name (embedding.rs, around line 106):

rust
fn infer_dimensions(model: &str) -> usize {
    match model {
        "text-embedding-3-small" => 1536,
        "text-embedding-3-large" => 3072,
        "text-embedding-ada-002" => 1536,
        "all-MiniLM-L6-v2" => 384,
        "all-MiniLM-L12-v2" => 384,
        "all-mpnet-base-v2" => 768,
        "nomic-embed-text" => 768,
        "mxbai-embed-large" => 1024,
        _ => 1536, // default
    }
}

Any model not in the table (e.g. a Qwen3-Embedding variant) silently defaults to 1536, regardless of what the server actually returns. To get a correct value today, you have to rename your model to match a table key — which is awkward when the same name is also what gets sent to the server in the request body.

There's also a comment a bit further down (around line 160) that reads like this was meant to be handled from the response, but the value isn't actually updated there:

rust
// Update dimensions from actual response if available
let embeddings: Vec<Vec<f32>> = data.data.into_iter().map(|d| d.embedding).collect();

(For what it's worth, in my reading the inferred dims value doesn't seem to be used to size anything downstream — storage looks variable-length and recall compares vectors of the actual returned length — so on main this seems mostly cosmetic. But it's still a foot-gun: the reported/configured dimension can silently disagree with reality, and that's exactly the kind of thing that bites once something does start relying on it. I may be missing a caller — happy to be corrected.)

Suggested fix (either, or both):

  • derive dimensions from the actual embedding response length (the comment above suggests that was the intent), and/or
  • allow an explicit override, e.g. [memory] embedding_dimensions = 1024, that wins over the table for known and unknown model names alike.

Why these two together

Both let OpenFang work with OpenAI-compatible servers that don't match the OpenAI/Ollama "/v1 + a known model name" shape — without renaming models or running an external proxy. Happy to open a PR for #1 (it's small) if that's welcome, and to help test #2 against OVMS.

Environment

  • OpenFang v0.6.9 (built from source), Linux, OpenAI-compatible embedding server on /v3.