初始提交:边缘AI算力机统一AI通讯层
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user