Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions internal/providers/openai/compatible_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"

"gomodel/internal/core"
"gomodel/internal/llmclient"
Expand Down Expand Up @@ -135,9 +136,24 @@ func (p *CompatibleProvider) ListModels(ctx context.Context) (*core.ModelsRespon
if err != nil {
return nil, err
}
normalizeModelsResponse(&resp)
return &resp, nil
}

func normalizeModelsResponse(resp *core.ModelsResponse) {
if resp == nil {
return
}
if strings.TrimSpace(resp.Object) == "" {
resp.Object = "list"
}
for i := range resp.Data {
if strings.TrimSpace(resp.Data[i].Object) == "" {
resp.Data[i].Object = "model"
}
}
}

func (p *CompatibleProvider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) {
if req == nil {
return nil, core.NewInvalidRequestError("responses request is required", nil)
Expand Down
32 changes: 32 additions & 0 deletions internal/providers/openai/compatible_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,38 @@ func TestCompatibleProvider_ListModels_ReturnsUpstreamOnSuccess(t *testing.T) {
}
}

func TestCompatibleProvider_ListModels_DefaultsMissingObjectFields(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"openrouter/model","object":"","owned_by":"openrouter"}]}`))
}))
defer server.Close()

provider := NewCompatibleProviderWithHTTPClient(
"test-key",
server.Client(),
llmclient.Hooks{},
CompatibleProviderConfig{
ProviderName: "openrouter",
BaseURL: server.URL,
},
)

resp, err := provider.ListModels(context.Background())
if err != nil {
t.Fatalf("ListModels() error = %v", err)
}
if resp.Object != "list" {
t.Fatalf("response object = %q, want list", resp.Object)
}
if len(resp.Data) != 1 {
t.Fatalf("model count = %d, want 1", len(resp.Data))
}
if resp.Data[0].Object != "model" {
t.Fatalf("model object = %q, want model", resp.Data[0].Object)
}
}

func TestCompatibleProvider_ListModels_ReturnsUpstreamError(t *testing.T) {
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
Expand Down
163 changes: 133 additions & 30 deletions tests/e2e/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,27 +38,95 @@ func TestAdminAPI_EndpointsEnabled_E2E(t *testing.T) {
ts := setupAdminServer(t, "", true, false)
defer ts.Close()

endpoints := []string{
"/admin/usage/summary",
"/admin/usage/daily",
"/admin/audit/log",
"/admin/audit/conversation?log_id=test",
"/admin/models",
// Each endpoint asserts the response shape it advertises in its handler,
// not just "valid JSON". A regression that returned `{"error":"..."}` with
// a 200 status would otherwise slip through the smoke test.
cases := []struct {
name string
endpoint string
check func(t *testing.T, body []byte)
}{
{
name: "usage summary returns aggregate counters",
endpoint: "/admin/usage/summary",
check: func(t *testing.T, body []byte) {
var summary usage.UsageSummary
require.NoError(t, json.Unmarshal(body, &summary))
assert.GreaterOrEqual(t, summary.TotalRequests, 0)
assert.GreaterOrEqual(t, summary.TotalInput, int64(0))
assert.GreaterOrEqual(t, summary.TotalOutput, int64(0))
assert.GreaterOrEqual(t, summary.TotalTokens, int64(0))
},
},
{
name: "daily usage returns a rollup array",
endpoint: "/admin/usage/daily",
check: func(t *testing.T, body []byte) {
var daily []usage.DailyUsage
require.NoError(t, json.Unmarshal(body, &daily))
for i, entry := range daily {
assert.NotEmpty(t, entry.Date, "entry %d should have a date label", i)
}
},
},
{
name: "audit log returns paginated entries envelope",
endpoint: "/admin/audit/log",
check: func(t *testing.T, body []byte) {
var envelope struct {
Entries []map[string]any `json:"entries"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
require.NoError(t, json.Unmarshal(body, &envelope))
// entries is `[]` (not null) even when empty per the handler contract.
assert.NotNil(t, envelope.Entries)
assert.GreaterOrEqual(t, envelope.Total, 0)
},
},
{
name: "audit conversation returns anchor + entries",
endpoint: "/admin/audit/conversation?log_id=test",
check: func(t *testing.T, body []byte) {
var conv struct {
AnchorID string `json:"anchor_id"`
Entries []map[string]any `json:"entries"`
}
require.NoError(t, json.Unmarshal(body, &conv))
assert.Equal(t, "test", conv.AnchorID,
"conversation must echo the requested log_id as anchor")
assert.NotNil(t, conv.Entries)
},
},
{
name: "models returns provider-tagged model list",
endpoint: "/admin/models",
check: func(t *testing.T, body []byte) {
var models []providers.ModelWithProvider
require.NoError(t, json.Unmarshal(body, &models))
require.NotEmpty(t, models, "registered test provider should expose at least one model")
for _, m := range models {
assert.NotEmpty(t, m.Model.ID, "every model should have an id")
assert.NotEmpty(t, m.ProviderType, "every model should have a provider_type")
}
},
},
}

