- options.go: new WithChatCompletions(resolver) and WithChatCompletionsPath(path); api.New(...) now auto-mounts at /v1/chat/completions when a resolver is configured (previously the resolver could be attached but never mounted, which would have panicked Gin) - chat_completions.go: fixed missing net/http import, dropped ModelType during discovery, Retry-After header set after c.JSON silently lost, swapped OpenAI error type/code fields, swapped validate call site, redundant nil check, builder length read before nil-receiver check - openapi.go: effective*Path helpers surface an explicit path even when the corresponding Enabled flag is false so CLI callers still get x-*-path extensions; /swagger always in authentik public paths - chat_completions_test.go: Good/Bad/Ugly coverage for new options, validation, no-resolver behaviour - openapi_test.go: fix stale assertion for CacheEnabled-gated X-Cache - go.mod: bump dappco.re/go/core/cli to v0.5.2 - Removed local go-io / go-log stubs — replace points to outer modules for single source of truth - Migrated forge.lthn.ai/core/cli imports to dappco.re/go/core/cli across cmd/api/*.go + docs Co-Authored-By: Virgil <virgil@lethean.io>
158 lines
4.3 KiB
Go
158 lines
4.3 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
api "dappco.re/go/core/api"
|
|
)
|
|
|
|
// TestChatCompletions_WithChatCompletions_Good verifies that WithChatCompletions
|
|
// mounts the endpoint and unknown model names produce a 404 body conforming to
|
|
// RFC §11.7.
|
|
func TestChatCompletions_WithChatCompletions_Good(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
resolver := api.NewModelResolver()
|
|
engine, err := api.New(api.WithChatCompletions(resolver))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model": "missing-model",
|
|
"messages": [{"role":"user","content":"hi"}]
|
|
}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
rec := httptest.NewRecorder()
|
|
engine.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d (body=%s)", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var payload struct {
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
Type string `json:"type"`
|
|
Param string `json:"param"`
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("invalid JSON body: %v", err)
|
|
}
|
|
if payload.Error.Code != "model_not_found" {
|
|
t.Fatalf("expected code=model_not_found, got %q", payload.Error.Code)
|
|
}
|
|
if payload.Error.Type != "model_not_found" {
|
|
t.Fatalf("expected type=model_not_found, got %q", payload.Error.Type)
|
|
}
|
|
if payload.Error.Param != "model" {
|
|
t.Fatalf("expected param=model, got %q", payload.Error.Param)
|
|
}
|
|
}
|
|
|
|
// TestChatCompletions_WithChatCompletionsPath_Good verifies the custom mount path override.
|
|
func TestChatCompletions_WithChatCompletionsPath_Good(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
resolver := api.NewModelResolver()
|
|
engine, err := api.New(
|
|
api.WithChatCompletions(resolver),
|
|
api.WithChatCompletionsPath("/chat"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(`{
|
|
"model": "missing-model",
|
|
"messages": [{"role":"user","content":"hi"}]
|
|
}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
rec := httptest.NewRecorder()
|
|
engine.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d (body=%s)", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestChatCompletions_ValidateRequest_Bad verifies that missing messages produces a 400.
|
|
func TestChatCompletions_ValidateRequest_Bad(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
resolver := api.NewModelResolver()
|
|
engine, _ := api.New(api.WithChatCompletions(resolver))
|
|
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
code string
|
|
}{
|
|
{
|
|
name: "missing-messages",
|
|
body: `{"model":"lemer"}`,
|
|
code: "invalid_request_error",
|
|
},
|
|
{
|
|
name: "missing-model",
|
|
body: `{"messages":[{"role":"user","content":"hi"}]}`,
|
|
code: "invalid_request_error",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader([]byte(tc.body)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
rec := httptest.NewRecorder()
|
|
engine.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d (body=%s)", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var payload struct {
|
|
Error struct {
|
|
Type string `json:"type"`
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("invalid JSON body: %v", err)
|
|
}
|
|
if payload.Error.Type != tc.code {
|
|
t.Fatalf("expected type=%q, got %q", tc.code, payload.Error.Type)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChatCompletions_NoResolver_Ugly verifies graceful handling when an engine
|
|
// is constructed WITHOUT a resolver — no route is mounted.
|
|
func TestChatCompletions_NoResolver_Ugly(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
engine, _ := api.New()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{}`))
|
|
rec := httptest.NewRecorder()
|
|
engine.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404 when no resolver is configured, got %d", rec.Code)
|
|
}
|
|
}
|