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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,10 @@ reported by ID in the triggering command's `auto_pruned_ids` field.

The embedding client speaks the Ollama API by default and the
OpenAI-compatible embeddings API when the endpoint ends in `/v1` (or when
`MNEMON_EMBED_PROTOCOL=openai` is set). For example, a local server such as
`MNEMON_EMBED_PROTOCOL=openai` is set). OpenAI-compatible servers are
normally probed via their `models` route; servers that do not serve that
route (e.g. [Voyage AI](https://docs.voyageai.com)) are detected via an
embeddings round-trip instead. For example, a local server such as
[oMLX](https://omlx.dev) can be configured with:

```bash
Expand All @@ -478,6 +481,15 @@ export MNEMON_EMBED_API_KEY=sk-... # omit for keyless local servers
mnemon embed --status
```

A hosted provider such as Voyage AI needs only the endpoint, model, and key:

```bash
export MNEMON_EMBED_ENDPOINT=https://api.voyageai.com/v1
export MNEMON_EMBED_MODEL=voyage-3.5
export MNEMON_EMBED_API_KEY=pa-...
mnemon embed --status
```

## Development

```bash
Expand Down
128 changes: 86 additions & 42 deletions internal/memory/embed/ollama.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,15 @@ func (c *Client) endpointURL(route string) (string, error) {
return endpointURL, nil
}

// Available returns true if the embedding server's discovery endpoint
// responds successfully. Uses a 2s timeout to avoid blocking the CLI on
// unresponsive servers.
// Available returns true if the embedding server responds successfully.
// Uses a 2s timeout to avoid blocking the CLI on unresponsive servers.
//
// OpenAI-compatible servers are probed via GET <endpoint>/models, the
// conventional discovery route. Some compatible providers do not serve
// that route at all (e.g. Voyage AI returns 404 while /embeddings works);
// when the models route is missing (404/405/501) the probe falls back to
// a single embedding round-trip, so availability reflects the endpoint
// the client actually depends on.
func (c *Client) Available() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
Expand All @@ -145,21 +151,81 @@ func (c *Client) Available() bool {
default:
route = "api/tags"
}
status, ok := c.probeStatus(ctx, route)
if ok {
return true
}
if c.protocol == ProtocolOpenAI && (status == 404 || status == 405 || status == 501) {
return c.probeEmbed(ctx)
}
return false
}

// probeStatus issues a GET against a discovery route and reports the
// HTTP status code. Transport errors yield status 0, ok false.
func (c *Client) probeStatus(ctx context.Context, route string) (status int, ok bool) {
endpointURL, err := c.endpointURL(route)
if err != nil {
return false
return 0, false
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpointURL, nil)
if err != nil {
return false
return 0, false
}
c.applyAuth(req)
resp, err := c.http.Do(req)
if err != nil {
return 0, false
}
defer resp.Body.Close()
return resp.StatusCode, resp.StatusCode == http.StatusOK
}

// probeEmbed verifies availability with a real embedding round-trip and
// discards the vector. Only reached when the OpenAI models route does not
// exist, so auth or quota failures still report unavailable.
func (c *Client) probeEmbed(ctx context.Context) bool {
vec, err := c.embedWithContext(ctx, "availability probe")
if err != nil {
return false
}
return len(vec) > 0
}

// embedWithContext is Embed with a caller-supplied context so the
// availability probe can enforce its 2s deadline.
func (c *Client) embedWithContext(ctx context.Context, text string) ([]float64, error) {
req := embedRequest{Model: c.model, Input: text}
if c.dims > 0 {
req.Dimensions = c.dims
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}

endpointURL, err := c.endpointURL(c.embedRequestRoute())
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
c.applyAuth(httpReq)

resp, err := c.http.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("embed request: %w", err)
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("embedding provider returned status %d", resp.StatusCode)
}

return c.decodeEmbedResponse(resp)
}

// Model returns the configured model name.
Expand Down Expand Up @@ -196,47 +262,25 @@ type openaiEmbedResponse struct {
} `json:"data"`
}

// embedRequestRoute returns the protocol-specific embeddings route.
func (c *Client) embedRequestRoute() string {
if c.protocol == ProtocolOpenAI {
return "embeddings"
}
return "api/embed"
}

// Embed generates an embedding vector for the given text.
// The request body is identical for both protocols; only the endpoint
// path and the response shape differ.
func (c *Client) Embed(text string) ([]float64, error) {
req := embedRequest{Model: c.model, Input: text}
if c.dims > 0 {
req.Dimensions = c.dims
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}

var route string
switch c.protocol {
case ProtocolOpenAI:
route = "embeddings"
default:
route = "api/embed"
}
endpointURL, err := c.endpointURL(route)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequest(http.MethodPost, endpointURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
c.applyAuth(httpReq)

resp, err := c.http.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("embed request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("embedding provider returned status %d", resp.StatusCode)
}
return c.embedWithContext(context.Background(), text)
}

// decodeEmbedResponse parses a successful embeddings response under the
// active protocol. Shared between Embed and the OpenAI availability
// fallback so the probe and the real call cannot drift apart.
func (c *Client) decodeEmbedResponse(resp *http.Response) ([]float64, error) {
switch c.protocol {
case ProtocolOpenAI:
var result openaiEmbedResponse
Expand Down
86 changes: 86 additions & 0 deletions internal/memory/embed/openai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,89 @@ func TestOpenAIEmbedEmptyResponse(t *testing.T) {
t.Fatal("expected error for empty embedding response")
}
}

func TestOpenAIAvailableFallsBackWithoutModelsRoute(t *testing.T) {
// OpenAI-compatible servers without a models route (e.g. Voyage AI)
// must still be reported available via an embeddings round-trip.
t.Setenv("MNEMON_EMBED_API_KEY", "sk-test")
var embedRequests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/models":
http.NotFound(w, r)
case "/v1/embeddings":
if r.Method != http.MethodPost {
t.Errorf("expected POST /v1/embeddings, got %s", r.Method)
}
if got := r.Header.Get("Authorization"); got != "Bearer sk-test" {
t.Errorf("expected Bearer sk-test on fallback probe, got %q", got)
}
embedRequests++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"embedding":[1.0,2.0]}]}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()

t.Setenv("MNEMON_EMBED_ENDPOINT", srv.URL+"/v1")
c := NewClient()
if !c.Available() {
t.Fatal("expected Available() true when /v1/models is 404 but /v1/embeddings works")
}
if embedRequests != 1 {
t.Fatalf("expected exactly one embedding probe, got %d", embedRequests)
}
}

func TestOpenAIAvailableFallbackRejectsAuthFailure(t *testing.T) {
// A 404 models route plus a 401 embeddings route must report
// unavailable: availability follows the endpoint that matters.
t.Setenv("MNEMON_EMBED_API_KEY", "sk-bad")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/models":
http.NotFound(w, r)
case "/v1/embeddings":
w.WriteHeader(http.StatusUnauthorized)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()

t.Setenv("MNEMON_EMBED_ENDPOINT", srv.URL+"/v1")
c := NewClient()
if c.Available() {
t.Fatal("expected Available() false when fallback probe returns 401")
}
}

func TestOpenAIAvailableNoFallbackOnServerError(t *testing.T) {
// Only a missing models route (404/405/501) triggers the fallback.
// A 500 models route must report unavailable without an embeddings call.
var embedRequests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/models":
w.WriteHeader(http.StatusInternalServerError)
case "/v1/embeddings":
embedRequests++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"embedding":[1.0]}]}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()

t.Setenv("MNEMON_EMBED_ENDPOINT", srv.URL+"/v1")
c := NewClient()
if c.Available() {
t.Fatal("expected Available() false for 500 models route")
}
if embedRequests != 0 {
t.Fatalf("expected no embedding probe after 500 models route, got %d", embedRequests)
}
}
Loading