for _, ep := range endpoints {
t.Run(ep, func(t *testing.T) {
resp, err := http.Get(ts.URL + ep)
for _, tc := range cases {
t.Run(tc.endpoint, func(t *testing.T) {
resp, err := http.Get(ts.URL + tc.endpoint)
require.NoError(t, err)
defer closeBody(resp)

assert.Equal(t, http.StatusOK, resp.StatusCode, "endpoint %s should return 200", ep)
require.Equal(t, http.StatusOK, resp.StatusCode, "endpoint %s should return 200", tc.endpoint)

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.True(t, json.Valid(body), "response should be valid JSON for %s, got: %s", tc.endpoint, string(body))

// Should be valid JSON
assert.True(t, json.Valid(body), "response should be valid JSON for %s, got: %s", ep, string(body))
tc.check(t, body)
})
}
}
Expand Down Expand Up @@ -144,21 +212,40 @@ func TestAdminDashboard_Enabled_E2E(t *testing.T) {
ts := setupAdminServer(t, "", true, true)
defer ts.Close()

t.Run("dashboard returns 200 HTML", func(t *testing.T) {
t.Run("dashboard returns 200 HTML with expected markup", func(t *testing.T) {
resp, err := http.Get(ts.URL + "/admin/dashboard")
require.NoError(t, err)
defer closeBody(resp)

assert.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Type"), "text/html")

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
html := string(body)

// Guard against regressions that return a 200 with an empty/placeholder
// document. The dashboard layout pins these markers.
assert.Contains(t, html, "<title>GoModel Dashboard</title>",
"dashboard HTML should carry the expected <title>")
assert.Contains(t, html, "css/dashboard.css",
"dashboard HTML should reference its stylesheet bundle")
assert.Contains(t, html, "js/dashboard.js",
"dashboard HTML should reference its script bundle")
})

t.Run("static CSS returns 200", func(t *testing.T) {
t.Run("static CSS returns 200 with css content", func(t *testing.T) {
resp, err := http.Get(ts.URL + "/admin/static/css/dashboard.css")
require.NoError(t, err)
defer closeBody(resp)

assert.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Type"), "text/css",
"static CSS asset must be served with a CSS content-type")

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.NotEmpty(t, body, "CSS bundle must not be empty")
})
}

Expand Down Expand Up @@ -235,24 +322,26 @@ func TestAdminAPI_UsageEndpoints_E2E(t *testing.T) {
expectedTotalTokens = expectedInputTokens + expectedOutputTokens
)

// Mock provider usage is 10 input + 20 output tokens per request, and this test sends 2 requests.
requestDate := time.Now().UTC()
today := requestDate.Format("2006-01-02")
yesterday := requestDate.Add(-24 * time.Hour).Format("2006-01-02")

usageFixture := setupSQLiteUsageFixture(t)
ts := setupE2EAdminServer(t, e2eServerOptions{
adminUsageReader: usageFixture.reader,
usageLogger: usageFixture.logger,
})
defer ts.Close()

// Mock provider usage is 10 input + 20 output tokens per request, and this test sends 2 requests.
requestWindowStart := time.Now().UTC()
for i := 0; i < expectedRequests; i++ {
resp := sendJSONRequest(t, ts.URL+chatCompletionsPath, defaultChatReq("Hello usage"))
require.Equal(t, http.StatusOK, resp.StatusCode)
closeBody(resp)
}
usageFixture.flush(t)
requestWindowEnd := time.Now().UTC()
expectedDailyDates := []string{requestWindowStart.Format("2006-01-02")}
if endDate := requestWindowEnd.Format("2006-01-02"); endDate != expectedDailyDates[0] {
expectedDailyDates = append(expectedDailyDates, endDate)
}

t.Run("summary includes persisted usage", func(t *testing.T) {
resp, err := http.Get(ts.URL + "/admin/usage/summary")
Expand Down Expand Up @@ -283,18 +372,32 @@ func TestAdminAPI_UsageEndpoints_E2E(t *testing.T) {
require.NoError(t, json.Unmarshal(body, &daily))
require.NotEmpty(t, daily)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Avoid midnight flakes The test captures today before sending the requests, then only accepts a daily rollup for that date. If the test crosses midnight between requestDate and the usage log timestamps, or if the usage reader groups in the configured timezone rather than this UTC timestamp, the requests can be stored under the adjacent day and this assertion fails despite correct usage logging. Capture the possible date after the requests as well, or derive the expected period from the persisted usage timestamps.

var todayEntry *usage.DailyUsage
var matchedEntries []usage.DailyUsage
for i := range daily {
if daily[i].Date == today || daily[i].Date == yesterday {
todayEntry = &daily[i]
break
for _, expectedDate := range expectedDailyDates {
if daily[i].Date == expectedDate {
matchedEntries = append(matchedEntries, daily[i])
break
}
}
}
require.NotNil(t, todayEntry, "expected daily usage entry for %s or %s", today, yesterday)
assert.Equal(t, expectedRequests, todayEntry.Requests)
assert.Equal(t, expectedInputTokens, todayEntry.InputTokens)
assert.Equal(t, expectedOutputTokens, todayEntry.OutputTokens)
assert.Equal(t, expectedTotalTokens, todayEntry.TotalTokens)
require.NotEmpty(t, matchedEntries, "expected daily usage entry for one of %v", expectedDailyDates)

var actualRequests int
var actualInputTokens int64
var actualOutputTokens int64
var actualTotalTokens int64
for _, entry := range matchedEntries {
actualRequests += entry.Requests
actualInputTokens += entry.InputTokens
actualOutputTokens += entry.OutputTokens
actualTotalTokens += entry.TotalTokens
}

assert.Equal(t, expectedRequests, actualRequests)
assert.Equal(t, expectedInputTokens, actualInputTokens)
assert.Equal(t, expectedOutputTokens, actualOutputTokens)
assert.Equal(t, expectedTotalTokens, actualTotalTokens)
})

t.Run("query params accepted", func(t *testing.T) {
Expand Down
46 changes: 43 additions & 3 deletions tests/e2e/auditlog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,33 @@ func TestAuditLogMiddleware(t *testing.T) {
require.Len(t, entries, 1)

entry := entries[0]
assert.NotNil(t, entry.Data.RequestBody)
assert.NotNil(t, entry.Data.ResponseBody)
require.NotNil(t, entry.Data.RequestBody)
require.NotNil(t, entry.Data.ResponseBody)

// Verify request body contains our message (now stored as interface{})
reqBody, ok := entry.Data.RequestBody.(map[string]interface{})
require.True(t, ok, "RequestBody should be a map[string]interface{}, got %T", entry.Data.RequestBody)
assert.Equal(t, "gpt-4", reqBody["model"])

// Verify response body captured the upstream chat completion payload, not
// just an empty marker — a regression that stored {} but non-nil would
// otherwise slip past a NotNil-only check.
respBody, ok := entry.Data.ResponseBody.(map[string]interface{})
require.True(t, ok, "ResponseBody should be a map[string]interface{}, got %T", entry.Data.ResponseBody)
assert.Equal(t, "chat.completion", respBody["object"])
assert.Equal(t, "gpt-4", respBody["model"])
assert.NotEmpty(t, respBody["id"], "response id should be captured")
choices, ok := respBody["choices"].([]interface{})
require.True(t, ok, "choices should be an array, got %T", respBody["choices"])
require.NotEmpty(t, choices)
choice0, ok := choices[0].(map[string]interface{})
require.True(t, ok)
msg, ok := choice0["message"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "assistant", msg["role"])
content, ok := msg["content"].(string)
require.True(t, ok, "message.content should be a string, got %T", msg["content"])
assert.Contains(t, content, "Test message", "captured response body should echo our input via the mock")
})

t.Run("captures headers with redaction when enabled", func(t *testing.T) {
Expand Down Expand Up @@ -466,8 +486,14 @@ func TestAuditLogConcurrency(t *testing.T) {
defer cleanup()

const numRequests = 20
type result struct {
statusCode int
err error
}

var wg sync.WaitGroup
wg.Add(numRequests)
results := make(chan result, numRequests)

for i := 0; i < numRequests; i++ {
go func(idx int) {
Expand All @@ -476,14 +502,28 @@ func TestAuditLogConcurrency(t *testing.T) {
body, _ := json.Marshal(payload)
resp, err := http.Post(serverURL+"/v1/chat/completions", "application/json", bytes.NewReader(body))
if err != nil {
t.Logf("Request %d failed: %v", idx, err)
results <- result{err: fmt.Errorf("request %d failed: %w", idx, err)}
return
}
results <- result{statusCode: resp.StatusCode}
closeBody(resp)
}(i)
}

wg.Wait()
close(results)

var requestErrors []error
statusCounts := make(map[int]int)
for r := range results {
if r.err != nil {
requestErrors = append(requestErrors, r.err)
continue
}
statusCounts[r.statusCode]++
}
require.Empty(t, requestErrors)
assert.Equal(t, numRequests, statusCounts[http.StatusOK], "all concurrent requests should return 200; status counts: %v", statusCounts)

// Wait for all log entries
entries := store.WaitForAPIEntries(numRequests, 5*time.Second)
Expand Down
Loading