296 lines
8.0 KiB
Go
296 lines
8.0 KiB
Go
package compatibility
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/edgeai/gateway/internal/config"
|
|
"github.com/edgeai/gateway/internal/observability"
|
|
"github.com/edgeai/gateway/internal/server"
|
|
"github.com/edgeai/gateway/pkg/api"
|
|
)
|
|
|
|
var compatServer *httptest.Server
|
|
|
|
func TestMain(m *testing.M) {
|
|
cfg := &config.Config{
|
|
Server: config.ServerConfig{Host: "127.0.0.1", Port: 0, MaxRequestBodyMB: 5},
|
|
Auth: config.AuthConfig{Enabled: true, Methods: []string{"api_key"}},
|
|
Scheduler: config.SchedulerConfig{
|
|
MaxRunningTasks: 2, MaxQueuedTasks: 10,
|
|
},
|
|
Timeouts: config.TimeoutConfig{
|
|
DefaultQueueMs: 2000, DefaultInferenceMs: 10000, DefaultTotalMs: 15000,
|
|
},
|
|
Context: config.ContextConfig{SafetyMarginRatio: 0.1, DefaultPolicy: "recent_only"},
|
|
Models: map[string]config.ModelConfig{
|
|
"general-chat": {
|
|
Provider: "ollama", ActualModel: "qwen2.5:0.5b",
|
|
Endpoint: "http://127.0.0.1:11434", ContextWindow: 4096,
|
|
MaxOutputTokens: 256, CancelSupported: true,
|
|
},
|
|
"fast-chat": {
|
|
Provider: "ollama", ActualModel: "qwen2.5:0.5b",
|
|
Endpoint: "http://127.0.0.1:11434", ContextWindow: 2048,
|
|
MaxOutputTokens: 128, CancelSupported: true,
|
|
},
|
|
},
|
|
Observability: config.ObservabilityConfig{MetricsPath: "/metrics", LogLevel: "error"},
|
|
Storage: config.StorageConfig{
|
|
SessionDB: "sqlite:///tmp/edgeai-compat/sessions.db",
|
|
TaskState: "sqlite:///tmp/edgeai-compat/tasks.db",
|
|
},
|
|
}
|
|
|
|
os.MkdirAll("/tmp/edgeai-compat", 0755)
|
|
defer os.RemoveAll("/tmp/edgeai-compat")
|
|
|
|
logger := observability.NewLogger(observability.LevelError, os.Stderr, "metadata_only")
|
|
srv, err := server.New(cfg, logger)
|
|
if err != nil {
|
|
panic("failed to create compat test server: " + err.Error())
|
|
}
|
|
|
|
compatServer = httptest.NewServer(srv.HTTPSrv.Handler)
|
|
defer compatServer.Close()
|
|
|
|
m.Run()
|
|
}
|
|
|
|
// COMPAT-001: GET /v1/models returns OpenAI-compatible format
|
|
func TestCompat001_ModelsFormat(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", compatServer.URL+"/v1/models", nil)
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 401 {
|
|
// If auth passes (unlikely without real key), check format
|
|
var result api.ModelListResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode models response: %v", err)
|
|
}
|
|
if result.Object != "list" {
|
|
t.Errorf("expected object 'list', got %s", result.Object)
|
|
}
|
|
for _, m := range result.Data {
|
|
if m.Object != "model" {
|
|
t.Errorf("expected object 'model', got %s", m.Object)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// COMPAT-002: Chat request format matches OpenAI API
|
|
func TestCompat002_ChatRequestFormat(t *testing.T) {
|
|
// Verify the request body structure is OpenAI-compatible
|
|
req := api.ChatRequest{
|
|
Model: "general-chat",
|
|
Messages: []api.Message{
|
|
{Role: "system", Content: "You are a helpful assistant."},
|
|
{Role: "user", Content: "Hello!"},
|
|
},
|
|
Stream: false,
|
|
}
|
|
|
|
data, err := json.Marshal(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify JSON structure
|
|
var raw map[string]any
|
|
json.Unmarshal(data, &raw)
|
|
|
|
requiredFields := []string{"model", "messages"}
|
|
for _, field := range requiredFields {
|
|
if _, ok := raw[field]; !ok {
|
|
t.Errorf("required field %q missing from chat request", field)
|
|
}
|
|
}
|
|
|
|
// Verify messages structure
|
|
msgs, ok := raw["messages"].([]any)
|
|
if !ok || len(msgs) != 2 {
|
|
t.Fatalf("expected 2 messages, got %v", raw["messages"])
|
|
}
|
|
|
|
firstMsg, ok := msgs[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected message to be object")
|
|
}
|
|
if firstMsg["role"] != "system" {
|
|
t.Errorf("expected role 'system', got %v", firstMsg["role"])
|
|
}
|
|
if firstMsg["content"] != "You are a helpful assistant." {
|
|
t.Errorf("unexpected content: %v", firstMsg["content"])
|
|
}
|
|
}
|
|
|
|
// COMPAT-003: Chat response format matches OpenAI API
|
|
func TestCompat003_ChatResponseFormat(t *testing.T) {
|
|
resp := api.ChatResponse{
|
|
RequestID: "req-123",
|
|
TaskID: "task-456",
|
|
Status: "completed",
|
|
Model: "general-chat",
|
|
Choices: []api.Choice{
|
|
{
|
|
Index: 0,
|
|
Message: &api.Message{
|
|
Role: "assistant",
|
|
Content: "Hello! How can I help you?",
|
|
},
|
|
FinishReason: "stop",
|
|
},
|
|
},
|
|
Usage: &api.Usage{
|
|
InputTokens: 10,
|
|
OutputTokens: 8,
|
|
TotalTokens: 18,
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(resp)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var raw map[string]any
|
|
json.Unmarshal(data, &raw)
|
|
|
|
// Verify OpenAI-compatible fields
|
|
if _, ok := raw["choices"]; !ok {
|
|
t.Error("choices field missing from response")
|
|
}
|
|
if _, ok := raw["model"]; !ok {
|
|
t.Error("model field missing from response")
|
|
}
|
|
}
|
|
|
|
// COMPAT-004: SSE streaming format matches OpenAI API
|
|
func TestCompat004_SSEFormat(t *testing.T) {
|
|
// Verify SSE chunk format
|
|
chunk := map[string]any{
|
|
"id": "req-123",
|
|
"object": "chat.completion.chunk",
|
|
"model": "general-chat",
|
|
"choices": []map[string]any{
|
|
{
|
|
"index": 0,
|
|
"delta": map[string]any{
|
|
"content": "Hello",
|
|
},
|
|
"finish_reason": nil,
|
|
},
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(chunk)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var raw map[string]any
|
|
json.Unmarshal(data, &raw)
|
|
|
|
if raw["object"] != "chat.completion.chunk" {
|
|
t.Errorf("expected object 'chat.completion.chunk', got %v", raw["object"])
|
|
}
|
|
}
|
|
|
|
// COMPAT-005: Error response format matches OpenAI API
|
|
func TestCompat005_ErrorFormat(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", compatServer.URL+"/v1/models", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var buf bytes.Buffer
|
|
buf.ReadFrom(resp.Body)
|
|
|
|
var errResp map[string]any
|
|
json.Unmarshal(buf.Bytes(), &errResp)
|
|
|
|
// OpenAI format: {"error": {"code": ..., "message": ...}}
|
|
errBody, ok := errResp["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected 'error' object in response")
|
|
}
|
|
if _, ok := errBody["code"]; !ok {
|
|
t.Error("expected 'code' field in error")
|
|
}
|
|
if _, ok := errBody["message"]; !ok {
|
|
t.Error("expected 'message' field in error")
|
|
}
|
|
}
|
|
|
|
// COMPAT-006: Session API follows REST conventions
|
|
func TestCompat006_SessionREST(t *testing.T) {
|
|
// POST /v1/sessions creates a session
|
|
createReq, _ := http.NewRequest("POST", compatServer.URL+"/v1/sessions", nil)
|
|
createReq.Header.Set("Content-Type", "application/json")
|
|
createReq.Header.Set("Authorization", "Bearer test-key")
|
|
createReq.Body = io.NopCloser(bytes.NewReader([]byte(`{"application_id":"test-app"}`)))
|
|
createResp, err := http.DefaultClient.Do(createReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
createResp.Body.Close()
|
|
// Should be 201 (Created) or 401 (auth)
|
|
if createResp.StatusCode != 201 && createResp.StatusCode != 401 {
|
|
t.Errorf("expected 201 or 401 for POST /v1/sessions, got %d", createResp.StatusCode)
|
|
}
|
|
|
|
// DELETE /v1/sessions/:id deletes a session
|
|
deleteReq, _ := http.NewRequest("DELETE", compatServer.URL+"/v1/sessions/test-id", nil)
|
|
deleteReq.Header.Set("Authorization", "Bearer test-key")
|
|
deleteResp, err := http.DefaultClient.Do(deleteReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deleteResp.Body.Close()
|
|
// Should be 200, 401, or 400 (not found)
|
|
if deleteResp.StatusCode == 500 {
|
|
t.Error("expected non-500 for DELETE session")
|
|
}
|
|
}
|
|
|
|
// COMPAT-007: Multiple models in config are all listed
|
|
func TestCompat007_MultipleModels(t *testing.T) {
|
|
// Verify config has multiple models
|
|
cfg := &config.Config{
|
|
Models: map[string]config.ModelConfig{
|
|
"general-chat": {Provider: "ollama", ActualModel: "a"},
|
|
"fast-chat": {Provider: "ollama", ActualModel: "b"},
|
|
},
|
|
}
|
|
if len(cfg.Models) != 2 {
|
|
t.Errorf("expected 2 models, got %d", len(cfg.Models))
|
|
}
|
|
}
|
|
|
|
// COMPAT-008: curl-compatible request (no extra headers needed)
|
|
func TestCompat008_CurlCompatible(t *testing.T) {
|
|
// Simulate a curl request with minimal headers
|
|
req, _ := http.NewRequest("GET", compatServer.URL+"/health", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("expected 200 for simple curl-like request, got %d", resp.StatusCode)
|
|
}
|
|
}
|