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
12 changes: 12 additions & 0 deletions cmd/gomodel/docs/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func applyPluginsLoadEnv(cfg *Config) {
return
}
load := make([]PluginFileConfig, 0, 4)
for _, item := range strings.Split(v, ",") {
for item := range strings.SplitSeq(v, ",") {
if entry := parsePluginLoadEntry(item); entry.File != "" {
load = append(load, entry)
}
Expand Down
16 changes: 16 additions & 0 deletions docs/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions internal/admin/handler_audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const conversationBuildTimeout = 10 * time.Second
// @Param error_type query string false "Filter by error type"
// @Param status_code query int false "Filter by status code"
// @Param stream query bool false "Filter by stream mode (true/false)"
// @Param exclude_operation query string false "Comma-separated endpoint operations to hide, e.g. mcp,provider_passthrough,audio_speech; other entries, including unclassified ones, stay"
// @Param search query string false "Search across request_id/requested_model/provider/method/path/session_id/error_type/error_message"
// @Param limit query int false "Page size (default 25, max 100)"
// @Param offset query int false "Offset for pagination"
Expand Down Expand Up @@ -169,6 +170,14 @@ func parseAuditLogQueryParams(c *echo.Context) (auditlog.LogQueryParams, error)
params.Stream = &parsed
}

if raw := c.QueryParam("exclude_operation"); raw != "" {
ops, unknown, ok := core.ParseOperations(raw)
if !ok {
return params, core.NewInvalidRequestError("invalid exclude_operation: "+unknown, nil)
}
params.ExcludeOperations = ops
}

if l := c.QueryParam("limit"); l != "" {
parsed, err := strconv.Atoi(l)
if err != nil || parsed <= 0 {
Expand Down Expand Up @@ -211,6 +220,7 @@ func parseAuditLogQueryParams(c *echo.Context) (auditlog.LogQueryParams, error)
// @Param error_type query string false "Filter by error type"
// @Param status_code query int false "Filter by status code"
// @Param stream query bool false "Filter by stream mode (true/false)"
// @Param exclude_operation query string false "Comma-separated endpoint operations to hide, e.g. mcp,provider_passthrough,audio_speech; other entries, including unclassified ones, stay"
// @Param search query string false "Search across request_id/requested_model/provider/method/path/session_id/error_type/error_message"
// @Param limit query int false "Page size in threads (default 25, max 100)"
// @Param offset query int false "Offset for pagination"
Expand Down
15 changes: 15 additions & 0 deletions internal/admin/handler_audit_sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/enterpilot/gomodel/internal/auditlog"
"github.com/enterpilot/gomodel/internal/core"
"github.com/enterpilot/gomodel/internal/echotest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -137,3 +138,17 @@ func TestAuditLog_SessionIDSkipsDefaultDateWindow(t *testing.T) {
require.False(t, reader.lastQuery.StartDate.IsZero())
require.False(t, reader.lastQuery.EndDate.IsZero())
}

func TestAuditLog_ExcludeOperationFilter(t *testing.T) {
reader := &mockAuditReader{logResult: &auditlog.LogListResult{}}
h := NewHandler(nil, nil, WithAuditReader(reader))

c, _ := echotest.Get(t, "/admin/audit/log?exclude_operation=mcp,audio_speech")
require.NoError(t, h.AuditLog(c))
assert.Equal(t, []core.Operation{core.OperationMCP, core.OperationAudioSpeech}, reader.lastQuery.ExcludeOperations)

c, rec := echotest.Get(t, "/admin/audit/log?exclude_operation=mcp,nope")
require.NoError(t, h.AuditLog(c))
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "invalid exclude_operation: nope")
}
10 changes: 8 additions & 2 deletions internal/auditlog/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package auditlog
import (
"context"
"time"

"github.com/enterpilot/gomodel/internal/core"
)

// QueryParams specifies the date range for audit log retrieval.
Expand All @@ -24,8 +26,12 @@ type LogQueryParams struct {
Search string
StatusCode *int
Stream *bool
Limit int
Offset int
// ExcludeOperations drops entries whose path belongs to one of these
// operations. Entries outside every operation (e.g. authentication
// events) always stay.
ExcludeOperations []core.Operation
Limit int
Offset int
// OmitAttempts excludes provider attempts from returned entries. The default is false.
OmitAttempts bool
// ExactUserPath matches only UserPath instead of its subtree. The default is false.
Expand Down
25 changes: 25 additions & 0 deletions internal/auditlog/reader_mongodb.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ func mongoLogMatchFilters(params LogQueryParams) (bson.D, error) {
if params.Stream != nil {
matchFilters = append(matchFilters, bson.E{Key: "stream", Value: *params.Stream})
}
if len(params.ExcludeOperations) > 0 {
matchFilters = append(matchFilters, mongoExcludeOperationsFilter(params.ExcludeOperations))
}
if params.Search != "" && isCanonicalUUID(params.Search) {
// A full canonical UUID is a pasted identifier: match the indexed
// identity fields by equality (both spellings — stored ids are
Expand Down Expand Up @@ -417,3 +420,25 @@ func (r *MongoDBReader) findConversationEntry(ctx context.Context, filter bson.D

return row.toLogEntry(), nil
}

// mongoExcludeOperationsFilter drops paths belonging to any of the
// operations; entries without a path stay. Exact paths also match with one
// trailing slash, as DescribeEndpoint does.
func mongoExcludeOperationsFilter(ops []core.Operation) bson.E {
var exact bson.A
var nor bson.A
for _, op := range ops {
paths, _ := core.PathsForOperation(op)
for _, path := range paths.Exact {
exact = append(exact, path, path+"/")
}
for _, prefix := range paths.Prefixes {
exact = append(exact, prefix)
nor = append(nor, bson.D{{Key: "path", Value: bson.D{
{Key: "$regex", Value: "^" + regexp.QuoteMeta(prefix+"/")},
}}})
}
}
nor = append(nor, bson.D{{Key: "path", Value: bson.D{{Key: "$in", Value: exact}}}})
return bson.E{Key: "$nor", Value: nor}
}
25 changes: 25 additions & 0 deletions internal/auditlog/reader_sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/goccy/go-json"

"github.com/enterpilot/gomodel/internal/core"
"github.com/enterpilot/gomodel/internal/storage/sqlutil"
"github.com/enterpilot/gomodel/internal/storage/sqlx"
)
Expand Down Expand Up @@ -243,6 +244,10 @@ func (r *SQLReader) logFilters(ctx context.Context, params LogQueryParams) ([]st
if params.Stream != nil {
add("stream = ?", *params.Stream)
}
if len(params.ExcludeOperations) > 0 {
condition, values := excludeOperationsSQLFilter(params.ExcludeOperations)
add(condition, values...)
}
if params.Search != "" {
condition, values := r.searchFilter(params.Search, r.searchIsIndexed(ctx))
add(condition, values...)
Expand Down Expand Up @@ -560,3 +565,23 @@ func isMissingAuditAttemptsTable(err error) bool {
return strings.Contains(message, "audit_log_attempts") &&
(strings.Contains(message, "no such table") || strings.Contains(message, "does not exist"))
}

// excludeOperationsSQLFilter drops paths belonging to any of the operations.
// Exact paths also match with one trailing slash, as DescribeEndpoint does.
// The prefixes hold no LIKE wildcards, so they need no escaping.
func excludeOperationsSQLFilter(ops []core.Operation) (string, []any) {
var clauses []string
var args []any
for _, op := range ops {
paths, _ := core.PathsForOperation(op)
for _, exact := range paths.Exact {
clauses = append(clauses, "path = ?", "path = ?")
args = append(args, exact, exact+"/")
}
for _, prefix := range paths.Prefixes {
clauses = append(clauses, "path = ?", "path LIKE ?")
args = append(args, prefix, prefix+"/%")
}
}
return "(path IS NULL OR NOT (" + strings.Join(clauses, " OR ") + "))", args
}
56 changes: 56 additions & 0 deletions internal/auditlog/reader_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package auditlog

import (
"context"
"fmt"
"testing"
"time"

"go.mongodb.org/mongo-driver/v2/mongo"

"github.com/enterpilot/gomodel/internal/core"
"github.com/enterpilot/gomodel/internal/storage/mongotest"
"github.com/enterpilot/gomodel/internal/storage/sqlx"
"github.com/enterpilot/gomodel/internal/storage/sqlx/sqlxtest"
Expand Down Expand Up @@ -103,3 +105,57 @@ func TestReader_GetLastUsedByAuthKeys(t *testing.T) {
assert.False(t, ok)
})
}

func TestReader_GetLogsExcludesOperations(t *testing.T) {
runReaderSuite(t, func(t *testing.T, store LogStore, reader Reader) {
ctx := context.Background()
base := time.Date(2026, 1, 16, 12, 0, 0, 0, time.UTC)
paths := []string{
"/v1/chat/completions", "/mcp", "/mcp/github", "/mcpx",
"/v1/audio/speech", "/v1/audio/speech/", "/v1/audio/transcriptions",
"/p/openai/v1/models", "/sso/callback", "",
}
entries := make([]*LogEntry, 0, len(paths))
for i, path := range paths {
entries = append(entries, &LogEntry{
ID: fmt.Sprintf("op-%d", i),
Timestamp: base.Add(time.Duration(i) * time.Minute),
Path: path,
})
}
require.NoError(t, store.WriteBatch(ctx, entries))

tests := []struct {
name string
ops []core.Operation
want []string
}{
{name: "mcp prefix", ops: []core.Operation{core.OperationMCP}, want: []string{
"/v1/chat/completions", "/mcpx", "/v1/audio/speech", "/v1/audio/speech/",
"/v1/audio/transcriptions", "/p/openai/v1/models", "/sso/callback", "",
}},
{name: "exact with trailing slash and prefix", ops: []core.Operation{
core.OperationAudioSpeech, core.OperationProviderPassthrough,
}, want: []string{
"/v1/chat/completions", "/mcp", "/mcp/github", "/mcpx",
"/v1/audio/transcriptions", "/sso/callback", "",
}},
{name: "every classified type keeps unclassified rows", ops: []core.Operation{
core.OperationChatCompletions, core.OperationMCP, core.OperationAudioSpeech,
core.OperationAudioTranscriptions, core.OperationProviderPassthrough,
}, want: []string{"/mcpx", "/sso/callback", ""}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := reader.GetLogs(ctx, LogQueryParams{ExcludeOperations: tt.ops, Limit: 50})
require.NoError(t, err)
got := make([]string, 0, len(result.Entries))
for _, entry := range result.Entries {
got = append(got, entry.Path)
}
assert.ElementsMatch(t, tt.want, got)
assert.Equal(t, len(tt.want), result.Total)
})
}
})
}
58 changes: 58 additions & 0 deletions internal/core/endpoint_operations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package core

import "strings"

// OperationPaths lists the request paths that DescribeEndpoint classifies as
// one operation, in a shape storage filters can match: Exact paths compare by
// equality, and each Prefix matches itself or anything under "Prefix/".
type OperationPaths struct {
Exact []string
Prefixes []string
}

// operationPaths mirrors describeEndpointPath. TestOperationPathsMatchDescribeEndpoint
// keeps the two in sync.
var operationPaths = map[Operation]OperationPaths{
OperationChatCompletions: {Exact: []string{"/v1/chat/completions", "/v1/messages", "/v1/messages/count_tokens"}},
OperationResponses: {Prefixes: []string{"/v1/responses"}},
OperationConversations: {Prefixes: []string{"/v1/conversations"}},
OperationEmbeddings: {Exact: []string{"/v1/embeddings"}},
OperationBatches: {Prefixes: []string{"/v1/batches", "/v1/messages/batches"}},
OperationFiles: {Prefixes: []string{"/v1/files"}},
OperationAudioSpeech: {Exact: []string{"/v1/audio/speech"}},
OperationAudioTranscriptions: {Exact: []string{"/v1/audio/transcriptions"}},
OperationAudioTranslations: {Exact: []string{"/v1/audio/translations"}},
OperationImageGenerations: {Exact: []string{"/v1/images/generations"}},
OperationImageEdits: {Exact: []string{"/v1/images/edits"}},
OperationRealtime: {Exact: []string{
"/v1/realtime", "/v1/realtime/calls", "/v1/realtime/client_secrets",
"/v1/realtime/translations", "/v1/realtime/translations/calls", "/v1/realtime/translations/client_secrets",
}},
OperationMCP: {Prefixes: []string{"/mcp"}},
OperationProviderPassthrough: {Prefixes: []string{"/p"}},
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// PathsForOperation returns the paths of a known operation.
func PathsForOperation(op Operation) (OperationPaths, bool) {
paths, ok := operationPaths[op]
return paths, ok
}

// ParseOperations parses a comma-separated operation list, ignoring blanks
// and duplicates. It reports the first unknown name.
func ParseOperations(raw string) ([]Operation, string, bool) {
var ops []Operation
seen := map[Operation]bool{}
for part := range strings.SplitSeq(raw, ",") {
op := Operation(strings.ToLower(strings.TrimSpace(part)))
if op == "" || seen[op] {
continue
}
if _, ok := operationPaths[op]; !ok {
return nil, string(op), false
}
seen[op] = true
ops = append(ops, op)
}
return ops, "", true
}
Loading
Loading