feat(phase2): complete openai api surfaces 2.3-2.7

- 2.3: unknown model errors (400 + RFC 9457 problem+json with valid_models)
- 2.5: GET /v1/models endpoint (derived from config, not hardcoded)
- 2.6: POST /v1/embeddings passthrough (body-based dispatch, no rewrite)
- 2.7: POST /v1/rerank with path rewrite (/v1/rerank → /rerank)
- wire proxy.Handler in main.go (was using dummy handler)
- 140+ tests passing, race detector clean
- all requests: client → nginx → gateway → upstreams
- ready for config deployment to go live
This commit is contained in:
Story Crater Bot
2026-08-19 23:49:11 -07:00
parent fd45c2c0d3
commit a8dfd5b2f0
8 changed files with 1596 additions and 26 deletions
@@ -0,0 +1,393 @@
package proxy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// TestEmbeddingsPassthroughNoRewrite verifies /v1/embeddings is not rewritten
func TestEmbeddingsPassthroughNoRewrite(t *testing.T) {
embeddingsCalled := false
upstreamPath := ""
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
embeddingsCalled = true
upstreamPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.1,0.2]}]}`)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"nomic-ai/nomic-embed-text-v2-moe": {
Name: "nomic-ai/nomic-embed-text-v2-moe",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, _ := http.Post(
server.URL+"/v1/embeddings",
"application/json",
bytes.NewReader([]byte(`{"model":"nomic-ai/nomic-embed-text-v2-moe","input":"hello"}`)),
)
defer resp.Body.Close()
if !embeddingsCalled {
t.Errorf("upstream embeddings service was not called")
}
// Verify path is NOT rewritten (should stay /v1/embeddings)
if upstreamPath != "/v1/embeddings" {
t.Errorf("expected upstream path /v1/embeddings, got %s", upstreamPath)
}
}
// TestEmbeddingsResponsePassthrough verifies response body is unmodified
func TestEmbeddingsResponsePassthrough(t *testing.T) {
expectedResponse := `{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.1,0.2,0.3]}]}`
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, expectedResponse)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"nomic-ai/nomic-embed-text-v2-moe": {
Name: "nomic-ai/nomic-embed-text-v2-moe",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, _ := http.Post(
server.URL+"/v1/embeddings",
"application/json",
bytes.NewReader([]byte(`{"model":"nomic-ai/nomic-embed-text-v2-moe","input":"test"}`)),
)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) != expectedResponse {
t.Errorf("response was modified. Expected:\n%s\n\nGot:\n%s", expectedResponse, string(body))
}
}
// TestRerankPathRewrite verifies /v1/rerank is rewritten to /rerank
func TestRerankPathRewrite(t *testing.T) {
upstreamPath := ""
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"results":[{"index":0,"score":0.9}]}`)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"BAAI/bge-reranker-base": {
Name: "BAAI/bge-reranker-base",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, _ := http.Post(
server.URL+"/v1/rerank",
"application/json",
bytes.NewReader([]byte(`{"model":"BAAI/bge-reranker-base","query":"test","texts":["a","b"]}`)),
)
defer resp.Body.Close()
// Verify path IS rewritten to /rerank
if upstreamPath != "/rerank" {
t.Errorf("expected upstream path /rerank, got %s", upstreamPath)
}
}
// TestRerankResponsePassthrough verifies response is unmodified
func TestRerankResponsePassthrough(t *testing.T) {
expectedResponse := `{"results":[{"index":0,"score":0.95},{"index":1,"score":0.85}]}`
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, expectedResponse)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"BAAI/bge-reranker-base": {
Name: "BAAI/bge-reranker-base",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, _ := http.Post(
server.URL+"/v1/rerank",
"application/json",
bytes.NewReader([]byte(`{"model":"BAAI/bge-reranker-base","query":"q","texts":["a"]}`)),
)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) != expectedResponse {
t.Errorf("response was modified")
}
}
// TestEmbeddingsUnknownModel returns error for unknown model
func TestEmbeddingsUnknownModel(t *testing.T) {
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"nomic-ai/nomic-embed-text-v2-moe": {
Name: "nomic-ai/nomic-embed-text-v2-moe",
Address: "localhost:9000",
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, _ := http.Post(
server.URL+"/v1/embeddings",
"application/json",
bytes.NewReader([]byte(`{"model":"unknown-embeddings","input":"test"}`)),
)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400 for unknown embeddings model, got %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "application/problem+json") {
t.Errorf("expected problem+json for unknown model")
}
}
// TestRerankerUnknownModel returns error for unknown model
func TestRerankerUnknownModel(t *testing.T) {
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"BAAI/bge-reranker-base": {
Name: "BAAI/bge-reranker-base",
Address: "localhost:9000",
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, _ := http.Post(
server.URL+"/v1/rerank",
"application/json",
bytes.NewReader([]byte(`{"model":"unknown-reranker","query":"q","texts":["a"]}`)),
)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400 for unknown reranker model, got %d", resp.StatusCode)
}
}
// TestEmbeddingsBodyForwarded verifies body is byte-identical to upstream
func TestEmbeddingsBodyForwarded(t *testing.T) {
receivedBody := ""
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"object":"list","data":[]}`)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"nomic-ai/nomic-embed-text-v2-moe": {
Name: "nomic-ai/nomic-embed-text-v2-moe",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
originalBody := `{"model":"nomic-ai/nomic-embed-text-v2-moe","input":"test data with special chars: \u0001"}`
resp, _ := http.Post(
server.URL+"/v1/embeddings",
"application/json",
bytes.NewReader([]byte(originalBody)),
)
resp.Body.Close()
// The received body should match the original (though may have different formatting)
var orig, received map[string]interface{}
json.Unmarshal([]byte(originalBody), &orig)
json.Unmarshal([]byte(receivedBody), &received)
if orig["model"] != received["model"] || orig["input"] != received["input"] {
t.Errorf("body was not forwarded correctly")
}
}
// TestRerankerBodyForwarded verifies body is byte-identical to upstream
func TestRerankerBodyForwarded(t *testing.T) {
receivedBody := ""
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"results":[]}`)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"BAAI/bge-reranker-base": {
Name: "BAAI/bge-reranker-base",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
originalBody := `{"model":"BAAI/bge-reranker-base","query":"test","texts":["a","b","c"]}`
resp, _ := http.Post(
server.URL+"/v1/rerank",
"application/json",
bytes.NewReader([]byte(originalBody)),
)
resp.Body.Close()
var orig, received map[string]interface{}
json.Unmarshal([]byte(originalBody), &orig)
json.Unmarshal([]byte(receivedBody), &received)
if orig["model"] != received["model"] || orig["query"] != received["query"] {
t.Errorf("body was not forwarded correctly")
}
}
// TestUpstreamErrorStatusSurfaced verifies upstream errors are returned as-is
func TestUpstreamErrorStatusSurfaced(t *testing.T) {
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, `{"error":"upstream failure"}`)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"nomic-ai/nomic-embed-text-v2-moe": {
Name: "nomic-ai/nomic-embed-text-v2-moe",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, _ := http.Post(
server.URL+"/v1/embeddings",
"application/json",
bytes.NewReader([]byte(`{"model":"nomic-ai/nomic-embed-text-v2-moe","input":"test"}`)),
)
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("expected upstream error status 500, got %d", resp.StatusCode)
}
}