Skip to content

Commit 07a6e7c

Browse files
committed
feat: add ai/embed connector for RAG (#153)
Adds a text-embeddings connector as the foundation for retrieval-augmented generation. - ai/embed connector (internal/connector/embed.go): provider selection, base-URL/model allowlists, and AI metrics mirroring ai/completion. It lives under the "ai/" prefix so the engine's token-budget accounting applies automatically (it returns usage.total_tokens). - EmbeddingProvider interface + OpenAI implementation (also covers Azure OpenAI and OpenAI-compatible servers via base_url). Extracted the shared OpenAI credential-header logic used by chat and embeddings. - Output includes a pgvector text literal (output.vector, "[...]") so an embedding drops straight into a postgres/query arg cast with ::vector, removing the array-encoding friction of building RAG by hand. - Registered ai/embed; serve applies the base-URL allowlist to it. - Example: pgvector RAG (rag-kb-schema.sql, rag-ingest.yaml, rag-ask.yaml), a rag-guide doc, and an ai/embed connector-reference entry. Both workflows pass `mantle validate`. Scoped as the first RAG increment. Bedrock embeddings (InvokeModel) and native kb/upsert / kb/query + chunking are planned follow-ups on #153. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnKgCRfMvFcZAkoVbr8k79
1 parent 32557fc commit 07a6e7c

12 files changed

Lines changed: 750 additions & 13 deletions

File tree

.changeset/ai-embed-connector.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@mantle/engine": minor
3+
---
4+
5+
Add an `ai/embed` connector for text embeddings, enabling retrieval-augmented generation (RAG). It mirrors `ai/completion`'s provider selection, base-URL allowlist, metrics, and token-budget accounting, and supports OpenAI and OpenAI-compatible endpoints (Azure OpenAI, local servers) via `base_url`. Output includes a pgvector text literal (`output.vector`) so embeddings drop straight into a `postgres/query` arg cast with `::vector`. Ships with a pgvector RAG example (`rag-ingest.yaml`, `rag-ask.yaml`, `rag-kb-schema.sql`) and a guide. Bedrock embeddings and native `kb/*` / chunking are planned follow-ups (#153).

packages/engine/internal/cli/serve.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ func newServeCommand() *cobra.Command {
7171
}
7272
}
7373

74+
// Apply the base-URL allowlist to the embeddings connector too, so
75+
// ai/embed can't be pointed at arbitrary hosts. The model allowlist
76+
// is intentionally not shared: allowed_models lists chat models, and
77+
// applying it here would block embedding models.
78+
if embConn, err := eng.Registry.Get("ai/embed"); err == nil {
79+
if emb, ok := embConn.(*connector.EmbeddingConnector); ok {
80+
emb.AllowedBaseURLs = cfg.Engine.AllowedBaseURLs
81+
}
82+
}
83+
7484
// Wire up Postgres-backed audit emitter with auth context enrichment.
7585
auditor := &audit.PostgresEmitter{
7686
DB: database,

packages/engine/internal/connector/connector.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func NewRegistry() *Registry {
2727
}
2828
r.Register("http/request", &HTTPConnector{})
2929
r.Register("ai/completion", &AIConnector{})
30+
r.Register("ai/embed", &EmbeddingConnector{})
3031
r.Register("slack/send", &SlackSendConnector{})
3132
r.Register("slack/history", &SlackHistoryConnector{})
3233
r.Register("postgres/query", &PostgresQueryConnector{})
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package connector
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"strconv"
8+
"strings"
9+
"time"
10+
11+
"github.com/dvflw/mantle/internal/metrics"
12+
)
13+
14+
// EmbeddingConnector implements the ai/embed action: it turns text into vector
15+
// embeddings via a provider's embeddings API. It mirrors AIConnector's provider
16+
// selection, base-URL/model allowlists, and metrics. Because the action lives
17+
// under the "ai/" prefix, the engine's token-budget accounting applies to it
18+
// automatically (via the usage.total_tokens it returns).
19+
type EmbeddingConnector struct {
20+
Client *http.Client
21+
AllowedBaseURLs []string
22+
AllowedModels []string // empty = all models allowed
23+
}
24+
25+
// Execute embeds one or more input strings and returns the vectors, including
26+
// pgvector text literals for direct use in a postgres/query arg (e.g.
27+
// INSERT ... VALUES ($1::vector)).
28+
func (c *EmbeddingConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) {
29+
providerName, _ := params["provider"].(string)
30+
if providerName == "" {
31+
providerName = "openai"
32+
}
33+
34+
model, _ := params["model"].(string)
35+
if model == "" {
36+
return nil, fmt.Errorf("ai/embed: model is required")
37+
}
38+
if len(c.AllowedModels) > 0 && !stringInSlice(model, c.AllowedModels) {
39+
return nil, fmt.Errorf("ai/embed: model %q not in allowed list", model)
40+
}
41+
42+
inputs, err := extractEmbedInputs(params)
43+
if err != nil {
44+
return nil, fmt.Errorf("ai/embed: %w", err)
45+
}
46+
47+
provider, err := c.getProvider(providerName, params)
48+
if err != nil {
49+
return nil, fmt.Errorf("ai/embed: %w", err)
50+
}
51+
52+
req := &EmbeddingRequest{Model: model, Inputs: inputs}
53+
if cred, ok := params["_credential"].(map[string]string); ok {
54+
req.Credential = cred
55+
}
56+
if d, ok := extractInt(params["dimensions"]); ok && d > 0 {
57+
req.Dimensions = d
58+
}
59+
60+
workflow, _ := params["_workflow"].(string)
61+
step, _ := params["_step"].(string)
62+
63+
start := time.Now()
64+
resp, err := provider.Embeddings(ctx, req)
65+
duration := time.Since(start).Seconds()
66+
67+
metrics.AIRequestDuration.WithLabelValues(workflow, step, model, providerName).Observe(duration)
68+
if err != nil {
69+
metrics.AIRequestsTotal.WithLabelValues(workflow, step, model, providerName, "error").Inc()
70+
return nil, fmt.Errorf("ai/embed: %w", err)
71+
}
72+
metrics.AITokensTotal.WithLabelValues(workflow, step, model, providerName, "prompt").Add(float64(resp.Usage.PromptTokens))
73+
metrics.AIRequestsTotal.WithLabelValues(workflow, step, model, providerName, "success").Inc()
74+
75+
return embeddingResponseToOutput(resp), nil
76+
}
77+
78+
// getProvider returns the EmbeddingProvider for the given provider name.
79+
func (c *EmbeddingConnector) getProvider(name string, params map[string]any) (EmbeddingProvider, error) {
80+
switch name {
81+
case "openai":
82+
baseURL := "https://api.openai.com/v1"
83+
if u, ok := params["base_url"].(string); ok && u != "" {
84+
baseURL = u
85+
}
86+
if len(c.AllowedBaseURLs) > 0 && !stringInSlice(baseURL, c.AllowedBaseURLs) {
87+
return nil, fmt.Errorf("base_url %q not in allowed list", baseURL)
88+
}
89+
return &OpenAIProvider{Client: c.Client, BaseURL: baseURL}, nil
90+
case "bedrock":
91+
// Bedrock embeddings use InvokeModel with per-model request shapes
92+
// (Titan, Cohere), distinct from the Converse API used for chat.
93+
// Tracked as a follow-up; OpenAI-compatible endpoints (including Azure
94+
// OpenAI and local servers) are reachable today via base_url.
95+
return nil, fmt.Errorf("bedrock embeddings are not yet supported; use provider: openai (optionally with base_url)")
96+
default:
97+
return nil, fmt.Errorf("unknown provider %q (available: openai)", name)
98+
}
99+
}
100+
101+
// extractEmbedInputs reads the `input` param, accepting a single string or an
102+
// array of strings.
103+
func extractEmbedInputs(params map[string]any) ([]string, error) {
104+
raw, ok := params["input"]
105+
if !ok {
106+
return nil, fmt.Errorf("input is required")
107+
}
108+
switch v := raw.(type) {
109+
case string:
110+
if v == "" {
111+
return nil, fmt.Errorf("input must not be empty")
112+
}
113+
return []string{v}, nil
114+
case []string:
115+
if len(v) == 0 {
116+
return nil, fmt.Errorf("input must not be empty")
117+
}
118+
return v, nil
119+
case []any:
120+
out := make([]string, 0, len(v))
121+
for i, item := range v {
122+
s, ok := item.(string)
123+
if !ok {
124+
return nil, fmt.Errorf("input[%d] must be a string, got %T", i, item)
125+
}
126+
out = append(out, s)
127+
}
128+
if len(out) == 0 {
129+
return nil, fmt.Errorf("input must not be empty")
130+
}
131+
return out, nil
132+
default:
133+
return nil, fmt.Errorf("input must be a string or array of strings, got %T", raw)
134+
}
135+
}
136+
137+
// embeddingResponseToOutput converts an EmbeddingResponse to the connector's
138+
// output map. It always includes the full `embeddings`/`vectors` arrays, and
139+
// for the common single-input case surfaces `embedding`/`vector`/`dimensions`.
140+
func embeddingResponseToOutput(resp *EmbeddingResponse) map[string]any {
141+
vectors := make([]string, len(resp.Embeddings))
142+
for i, e := range resp.Embeddings {
143+
vectors[i] = formatPGVector(e)
144+
}
145+
146+
out := map[string]any{
147+
"model": resp.Model,
148+
"embeddings": resp.Embeddings,
149+
"vectors": vectors,
150+
"count": len(resp.Embeddings),
151+
"usage": map[string]any{
152+
"prompt_tokens": resp.Usage.PromptTokens,
153+
"total_tokens": resp.Usage.TotalTokens,
154+
},
155+
}
156+
if len(resp.Embeddings) > 0 {
157+
out["embedding"] = resp.Embeddings[0]
158+
out["vector"] = vectors[0]
159+
out["dimensions"] = len(resp.Embeddings[0])
160+
}
161+
return out
162+
}
163+
164+
// formatPGVector renders a float slice as a pgvector text literal: "[1,2,3]".
165+
// This can be passed straight into a postgres/query arg and cast with ::vector.
166+
func formatPGVector(v []float64) string {
167+
var b strings.Builder
168+
b.WriteByte('[')
169+
for i, f := range v {
170+
if i > 0 {
171+
b.WriteByte(',')
172+
}
173+
b.WriteString(strconv.FormatFloat(f, 'g', -1, 64))
174+
}
175+
b.WriteByte(']')
176+
return b.String()
177+
}
178+
179+
func stringInSlice(s string, list []string) bool {
180+
for _, v := range list {
181+
if v == s {
182+
return true
183+
}
184+
}
185+
return false
186+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package connector
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
)
10+
11+
// embedTestServer returns an httptest server that emits `n` deterministic
12+
// embeddings echoing the request order via the index field.
13+
func embedTestServer(t *testing.T, dims int) *httptest.Server {
14+
t.Helper()
15+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
if r.URL.Path != "/embeddings" {
17+
t.Errorf("path = %s, want /embeddings", r.URL.Path)
18+
}
19+
var body struct {
20+
Model string `json:"model"`
21+
Input []string `json:"input"`
22+
}
23+
_ = json.NewDecoder(r.Body).Decode(&body)
24+
25+
data := make([]map[string]any, len(body.Input))
26+
for i := range body.Input {
27+
vec := make([]float64, dims)
28+
for j := range vec {
29+
vec[j] = float64(i) + float64(j)/10.0
30+
}
31+
// Return out of order to exercise index reassembly.
32+
data[len(body.Input)-1-i] = map[string]any{"index": i, "embedding": vec}
33+
}
34+
w.Header().Set("Content-Type", "application/json")
35+
_ = json.NewEncoder(w).Encode(map[string]any{
36+
"model": body.Model,
37+
"data": data,
38+
"usage": map[string]any{"prompt_tokens": 7, "total_tokens": 7},
39+
})
40+
}))
41+
}
42+
43+
func TestEmbeddingConnector_SingleInput(t *testing.T) {
44+
server := embedTestServer(t, 3)
45+
defer server.Close()
46+
47+
c := &EmbeddingConnector{Client: server.Client()}
48+
out, err := c.Execute(context.Background(), map[string]any{
49+
"model": "text-embedding-3-small",
50+
"input": "hello world",
51+
"base_url": server.URL,
52+
"_credential": map[string]string{"api_key": "sk-test"},
53+
})
54+
if err != nil {
55+
t.Fatalf("Execute() error: %v", err)
56+
}
57+
if got := out["count"].(int); got != 1 {
58+
t.Errorf("count = %d, want 1", got)
59+
}
60+
if got := out["dimensions"].(int); got != 3 {
61+
t.Errorf("dimensions = %d, want 3", got)
62+
}
63+
// First input's vector is [0, 0.1, 0.2] → pgvector literal.
64+
if got := out["vector"].(string); got != "[0,0.1,0.2]" {
65+
t.Errorf("vector = %q, want %q", got, "[0,0.1,0.2]")
66+
}
67+
usage := out["usage"].(map[string]any)
68+
if usage["total_tokens"].(int) != 7 {
69+
t.Errorf("total_tokens = %v, want 7", usage["total_tokens"])
70+
}
71+
}
72+
73+
func TestEmbeddingConnector_MultiInputPreservesOrder(t *testing.T) {
74+
server := embedTestServer(t, 2)
75+
defer server.Close()
76+
77+
c := &EmbeddingConnector{Client: server.Client()}
78+
out, err := c.Execute(context.Background(), map[string]any{
79+
"model": "text-embedding-3-small",
80+
"input": []any{"a", "b", "c"},
81+
"base_url": server.URL,
82+
})
83+
if err != nil {
84+
t.Fatalf("Execute() error: %v", err)
85+
}
86+
if out["count"].(int) != 3 {
87+
t.Fatalf("count = %d, want 3", out["count"])
88+
}
89+
vectors := out["vectors"].([]string)
90+
// Index reassembly: input i → vector [i, i+0.1].
91+
want := []string{"[0,0.1]", "[1,1.1]", "[2,2.1]"}
92+
for i, w := range want {
93+
if vectors[i] != w {
94+
t.Errorf("vectors[%d] = %q, want %q", i, vectors[i], w)
95+
}
96+
}
97+
}
98+
99+
func TestEmbeddingConnector_Validation(t *testing.T) {
100+
c := &EmbeddingConnector{}
101+
if _, err := c.Execute(context.Background(), map[string]any{"input": "x"}); err == nil {
102+
t.Error("expected error when model is missing")
103+
}
104+
if _, err := c.Execute(context.Background(), map[string]any{"model": "m"}); err == nil {
105+
t.Error("expected error when input is missing")
106+
}
107+
if _, err := c.Execute(context.Background(), map[string]any{"model": "m", "input": ""}); err == nil {
108+
t.Error("expected error when input is empty")
109+
}
110+
}
111+
112+
func TestEmbeddingConnector_ModelAllowlist(t *testing.T) {
113+
c := &EmbeddingConnector{AllowedModels: []string{"text-embedding-3-large"}}
114+
_, err := c.Execute(context.Background(), map[string]any{
115+
"model": "text-embedding-3-small",
116+
"input": "x",
117+
})
118+
if err == nil {
119+
t.Error("expected error for disallowed model")
120+
}
121+
}
122+
123+
func TestEmbeddingConnector_BedrockUnsupported(t *testing.T) {
124+
c := &EmbeddingConnector{}
125+
_, err := c.Execute(context.Background(), map[string]any{
126+
"model": "amazon.titan-embed-text-v2:0",
127+
"input": "x",
128+
"provider": "bedrock",
129+
})
130+
if err == nil {
131+
t.Error("expected error for unsupported bedrock provider")
132+
}
133+
}

packages/engine/internal/connector/provider.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ type LLMProvider interface {
1010
ChatCompletion(ctx context.Context, req *ChatRequest) (*ChatResponse, error)
1111
}
1212

13+
// EmbeddingProvider implements a specific provider's text-embeddings API.
14+
type EmbeddingProvider interface {
15+
Embeddings(ctx context.Context, req *EmbeddingRequest) (*EmbeddingResponse, error)
16+
}
17+
18+
// EmbeddingRequest is the provider-agnostic embeddings request format.
19+
type EmbeddingRequest struct {
20+
Model string
21+
Inputs []string
22+
Dimensions int // 0 = provider default
23+
Credential map[string]string
24+
}
25+
26+
// EmbeddingResponse is the provider-agnostic embeddings response format.
27+
// Embeddings are returned in the same order as EmbeddingRequest.Inputs.
28+
type EmbeddingResponse struct {
29+
Embeddings [][]float64
30+
Model string
31+
Usage ChatUsage // PromptTokens / TotalTokens are meaningful for embeddings
32+
}
33+
1334
// ChatRequest is the provider-agnostic request format.
1435
type ChatRequest struct {
1536
Model string

0 commit comments

Comments
 (0)