From a6aea9b9150bb6e74b12bc9e5b9e694c34ba08de Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 14 Mar 2026 21:30:58 +0100 Subject: [PATCH 01/16] feat: add model aliases feature --- internal/admin/handler.go | 90 ++++++ internal/admin/handler_aliases_test.go | 158 +++++++++++ internal/aliases/factory.go | 166 +++++++++++ internal/aliases/provider.go | 377 +++++++++++++++++++++++++ internal/aliases/provider_test.go | 222 +++++++++++++++ internal/aliases/service.go | 289 +++++++++++++++++++ internal/aliases/service_test.go | 164 +++++++++++ internal/aliases/store.go | 70 +++++ internal/aliases/store_mongodb.go | 137 +++++++++ internal/aliases/store_postgresql.go | 143 ++++++++++ internal/aliases/store_sqlite.go | 151 ++++++++++ internal/aliases/types.go | 38 +++ internal/app/app.go | 91 ++++-- internal/providers/registry.go | 11 + internal/server/handlers.go | 57 ++-- internal/server/http.go | 39 +-- 16 files changed, 2146 insertions(+), 57 deletions(-) create mode 100644 internal/admin/handler_aliases_test.go create mode 100644 internal/aliases/factory.go create mode 100644 internal/aliases/provider.go create mode 100644 internal/aliases/provider_test.go create mode 100644 internal/aliases/service.go create mode 100644 internal/aliases/service_test.go create mode 100644 internal/aliases/store.go create mode 100644 internal/aliases/store_mongodb.go create mode 100644 internal/aliases/store_postgresql.go create mode 100644 internal/aliases/store_sqlite.go create mode 100644 internal/aliases/types.go diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 5cb22872..9a26b815 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -11,6 +11,7 @@ import ( "github.com/labstack/echo/v5" + "gomodel/internal/aliases" "gomodel/internal/auditlog" "gomodel/internal/core" "gomodel/internal/providers" @@ -22,6 +23,7 @@ type Handler struct { usageReader usage.UsageReader auditReader auditlog.Reader registry *providers.ModelRegistry + aliases *aliases.Service } // Option configures the admin API handler. @@ -34,6 +36,13 @@ func WithAuditReader(reader auditlog.Reader) Option { } } +// WithAliases enables alias administration endpoints. +func WithAliases(service *aliases.Service) Option { + return func(h *Handler) { + h.aliases = service + } +} + // NewHandler creates a new admin API handler. // usageReader may be nil if usage tracking is not available. func NewHandler(reader usage.UsageReader, registry *providers.ModelRegistry, options ...Option) *Handler { @@ -501,3 +510,84 @@ func (h *Handler) ListCategories(c *echo.Context) error { return c.JSON(http.StatusOK, h.registry.GetCategoryCounts()) } + +type upsertAliasRequest struct { + TargetModel string `json:"target_model"` + TargetProvider string `json:"target_provider,omitempty"` + Description string `json:"description,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +func (h *Handler) aliasesUnavailableError() error { + return core.NewProviderError("aliases", http.StatusInternalServerError, "aliases service is unavailable", nil) +} + +// ListAliases handles GET /admin/api/v1/aliases +func (h *Handler) ListAliases(c *echo.Context) error { + if h.aliases == nil { + return c.JSON(http.StatusOK, []aliases.View{}) + } + views := h.aliases.ListViews() + if views == nil { + views = []aliases.View{} + } + return c.JSON(http.StatusOK, views) +} + +// UpsertAlias handles PUT /admin/api/v1/aliases/{name} +func (h *Handler) UpsertAlias(c *echo.Context) error { + if h.aliases == nil { + return handleError(c, h.aliasesUnavailableError()) + } + + name := strings.TrimSpace(c.Param("name")) + if name == "" { + return handleError(c, core.NewInvalidRequestError("alias name is required", nil)) + } + + var req upsertAliasRequest + if err := c.Bind(&req); err != nil { + return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) + } + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + + if err := h.aliases.Upsert(c.Request().Context(), aliases.Alias{ + Name: name, + TargetModel: req.TargetModel, + TargetProvider: req.TargetProvider, + Description: req.Description, + Enabled: enabled, + }); err != nil { + return handleError(c, core.NewInvalidRequestError(err.Error(), err)) + } + + alias, ok := h.aliases.Get(name) + if !ok { + return c.NoContent(http.StatusNoContent) + } + return c.JSON(http.StatusOK, alias) +} + +// DeleteAlias handles DELETE /admin/api/v1/aliases/{name} +func (h *Handler) DeleteAlias(c *echo.Context) error { + if h.aliases == nil { + return handleError(c, h.aliasesUnavailableError()) + } + + name := strings.TrimSpace(c.Param("name")) + if name == "" { + return handleError(c, core.NewInvalidRequestError("alias name is required", nil)) + } + + if err := h.aliases.Delete(c.Request().Context(), name); err != nil { + if errors.Is(err, aliases.ErrNotFound) { + return handleError(c, core.NewNotFoundError("alias not found: "+name)) + } + return handleError(c, core.NewInvalidRequestError(err.Error(), err)) + } + return c.NoContent(http.StatusNoContent) +} diff --git a/internal/admin/handler_aliases_test.go b/internal/admin/handler_aliases_test.go new file mode 100644 index 00000000..285a036a --- /dev/null +++ b/internal/admin/handler_aliases_test.go @@ -0,0 +1,158 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + + "gomodel/internal/aliases" + "gomodel/internal/core" +) + +type aliasTestStore struct { + items map[string]aliases.Alias +} + +func newAliasTestStore(items ...aliases.Alias) *aliasTestStore { + store := &aliasTestStore{items: make(map[string]aliases.Alias, len(items))} + for _, item := range items { + store.items[item.Name] = item + } + return store +} + +func (s *aliasTestStore) List(_ context.Context) ([]aliases.Alias, error) { + result := make([]aliases.Alias, 0, len(s.items)) + for _, item := range s.items { + result = append(result, item) + } + return result, nil +} + +func (s *aliasTestStore) Get(_ context.Context, name string) (*aliases.Alias, error) { + item, ok := s.items[name] + if !ok { + return nil, aliases.ErrNotFound + } + copy := item + return ©, nil +} + +func (s *aliasTestStore) Upsert(_ context.Context, alias aliases.Alias) error { + s.items[alias.Name] = alias + return nil +} + +func (s *aliasTestStore) Delete(_ context.Context, name string) error { + if _, ok := s.items[name]; !ok { + return aliases.ErrNotFound + } + delete(s.items, name) + return nil +} + +func (s *aliasTestStore) Close() error { return nil } + +type aliasTestCatalog struct { + providerTypes map[string]string + models map[string]core.Model +} + +func newAliasTestCatalog() *aliasTestCatalog { + return &aliasTestCatalog{ + providerTypes: map[string]string{}, + models: map[string]core.Model{}, + } +} + +func (c *aliasTestCatalog) add(model, providerType string) { + c.providerTypes[model] = providerType + c.models[model] = core.Model{ID: model, Object: "model"} +} + +func (c *aliasTestCatalog) Supports(model string) bool { + _, ok := c.models[model] + return ok +} + +func (c *aliasTestCatalog) GetProviderType(model string) string { + return c.providerTypes[model] +} + +func (c *aliasTestCatalog) LookupModel(model string) (*core.Model, bool) { + value, ok := c.models[model] + if !ok { + return nil, false + } + copy := value + return ©, true +} + +func newAliasHandler(t *testing.T, items ...aliases.Alias) *Handler { + t.Helper() + catalog := newAliasTestCatalog() + catalog.add("gpt-4o", "openai") + service, err := aliases.NewService(newAliasTestStore(items...), catalog) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + return NewHandler(nil, nil, WithAliases(service)) +} + +func TestListAliases(t *testing.T) { + h := newAliasHandler(t, aliases.Alias{Name: "smart", TargetModel: "gpt-4o", Enabled: true}) + c, rec := newHandlerContext("/admin/api/v1/aliases") + + if err := h.ListAliases(c); err != nil { + t.Fatalf("ListAliases() error = %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var body []aliases.View + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(body) != 1 || body[0].Name != "smart" || !body[0].Valid { + t.Fatalf("response = %#v, want one valid alias", body) + } +} + +func TestUpsertAliasAndDeleteAlias(t *testing.T) { + h := newAliasHandler(t) + e := echo.New() + + putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases/smart", bytes.NewBufferString(`{"target_model":"gpt-4o","description":"primary"}`)) + putReq.Header.Set("Content-Type", "application/json") + putRec := httptest.NewRecorder() + putCtx := e.NewContext(putReq, putRec) + putCtx.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) + + if err := h.UpsertAlias(putCtx); err != nil { + t.Fatalf("UpsertAlias() error = %v", err) + } + if putRec.Code != http.StatusOK { + t.Fatalf("put status = %d, want 200", putRec.Code) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/aliases/smart", nil) + deleteRec := httptest.NewRecorder() + deleteCtx := e.NewContext(deleteReq, deleteRec) + deleteCtx.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) + + if err := h.DeleteAlias(deleteCtx); err != nil { + t.Fatalf("DeleteAlias() error = %v", err) + } + if deleteRec.Code != http.StatusNoContent { + t.Fatalf("delete status = %d, want 204", deleteRec.Code) + } +} diff --git a/internal/aliases/factory.go b/internal/aliases/factory.go new file mode 100644 index 00000000..ad5c4ea8 --- /dev/null +++ b/internal/aliases/factory.go @@ -0,0 +1,166 @@ +package aliases + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "go.mongodb.org/mongo-driver/v2/mongo" + + "gomodel/config" + "gomodel/internal/storage" +) + +// Result holds the initialized aliases service and any owned resources. +type Result struct { + Service *Service + Store Store + Storage storage.Storage + + stopRefresh func() + closeOnce sync.Once + closeErr error +} + +// Close releases resources held by the aliases subsystem. +func (r *Result) Close() error { + if r == nil { + return nil + } + r.closeOnce.Do(func() { + if r.stopRefresh != nil { + r.stopRefresh() + r.stopRefresh = nil + } + + var errs []error + if r.Store != nil { + if err := r.Store.Close(); err != nil { + errs = append(errs, fmt.Errorf("store close: %w", err)) + } + } + if r.Storage != nil { + if err := r.Storage.Close(); err != nil { + errs = append(errs, fmt.Errorf("storage close: %w", err)) + } + } + if len(errs) > 0 { + r.closeErr = fmt.Errorf("close errors: %w", errors.Join(errs...)) + } + }) + return r.closeErr +} + +// New creates an aliases subsystem with its own storage connection. +func New(ctx context.Context, cfg *config.Config, catalog Catalog) (*Result, error) { + if cfg == nil { + return nil, fmt.Errorf("config is required") + } + storeConn, err := storage.New(ctx, buildStorageConfig(cfg)) + if err != nil { + return nil, fmt.Errorf("failed to create storage: %w", err) + } + result, err := newResult(ctx, cfg, storeConn, catalog) + if err != nil { + _ = storeConn.Close() + return nil, err + } + result.Storage = storeConn + return result, nil +} + +// NewWithSharedStorage creates an aliases subsystem using an existing storage connection. +func NewWithSharedStorage(ctx context.Context, cfg *config.Config, shared storage.Storage, catalog Catalog) (*Result, error) { + if shared == nil { + return nil, fmt.Errorf("shared storage is required") + } + if cfg == nil { + return nil, fmt.Errorf("config is required") + } + return newResult(ctx, cfg, shared, catalog) +} + +func newResult(ctx context.Context, cfg *config.Config, storeConn storage.Storage, catalog Catalog) (*Result, error) { + store, err := createStore(ctx, storeConn) + if err != nil { + return nil, err + } + service, err := NewService(store, catalog) + if err != nil { + return nil, err + } + if err := service.Refresh(ctx); err != nil { + return nil, err + } + + refreshInterval := time.Duration(cfg.Cache.Model.RefreshInterval) * time.Second + if refreshInterval <= 0 { + refreshInterval = time.Hour + } + + return &Result{ + Service: service, + Store: store, + stopRefresh: service.StartBackgroundRefresh(refreshInterval), + }, nil +} + +func buildStorageConfig(cfg *config.Config) storage.Config { + storageCfg := storage.Config{ + Type: cfg.Storage.Type, + SQLite: storage.SQLiteConfig{ + Path: cfg.Storage.SQLite.Path, + }, + PostgreSQL: storage.PostgreSQLConfig{ + URL: cfg.Storage.PostgreSQL.URL, + MaxConns: cfg.Storage.PostgreSQL.MaxConns, + }, + MongoDB: storage.MongoDBConfig{ + URL: cfg.Storage.MongoDB.URL, + Database: cfg.Storage.MongoDB.Database, + }, + } + + if storageCfg.Type == "" { + storageCfg.Type = storage.TypeSQLite + } + if storageCfg.SQLite.Path == "" { + storageCfg.SQLite.Path = storage.DefaultSQLitePath + } + if storageCfg.MongoDB.Database == "" { + storageCfg.MongoDB.Database = "gomodel" + } + return storageCfg +} + +func createStore(ctx context.Context, store storage.Storage) (Store, error) { + switch store.Type() { + case storage.TypeSQLite: + return NewSQLiteStore(store.SQLiteDB()) + case storage.TypePostgreSQL: + pool := store.PostgreSQLPool() + if pool == nil { + return nil, fmt.Errorf("PostgreSQL pool is nil") + } + pgxPool, ok := pool.(*pgxpool.Pool) + if !ok { + return nil, fmt.Errorf("invalid PostgreSQL pool type: %T", pool) + } + return NewPostgreSQLStore(ctx, pgxPool) + case storage.TypeMongoDB: + db := store.MongoDatabase() + if db == nil { + return nil, fmt.Errorf("MongoDB database is nil") + } + mongoDB, ok := db.(*mongo.Database) + if !ok { + return nil, fmt.Errorf("invalid MongoDB database type: %T", db) + } + return NewMongoDBStore(mongoDB) + default: + return nil, fmt.Errorf("unknown storage type: %s", store.Type()) + } +} diff --git a/internal/aliases/provider.go b/internal/aliases/provider.go new file mode 100644 index 00000000..c01111d9 --- /dev/null +++ b/internal/aliases/provider.go @@ -0,0 +1,377 @@ +package aliases + +import ( + "context" + "encoding/json" + "fmt" + "io" + "sort" + + "gomodel/internal/core" +) + +// Provider wraps a routable provider and resolves aliases before delegating. +type Provider struct { + inner core.RoutableProvider + service *Service +} + +// NewProvider creates an alias-aware provider wrapper. +func NewProvider(inner core.RoutableProvider, service *Service) *Provider { + return &Provider{inner: inner, service: service} +} + +// ResolveModel resolves a model/provider pair through the alias table. +func (p *Provider) ResolveModel(model, provider string) (core.ModelSelector, bool, error) { + if p.service == nil { + selector, err := core.ParseModelSelector(model, provider) + return selector, false, err + } + resolution, ok, err := p.service.Resolve(model, provider) + if err != nil { + return core.ModelSelector{}, false, err + } + return resolution.Resolved, ok, nil +} + +func (p *Provider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + forward, err := p.rewriteChatRequest(req) + if err != nil { + return nil, err + } + return p.inner.ChatCompletion(ctx, forward) +} + +func (p *Provider) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { + forward, err := p.rewriteChatRequest(req) + if err != nil { + return nil, err + } + return p.inner.StreamChatCompletion(ctx, forward) +} + +func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) { + resp, err := p.inner.ListModels(ctx) + if err != nil { + return nil, err + } + if resp == nil { + resp = &core.ModelsResponse{Object: "list", Data: []core.Model{}} + } + if p.service == nil { + return resp, nil + } + + dataByID := make(map[string]core.Model, len(resp.Data)) + for _, model := range resp.Data { + dataByID[model.ID] = model + } + for _, aliasModel := range p.service.ExposedModels() { + dataByID[aliasModel.ID] = aliasModel + } + data := make([]core.Model, 0, len(dataByID)) + for _, model := range dataByID { + data = append(data, model) + } + sort.Slice(data, func(i, j int) bool { return data[i].ID < data[j].ID }) + + cloned := *resp + cloned.Data = data + return &cloned, nil +} + +func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + forward, err := p.rewriteResponsesRequest(req) + if err != nil { + return nil, err + } + return p.inner.Responses(ctx, forward) +} + +func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { + forward, err := p.rewriteResponsesRequest(req) + if err != nil { + return nil, err + } + return p.inner.StreamResponses(ctx, forward) +} + +func (p *Provider) Embeddings(ctx context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + forward, err := p.rewriteEmbeddingRequest(req) + if err != nil { + return nil, err + } + return p.inner.Embeddings(ctx, forward) +} + +func (p *Provider) Supports(model string) bool { + if p.service != nil && p.service.Supports(model) { + return true + } + return p.inner.Supports(model) +} + +func (p *Provider) GetProviderType(model string) string { + if p.service != nil { + if providerType := p.service.GetProviderType(model); providerType != "" { + return providerType + } + } + return p.inner.GetProviderType(model) +} + +func (p *Provider) ModelCount() int { + if counted, ok := p.inner.(interface{ ModelCount() int }); ok { + return counted.ModelCount() + } + return -1 +} + +func (p *Provider) CreateBatch(ctx context.Context, providerType string, req *core.BatchRequest) (*core.BatchResponse, error) { + native, err := p.nativeBatchRouter() + if err != nil { + return nil, err + } + forward, err := p.rewriteBatchRequest(req) + if err != nil { + return nil, err + } + return native.CreateBatch(ctx, providerType, forward) +} + +func (p *Provider) GetBatch(ctx context.Context, providerType, id string) (*core.BatchResponse, error) { + native, err := p.nativeBatchRouter() + if err != nil { + return nil, err + } + return native.GetBatch(ctx, providerType, id) +} + +func (p *Provider) ListBatches(ctx context.Context, providerType string, limit int, after string) (*core.BatchListResponse, error) { + native, err := p.nativeBatchRouter() + if err != nil { + return nil, err + } + return native.ListBatches(ctx, providerType, limit, after) +} + +func (p *Provider) CancelBatch(ctx context.Context, providerType, id string) (*core.BatchResponse, error) { + native, err := p.nativeBatchRouter() + if err != nil { + return nil, err + } + return native.CancelBatch(ctx, providerType, id) +} + +func (p *Provider) GetBatchResults(ctx context.Context, providerType, id string) (*core.BatchResultsResponse, error) { + native, err := p.nativeBatchRouter() + if err != nil { + return nil, err + } + return native.GetBatchResults(ctx, providerType, id) +} + +func (p *Provider) CreateBatchWithHints(ctx context.Context, providerType string, req *core.BatchRequest) (*core.BatchResponse, map[string]string, error) { + hinted, err := p.nativeBatchHintRouter() + if err != nil { + return nil, nil, err + } + forward, err := p.rewriteBatchRequest(req) + if err != nil { + return nil, nil, err + } + return hinted.CreateBatchWithHints(ctx, providerType, forward) +} + +func (p *Provider) GetBatchResultsWithHints(ctx context.Context, providerType, id string, endpointByCustomID map[string]string) (*core.BatchResultsResponse, error) { + hinted, err := p.nativeBatchHintRouter() + if err != nil { + return nil, err + } + return hinted.GetBatchResultsWithHints(ctx, providerType, id, endpointByCustomID) +} + +func (p *Provider) ClearBatchResultHints(providerType, batchID string) { + hinted, err := p.nativeBatchHintRouter() + if err != nil { + return + } + hinted.ClearBatchResultHints(providerType, batchID) +} + +func (p *Provider) CreateFile(ctx context.Context, providerType string, req *core.FileCreateRequest) (*core.FileObject, error) { + files, err := p.nativeFileRouter() + if err != nil { + return nil, err + } + return files.CreateFile(ctx, providerType, req) +} + +func (p *Provider) ListFiles(ctx context.Context, providerType, purpose string, limit int, after string) (*core.FileListResponse, error) { + files, err := p.nativeFileRouter() + if err != nil { + return nil, err + } + return files.ListFiles(ctx, providerType, purpose, limit, after) +} + +func (p *Provider) GetFile(ctx context.Context, providerType, id string) (*core.FileObject, error) { + files, err := p.nativeFileRouter() + if err != nil { + return nil, err + } + return files.GetFile(ctx, providerType, id) +} + +func (p *Provider) DeleteFile(ctx context.Context, providerType, id string) (*core.FileDeleteResponse, error) { + files, err := p.nativeFileRouter() + if err != nil { + return nil, err + } + return files.DeleteFile(ctx, providerType, id) +} + +func (p *Provider) GetFileContent(ctx context.Context, providerType, id string) (*core.FileContentResponse, error) { + files, err := p.nativeFileRouter() + if err != nil { + return nil, err + } + return files.GetFileContent(ctx, providerType, id) +} + +func (p *Provider) Passthrough(ctx context.Context, providerType string, req *core.PassthroughRequest) (*core.PassthroughResponse, error) { + passthrough, err := p.passthroughRouter() + if err != nil { + return nil, err + } + return passthrough.Passthrough(ctx, providerType, req) +} + +func (p *Provider) rewriteChatRequest(req *core.ChatRequest) (*core.ChatRequest, error) { + if req == nil { + return nil, nil + } + selector, changed, err := p.ResolveModel(req.Model, req.Provider) + if err != nil || !changed { + return req, err + } + forward := *req + forward.Model = selector.Model + forward.Provider = selector.Provider + return &forward, nil +} + +func (p *Provider) rewriteResponsesRequest(req *core.ResponsesRequest) (*core.ResponsesRequest, error) { + if req == nil { + return nil, nil + } + selector, changed, err := p.ResolveModel(req.Model, req.Provider) + if err != nil || !changed { + return req, err + } + forward := *req + forward.Model = selector.Model + forward.Provider = selector.Provider + return &forward, nil +} + +func (p *Provider) rewriteEmbeddingRequest(req *core.EmbeddingRequest) (*core.EmbeddingRequest, error) { + if req == nil { + return nil, nil + } + selector, changed, err := p.ResolveModel(req.Model, req.Provider) + if err != nil || !changed { + return req, err + } + forward := *req + forward.Model = selector.Model + forward.Provider = selector.Provider + return &forward, nil +} + +func (p *Provider) rewriteBatchRequest(req *core.BatchRequest) (*core.BatchRequest, error) { + if req == nil || len(req.Requests) == 0 { + return req, nil + } + + forward := *req + forward.Requests = make([]core.BatchRequestItem, len(req.Requests)) + copy(forward.Requests, req.Requests) + + for i, item := range forward.Requests { + decoded, handled, err := core.MaybeDecodeKnownBatchItemRequest(req.Endpoint, item, "chat_completions", "responses", "embeddings") + if err != nil { + return nil, core.NewInvalidRequestError(fmt.Sprintf("batch item %d: %s", i, err.Error()), err) + } + if !handled { + continue + } + + var body []byte + switch typed := decoded.Request.(type) { + case *core.ChatRequest: + modified, err := p.rewriteChatRequest(typed) + if err != nil { + return nil, err + } + body, err = json.Marshal(modified) + if err != nil { + return nil, core.NewInvalidRequestError("failed to encode batch item", err) + } + case *core.ResponsesRequest: + modified, err := p.rewriteResponsesRequest(typed) + if err != nil { + return nil, err + } + body, err = json.Marshal(modified) + if err != nil { + return nil, core.NewInvalidRequestError("failed to encode batch item", err) + } + case *core.EmbeddingRequest: + modified, err := p.rewriteEmbeddingRequest(typed) + if err != nil { + return nil, err + } + body, err = json.Marshal(modified) + if err != nil { + return nil, core.NewInvalidRequestError("failed to encode batch item", err) + } + default: + continue + } + forward.Requests[i].Body = body + } + return &forward, nil +} + +func (p *Provider) nativeBatchRouter() (core.NativeBatchRoutableProvider, error) { + native, ok := p.inner.(core.NativeBatchRoutableProvider) + if !ok { + return nil, core.NewInvalidRequestError("batch routing is not supported by the current provider router", nil) + } + return native, nil +} + +func (p *Provider) nativeBatchHintRouter() (core.NativeBatchHintRoutableProvider, error) { + hinted, ok := p.inner.(core.NativeBatchHintRoutableProvider) + if !ok { + return nil, core.NewInvalidRequestError("batch hint routing is not supported by the current provider router", nil) + } + return hinted, nil +} + +func (p *Provider) nativeFileRouter() (core.NativeFileRoutableProvider, error) { + files, ok := p.inner.(core.NativeFileRoutableProvider) + if !ok { + return nil, core.NewInvalidRequestError("file routing is not supported by the current provider router", nil) + } + return files, nil +} + +func (p *Provider) passthroughRouter() (core.RoutablePassthrough, error) { + passthrough, ok := p.inner.(core.RoutablePassthrough) + if !ok { + return nil, core.NewInvalidRequestError("passthrough routing is not supported by the current provider router", nil) + } + return passthrough, nil +} diff --git a/internal/aliases/provider_test.go b/internal/aliases/provider_test.go new file mode 100644 index 00000000..b935024b --- /dev/null +++ b/internal/aliases/provider_test.go @@ -0,0 +1,222 @@ +package aliases + +import ( + "context" + "encoding/json" + "io" + "testing" + + "gomodel/internal/core" +) + +type providerMock struct { + chatReq *core.ChatRequest + responsesReq *core.ResponsesRequest + embeddingReq *core.EmbeddingRequest + batchReq *core.BatchRequest + modelsResp *core.ModelsResponse + supported map[string]bool + providerType map[string]string +} + +func newProviderMock() *providerMock { + return &providerMock{ + supported: map[string]bool{}, + providerType: map[string]string{}, + modelsResp: &core.ModelsResponse{Object: "list", Data: []core.Model{}}, + } +} + +func (m *providerMock) ChatCompletion(_ context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + m.chatReq = req + return &core.ChatResponse{Model: req.Model}, nil +} + +func (m *providerMock) StreamChatCompletion(_ context.Context, req *core.ChatRequest) (io.ReadCloser, error) { + m.chatReq = req + return io.NopCloser(nil), nil +} + +func (m *providerMock) ListModels(_ context.Context) (*core.ModelsResponse, error) { + return m.modelsResp, nil +} + +func (m *providerMock) Responses(_ context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + m.responsesReq = req + return &core.ResponsesResponse{Model: req.Model}, nil +} + +func (m *providerMock) StreamResponses(_ context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { + m.responsesReq = req + return io.NopCloser(nil), nil +} + +func (m *providerMock) Embeddings(_ context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + m.embeddingReq = req + return &core.EmbeddingResponse{Model: req.Model}, nil +} + +func (m *providerMock) Supports(model string) bool { + return m.supported[model] +} + +func (m *providerMock) GetProviderType(model string) string { + return m.providerType[model] +} + +func (m *providerMock) CreateBatch(_ context.Context, _ string, req *core.BatchRequest) (*core.BatchResponse, error) { + m.batchReq = req + return &core.BatchResponse{ID: "batch_1", Object: "batch"}, nil +} + +func (m *providerMock) GetBatch(_ context.Context, _ string, _ string) (*core.BatchResponse, error) { + return &core.BatchResponse{ID: "batch_1", Object: "batch"}, nil +} + +func (m *providerMock) ListBatches(_ context.Context, _ string, _ int, _ string) (*core.BatchListResponse, error) { + return &core.BatchListResponse{Object: "list"}, nil +} + +func (m *providerMock) CancelBatch(_ context.Context, _ string, _ string) (*core.BatchResponse, error) { + return &core.BatchResponse{ID: "batch_1", Object: "batch", Status: "cancelled"}, nil +} + +func (m *providerMock) GetBatchResults(_ context.Context, _ string, _ string) (*core.BatchResultsResponse, error) { + return &core.BatchResultsResponse{Object: "list", BatchID: "batch_1"}, nil +} + +func TestProviderResolvesRequestsAndExposesAliasModels(t *testing.T) { + catalog := newTestCatalog() + catalog.add("gpt-4o", "openai", core.Model{ID: "gpt-4o", Object: "model", OwnedBy: "openai"}) + + service, err := NewService(newMemoryStore(Alias{Name: "smart", TargetModel: "gpt-4o", Enabled: true}), catalog) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + inner := newProviderMock() + inner.supported["gpt-4o"] = true + inner.providerType["gpt-4o"] = "openai" + inner.modelsResp = &core.ModelsResponse{Object: "list", Data: []core.Model{{ID: "gpt-4o", Object: "model"}}} + + provider := NewProvider(inner, service) + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{Model: "smart"}); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if inner.chatReq == nil || inner.chatReq.Model != "gpt-4o" { + t.Fatalf("inner.chatReq = %#v, want rewritten model gpt-4o", inner.chatReq) + } + if !provider.Supports("smart") { + t.Fatal("Supports(smart) = false, want true") + } + if got := provider.GetProviderType("smart"); got != "openai" { + t.Fatalf("GetProviderType(smart) = %q, want openai", got) + } + selector, changed, err := provider.ResolveModel("smart", "") + if err != nil { + t.Fatalf("ResolveModel() error = %v", err) + } + if !changed || selector.QualifiedModel() != "gpt-4o" { + t.Fatalf("ResolveModel() = (%q, %v), want gpt-4o,true", selector.QualifiedModel(), changed) + } + + models, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(models.Data) != 2 { + t.Fatalf("len(ListModels().Data) = %d, want 2", len(models.Data)) + } + if models.Data[0].ID != "gpt-4o" || models.Data[1].ID != "smart" { + t.Fatalf("ListModels().Data = %#v, want concrete model plus alias", models.Data) + } +} + +func TestProviderMaskingAliasOverridesConcreteModelEntry(t *testing.T) { + catalog := newTestCatalog() + catalog.add("gpt-4o", "openai", core.Model{ID: "gpt-4o", Object: "model", OwnedBy: "openai"}) + catalog.add("gpt-4o-mini", "openai", core.Model{ID: "gpt-4o-mini", Object: "model", OwnedBy: "openai", Metadata: &core.ModelMetadata{DisplayName: "GPT-4o mini"}}) + + service, err := NewService(newMemoryStore(Alias{Name: "gpt-4o", TargetModel: "gpt-4o-mini", Enabled: true}), catalog) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + inner := newProviderMock() + inner.supported["gpt-4o"] = true + inner.supported["gpt-4o-mini"] = true + inner.providerType["gpt-4o"] = "openai" + inner.providerType["gpt-4o-mini"] = "openai" + inner.modelsResp = &core.ModelsResponse{Object: "list", Data: []core.Model{ + {ID: "gpt-4o", Object: "model", Metadata: &core.ModelMetadata{DisplayName: "GPT-4o"}}, + {ID: "gpt-4o-mini", Object: "model", Metadata: &core.ModelMetadata{DisplayName: "GPT-4o mini"}}, + }} + + provider := NewProvider(inner, service) + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-4o"}); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if inner.chatReq == nil || inner.chatReq.Model != "gpt-4o-mini" { + t.Fatalf("masked chat request = %#v, want rewritten model gpt-4o-mini", inner.chatReq) + } + + models, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(models.Data) != 2 { + t.Fatalf("len(ListModels().Data) = %d, want 2", len(models.Data)) + } + if models.Data[0].ID != "gpt-4o" { + t.Fatalf("first model id = %q, want gpt-4o", models.Data[0].ID) + } + if models.Data[0].Metadata == nil || models.Data[0].Metadata.DisplayName != "GPT-4o mini" { + t.Fatalf("masked model metadata = %#v, want alias target metadata", models.Data[0].Metadata) + } +} + +func TestProviderRewritesBatchItemBodies(t *testing.T) { + catalog := newTestCatalog() + catalog.add("gpt-4o", "openai", core.Model{ID: "gpt-4o", Object: "model"}) + + service, err := NewService(newMemoryStore(Alias{Name: "smart", TargetModel: "gpt-4o", Enabled: true}), catalog) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + inner := newProviderMock() + provider := NewProvider(inner, service) + + body := json.RawMessage(`{"model":"smart","messages":[{"role":"user","content":"hi"}]}`) + _, err = provider.CreateBatch(context.Background(), "openai", &core.BatchRequest{ + Endpoint: "/v1/chat/completions", + Requests: []core.BatchRequestItem{{CustomID: "1", Body: body}}, + }) + if err != nil { + t.Fatalf("CreateBatch() error = %v", err) + } + if inner.batchReq == nil || len(inner.batchReq.Requests) != 1 { + t.Fatalf("captured batch request = %#v, want one request", inner.batchReq) + } + + var rewritten struct { + Model string `json:"model"` + } + if err := json.Unmarshal(inner.batchReq.Requests[0].Body, &rewritten); err != nil { + t.Fatalf("unmarshal rewritten batch item: %v", err) + } + if rewritten.Model != "gpt-4o" { + t.Fatalf("rewritten batch model = %q, want gpt-4o", rewritten.Model) + } +} diff --git a/internal/aliases/service.go b/internal/aliases/service.go new file mode 100644 index 00000000..b0df854f --- /dev/null +++ b/internal/aliases/service.go @@ -0,0 +1,289 @@ +package aliases + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "gomodel/internal/core" +) + +// Catalog exposes the concrete model catalog used to validate alias targets. +type Catalog interface { + Supports(model string) bool + GetProviderType(model string) string + LookupModel(model string) (*core.Model, bool) +} + +type snapshot struct { + aliases map[string]Alias + order []string +} + +// Service keeps aliases cached in memory and refreshes them from storage. +type Service struct { + store Store + catalog Catalog + + mu sync.RWMutex + snapshot snapshot +} + +// NewService creates an alias service backed by the provided store and catalog. +func NewService(store Store, catalog Catalog) (*Service, error) { + if store == nil { + return nil, fmt.Errorf("store is required") + } + if catalog == nil { + return nil, fmt.Errorf("catalog is required") + } + return &Service{store: store, catalog: catalog}, nil +} + +// Refresh reloads aliases from storage and atomically swaps the in-memory snapshot. +func (s *Service) Refresh(ctx context.Context) error { + aliases, err := s.store.List(ctx) + if err != nil { + return fmt.Errorf("list aliases: %w", err) + } + + next := snapshot{ + aliases: make(map[string]Alias, len(aliases)), + order: make([]string, 0, len(aliases)), + } + for _, alias := range aliases { + normalized, err := normalizeAlias(alias) + if err != nil { + return fmt.Errorf("load alias %q: %w", alias.Name, err) + } + next.aliases[normalized.Name] = normalized + next.order = append(next.order, normalized.Name) + } + sort.Strings(next.order) + + s.mu.Lock() + s.snapshot = next + s.mu.Unlock() + return nil +} + +// List returns all cached aliases sorted by name. +func (s *Service) List() []Alias { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]Alias, 0, len(s.snapshot.order)) + for _, name := range s.snapshot.order { + result = append(result, s.snapshot.aliases[name]) + } + return result +} + +// ListViews returns aliases with current validity derived from the concrete model catalog. +func (s *Service) ListViews() []View { + aliases := s.List() + views := make([]View, 0, len(aliases)) + for _, alias := range aliases { + view := View{Alias: alias} + selector, err := alias.TargetSelector() + if err == nil { + view.ResolvedModel = selector.QualifiedModel() + view.ProviderType = strings.TrimSpace(s.catalog.GetProviderType(view.ResolvedModel)) + view.Valid = s.catalog.Supports(view.ResolvedModel) + } + views = append(views, view) + } + return views +} + +// Get returns one cached alias by name. +func (s *Service) Get(name string) (*Alias, bool) { + name = normalizeName(name) + if name == "" { + return nil, false + } + + s.mu.RLock() + defer s.mu.RUnlock() + alias, ok := s.snapshot.aliases[name] + if !ok { + return nil, false + } + copy := alias + return ©, true +} + +// ResolveSelector resolves a selector through the alias table. +// Explicit provider selection bypasses aliases. +func (s *Service) ResolveSelector(selector core.ModelSelector) (Resolution, bool) { + resolution := Resolution{Requested: selector, Resolved: selector} + if strings.TrimSpace(selector.Provider) != "" { + return resolution, false + } + + alias, ok := s.Get(selector.Model) + if !ok || !alias.Enabled { + return resolution, false + } + + target, err := alias.TargetSelector() + if err != nil { + return resolution, false + } + if !s.catalog.Supports(target.QualifiedModel()) { + return resolution, false + } + + resolution.Resolved = target + resolution.Alias = alias + return resolution, true +} + +// Resolve resolves raw model/provider inputs through the alias table. +func (s *Service) Resolve(model, provider string) (Resolution, bool, error) { + selector, err := core.ParseModelSelector(model, provider) + if err != nil { + return Resolution{}, false, err + } + resolution, ok := s.ResolveSelector(selector) + return resolution, ok, nil +} + +// Supports reports whether an alias currently resolves to a concrete model. +func (s *Service) Supports(model string) bool { + selector, err := core.ParseModelSelector(model, "") + if err != nil { + return false + } + _, ok := s.ResolveSelector(selector) + return ok +} + +// GetProviderType returns the resolved provider type for an alias, or empty if unresolved. +func (s *Service) GetProviderType(model string) string { + selector, err := core.ParseModelSelector(model, "") + if err != nil { + return "" + } + resolution, ok := s.ResolveSelector(selector) + if !ok { + return "" + } + return strings.TrimSpace(s.catalog.GetProviderType(resolution.Resolved.QualifiedModel())) +} + +// ExposedModels returns enabled aliases projected as model-list entries. +func (s *Service) ExposedModels() []core.Model { + aliases := s.List() + result := make([]core.Model, 0, len(aliases)) + for _, alias := range aliases { + if !alias.Enabled { + continue + } + selector, err := alias.TargetSelector() + if err != nil { + continue + } + model, ok := s.catalog.LookupModel(selector.QualifiedModel()) + if !ok || model == nil { + continue + } + cloned := *model + cloned.ID = alias.Name + result = append(result, cloned) + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +// Upsert validates and stores an alias, then refreshes the in-memory snapshot. +func (s *Service) Upsert(ctx context.Context, alias Alias) error { + normalized, err := normalizeAlias(alias) + if err != nil { + return err + } + if err := s.validate(normalized); err != nil { + return err + } + if err := s.store.Upsert(ctx, normalized); err != nil { + return fmt.Errorf("upsert alias: %w", err) + } + if err := s.Refresh(ctx); err != nil { + return fmt.Errorf("refresh aliases: %w", err) + } + return nil +} + +// Delete removes an alias from storage and refreshes the in-memory snapshot. +func (s *Service) Delete(ctx context.Context, name string) error { + name = normalizeName(name) + if name == "" { + return fmt.Errorf("alias name is required") + } + if err := s.store.Delete(ctx, name); err != nil { + return fmt.Errorf("delete alias: %w", err) + } + if err := s.Refresh(ctx); err != nil { + return fmt.Errorf("refresh aliases: %w", err) + } + return nil +} + +func (s *Service) validate(alias Alias) error { + target, err := alias.TargetSelector() + if err != nil { + return fmt.Errorf("invalid target selector: %w", err) + } + if alias.Name == target.Model && target.Provider == "" { + return fmt.Errorf("alias %q cannot target itself", alias.Name) + } + + s.mu.RLock() + defer s.mu.RUnlock() + if target.Provider == "" { + if existing, ok := s.snapshot.aliases[target.Model]; ok && existing.Name != alias.Name { + return fmt.Errorf("alias target %q refers to another alias", target.Model) + } + } + if !s.catalog.Supports(target.QualifiedModel()) { + return fmt.Errorf("target model not found: %s", target.QualifiedModel()) + } + return nil +} + +// StartBackgroundRefresh periodically reloads aliases from storage until stopped. +func (s *Service) StartBackgroundRefresh(interval time.Duration) func() { + if interval <= 0 { + interval = time.Hour + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + var once sync.Once + + go func() { + defer close(done) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + refreshCtx, refreshCancel := context.WithTimeout(ctx, 30*time.Second) + _ = s.Refresh(refreshCtx) + refreshCancel() + } + } + }() + + return func() { + once.Do(func() { + cancel() + <-done + }) + } +} diff --git a/internal/aliases/service_test.go b/internal/aliases/service_test.go new file mode 100644 index 00000000..f4401f4e --- /dev/null +++ b/internal/aliases/service_test.go @@ -0,0 +1,164 @@ +package aliases + +import ( + "context" + "strings" + "testing" + + "gomodel/internal/core" +) + +type memoryStore struct { + items map[string]Alias +} + +func newMemoryStore(items ...Alias) *memoryStore { + store := &memoryStore{items: make(map[string]Alias, len(items))} + for _, item := range items { + store.items[item.Name] = item + } + return store +} + +func (s *memoryStore) List(_ context.Context) ([]Alias, error) { + result := make([]Alias, 0, len(s.items)) + for _, item := range s.items { + result = append(result, item) + } + return result, nil +} + +func (s *memoryStore) Get(_ context.Context, name string) (*Alias, error) { + item, ok := s.items[name] + if !ok { + return nil, ErrNotFound + } + copy := item + return ©, nil +} + +func (s *memoryStore) Upsert(_ context.Context, alias Alias) error { + s.items[alias.Name] = alias + return nil +} + +func (s *memoryStore) Delete(_ context.Context, name string) error { + if _, ok := s.items[name]; !ok { + return ErrNotFound + } + delete(s.items, name) + return nil +} + +func (s *memoryStore) Close() error { return nil } + +type testCatalog struct { + providerTypes map[string]string + models map[string]core.Model +} + +func newTestCatalog() *testCatalog { + return &testCatalog{ + providerTypes: map[string]string{}, + models: map[string]core.Model{}, + } +} + +func (c *testCatalog) add(model string, providerType string, value core.Model) { + c.providerTypes[model] = providerType + c.models[model] = value +} + +func (c *testCatalog) Supports(model string) bool { + _, ok := c.models[model] + return ok +} + +func (c *testCatalog) GetProviderType(model string) string { + return c.providerTypes[model] +} + +func (c *testCatalog) LookupModel(model string) (*core.Model, bool) { + value, ok := c.models[model] + if !ok { + return nil, false + } + copy := value + return ©, true +} + +func TestServiceResolveAndExposeModels(t *testing.T) { + catalog := newTestCatalog() + catalog.add("gpt-4o", "openai", core.Model{ID: "gpt-4o", Object: "model", OwnedBy: "openai", Metadata: &core.ModelMetadata{DisplayName: "GPT-4o"}}) + + service, err := NewService(newMemoryStore(Alias{Name: "smart", TargetModel: "gpt-4o", Enabled: true}), catalog) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + resolution, ok, err := service.Resolve("smart", "") + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if !ok { + t.Fatal("Resolve() ok = false, want true") + } + if got := resolution.Resolved.QualifiedModel(); got != "gpt-4o" { + t.Fatalf("resolved selector = %q, want gpt-4o", got) + } + if !service.Supports("smart") { + t.Fatal("Supports(smart) = false, want true") + } + if got := service.GetProviderType("smart"); got != "openai" { + t.Fatalf("GetProviderType(smart) = %q, want openai", got) + } + + models := service.ExposedModels() + if len(models) != 1 { + t.Fatalf("len(ExposedModels()) = %d, want 1", len(models)) + } + if models[0].ID != "smart" { + t.Fatalf("alias model id = %q, want smart", models[0].ID) + } + if models[0].Metadata == nil || models[0].Metadata.DisplayName != "GPT-4o" { + t.Fatalf("alias metadata = %#v, want copied target metadata", models[0].Metadata) + } +} + +func TestServiceUpsertRejectsAliasChainAndAllowsMasking(t *testing.T) { + catalog := newTestCatalog() + catalog.add("gpt-4o", "openai", core.Model{ID: "gpt-4o", Object: "model"}) + catalog.add("gpt-4o-mini", "openai", core.Model{ID: "gpt-4o-mini", Object: "model"}) + + service, err := NewService(newMemoryStore(Alias{Name: "front", TargetModel: "gpt-4o", Enabled: true}), catalog) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + err = service.Upsert(context.Background(), Alias{Name: "second", TargetModel: "front", Enabled: true}) + if err == nil || !strings.Contains(err.Error(), "refers to another alias") { + t.Fatalf("Upsert(alias chain) error = %v, want alias-chain validation error", err) + } + + err = service.Upsert(context.Background(), Alias{Name: "gpt-4o", TargetModel: "gpt-4o-mini", Enabled: true}) + if err != nil { + t.Fatalf("Upsert(masking alias) error = %v, want nil", err) + } + + resolution, ok, err := service.Resolve("gpt-4o", "") + if err != nil { + t.Fatalf("Resolve(masked model) error = %v", err) + } + if !ok { + t.Fatal("Resolve(masked model) ok = false, want true") + } + if got := resolution.Resolved.QualifiedModel(); got != "gpt-4o-mini" { + t.Fatalf("masked model resolved to %q, want gpt-4o-mini", got) + } +} diff --git a/internal/aliases/store.go b/internal/aliases/store.go new file mode 100644 index 00000000..68349cc1 --- /dev/null +++ b/internal/aliases/store.go @@ -0,0 +1,70 @@ +package aliases + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// ErrNotFound indicates a requested alias was not found. +var ErrNotFound = errors.New("alias not found") + +// Store defines persistence operations for aliases. +type Store interface { + List(ctx context.Context) ([]Alias, error) + Get(ctx context.Context, name string) (*Alias, error) + Upsert(ctx context.Context, alias Alias) error + Delete(ctx context.Context, name string) error + Close() error +} + +type aliasScanner interface { + Scan(dest ...any) error +} + +type aliasRows interface { + aliasScanner + Next() bool + Err() error +} + +func normalizeName(name string) string { + return strings.TrimSpace(name) +} + +func normalizeAlias(alias Alias) (Alias, error) { + alias.Name = normalizeName(alias.Name) + alias.TargetModel = strings.TrimSpace(alias.TargetModel) + alias.TargetProvider = strings.TrimSpace(alias.TargetProvider) + alias.Description = strings.TrimSpace(alias.Description) + + if alias.Name == "" { + return Alias{}, fmt.Errorf("alias name is required") + } + if strings.Contains(alias.Name, "/") { + return Alias{}, fmt.Errorf("alias name %q must be unqualified", alias.Name) + } + if alias.TargetModel == "" { + return Alias{}, fmt.Errorf("target_model is required") + } + if _, err := alias.TargetSelector(); err != nil { + return Alias{}, fmt.Errorf("invalid target selector: %w", err) + } + return alias, nil +} + +func collectAliases(rows aliasRows, scan func(aliasScanner) (Alias, error)) ([]Alias, error) { + result := make([]Alias, 0) + for rows.Next() { + alias, err := scan(rows) + if err != nil { + return nil, err + } + result = append(result, alias) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} diff --git a/internal/aliases/store_mongodb.go b/internal/aliases/store_mongodb.go new file mode 100644 index 00000000..00db5f71 --- /dev/null +++ b/internal/aliases/store_mongodb.go @@ -0,0 +1,137 @@ +package aliases + +import ( + "context" + "errors" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type mongoAliasDocument struct { + Name string `bson:"_id"` + TargetModel string `bson:"target_model"` + TargetProvider string `bson:"target_provider,omitempty"` + Description string `bson:"description,omitempty"` + Enabled bool `bson:"enabled"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` +} + +// MongoDBStore stores aliases in MongoDB. +type MongoDBStore struct { + collection *mongo.Collection +} + +// NewMongoDBStore creates collection indexes if needed. +func NewMongoDBStore(database *mongo.Database) (*MongoDBStore, error) { + if database == nil { + return nil, fmt.Errorf("database is required") + } + coll := database.Collection("aliases") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + indexes := []mongo.IndexModel{ + {Keys: bson.D{{Key: "enabled", Value: 1}}}, + {Keys: bson.D{{Key: "updated_at", Value: -1}}}, + } + if _, err := coll.Indexes().CreateMany(ctx, indexes); err != nil { + return nil, fmt.Errorf("create aliases indexes: %w", err) + } + return &MongoDBStore{collection: coll}, nil +} + +func (s *MongoDBStore) List(ctx context.Context) ([]Alias, error) { + cursor, err := s.collection.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "_id", Value: 1}})) + if err != nil { + return nil, fmt.Errorf("list aliases: %w", err) + } + defer cursor.Close(ctx) + + result := make([]Alias, 0) + for cursor.Next(ctx) { + var doc mongoAliasDocument + if err := cursor.Decode(&doc); err != nil { + return nil, fmt.Errorf("decode alias: %w", err) + } + result = append(result, aliasFromMongo(doc)) + } + if err := cursor.Err(); err != nil { + return nil, fmt.Errorf("iterate aliases: %w", err) + } + return result, nil +} + +func (s *MongoDBStore) Get(ctx context.Context, name string) (*Alias, error) { + var doc mongoAliasDocument + err := s.collection.FindOne(ctx, bson.M{"_id": normalizeName(name)}).Decode(&doc) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get alias: %w", err) + } + alias := aliasFromMongo(doc) + return &alias, nil +} + +func (s *MongoDBStore) Upsert(ctx context.Context, alias Alias) error { + alias, err := normalizeAlias(alias) + if err != nil { + return err + } + now := time.Now().UTC() + if alias.CreatedAt.IsZero() { + alias.CreatedAt = now + } + alias.UpdatedAt = now + + update := bson.M{ + "$set": bson.M{ + "target_model": alias.TargetModel, + "target_provider": alias.TargetProvider, + "description": alias.Description, + "enabled": alias.Enabled, + "updated_at": alias.UpdatedAt, + }, + "$setOnInsert": bson.M{ + "created_at": alias.CreatedAt, + }, + } + _, err = s.collection.UpdateOne(ctx, bson.M{"_id": alias.Name}, update, options.UpdateOne().SetUpsert(true)) + if err != nil { + return fmt.Errorf("upsert alias: %w", err) + } + return nil +} + +func (s *MongoDBStore) Delete(ctx context.Context, name string) error { + result, err := s.collection.DeleteOne(ctx, bson.M{"_id": normalizeName(name)}) + if err != nil { + return fmt.Errorf("delete alias: %w", err) + } + if result.DeletedCount == 0 { + return ErrNotFound + } + return nil +} + +func (s *MongoDBStore) Close() error { + return nil +} + +func aliasFromMongo(doc mongoAliasDocument) Alias { + return Alias{ + Name: doc.Name, + TargetModel: doc.TargetModel, + TargetProvider: doc.TargetProvider, + Description: doc.Description, + Enabled: doc.Enabled, + CreatedAt: doc.CreatedAt.UTC(), + UpdatedAt: doc.UpdatedAt.UTC(), + } +} diff --git a/internal/aliases/store_postgresql.go b/internal/aliases/store_postgresql.go new file mode 100644 index 00000000..309fcc18 --- /dev/null +++ b/internal/aliases/store_postgresql.go @@ -0,0 +1,143 @@ +package aliases + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PostgreSQLStore stores aliases in PostgreSQL. +type PostgreSQLStore struct { + pool *pgxpool.Pool +} + +// NewPostgreSQLStore creates the aliases table and indexes if needed. +func NewPostgreSQLStore(ctx context.Context, pool *pgxpool.Pool) (*PostgreSQLStore, error) { + if ctx == nil { + return nil, fmt.Errorf("context is required") + } + if pool == nil { + return nil, fmt.Errorf("connection pool is required") + } + + _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS aliases ( + name TEXT PRIMARY KEY, + target_model TEXT NOT NULL, + target_provider TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL + ) + `) + if err != nil { + return nil, fmt.Errorf("failed to create aliases table: %w", err) + } + if _, err := pool.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_aliases_enabled ON aliases(enabled)`); err != nil { + return nil, fmt.Errorf("failed to create aliases enabled index: %w", err) + } + if _, err := pool.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_aliases_updated_at ON aliases(updated_at DESC)`); err != nil { + return nil, fmt.Errorf("failed to create aliases updated_at index: %w", err) + } + return &PostgreSQLStore{pool: pool}, nil +} + +func (s *PostgreSQLStore) List(ctx context.Context) ([]Alias, error) { + rows, err := s.pool.Query(ctx, ` + SELECT name, target_model, target_provider, description, enabled, created_at, updated_at + FROM aliases + ORDER BY name ASC + `) + if err != nil { + return nil, fmt.Errorf("list aliases: %w", err) + } + defer rows.Close() + result, err := collectAliases(rows, scanPostgreSQLAlias) + if err != nil { + return nil, fmt.Errorf("iterate aliases: %w", err) + } + return result, nil +} + +func (s *PostgreSQLStore) Get(ctx context.Context, name string) (*Alias, error) { + row := s.pool.QueryRow(ctx, ` + SELECT name, target_model, target_provider, description, enabled, created_at, updated_at + FROM aliases + WHERE name = $1 + `, normalizeName(name)) + alias, err := scanPostgreSQLAlias(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &alias, nil +} + +func (s *PostgreSQLStore) Upsert(ctx context.Context, alias Alias) error { + alias, err := normalizeAlias(alias) + if err != nil { + return err + } + now := time.Now().UTC().Unix() + if alias.CreatedAt.IsZero() { + alias.CreatedAt = time.Unix(now, 0).UTC() + } + alias.UpdatedAt = time.Unix(now, 0).UTC() + + _, err = s.pool.Exec(ctx, ` + INSERT INTO aliases (name, target_model, target_provider, description, enabled, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT(name) DO UPDATE SET + target_model = excluded.target_model, + target_provider = excluded.target_provider, + description = excluded.description, + enabled = excluded.enabled, + updated_at = excluded.updated_at + `, alias.Name, alias.TargetModel, alias.TargetProvider, alias.Description, alias.Enabled, alias.CreatedAt.Unix(), alias.UpdatedAt.Unix()) + if err != nil { + return fmt.Errorf("upsert alias: %w", err) + } + return nil +} + +func (s *PostgreSQLStore) Delete(ctx context.Context, name string) error { + cmd, err := s.pool.Exec(ctx, `DELETE FROM aliases WHERE name = $1`, normalizeName(name)) + if err != nil { + return fmt.Errorf("delete alias: %w", err) + } + if cmd.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *PostgreSQLStore) Close() error { + return nil +} + +func scanPostgreSQLAlias(scanner aliasScanner) (Alias, error) { + var alias Alias + var createdAt int64 + var updatedAt int64 + if err := scanner.Scan( + &alias.Name, + &alias.TargetModel, + &alias.TargetProvider, + &alias.Description, + &alias.Enabled, + &createdAt, + &updatedAt, + ); err != nil { + return Alias{}, err + } + alias.CreatedAt = time.Unix(createdAt, 0).UTC() + alias.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return alias, nil +} diff --git a/internal/aliases/store_sqlite.go b/internal/aliases/store_sqlite.go new file mode 100644 index 00000000..dd9f8f7d --- /dev/null +++ b/internal/aliases/store_sqlite.go @@ -0,0 +1,151 @@ +package aliases + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// SQLiteStore stores aliases in SQLite. +type SQLiteStore struct { + db *sql.DB +} + +// NewSQLiteStore creates the aliases table and indexes if needed. +func NewSQLiteStore(db *sql.DB) (*SQLiteStore, error) { + if db == nil { + return nil, fmt.Errorf("database connection is required") + } + + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS aliases ( + name TEXT PRIMARY KEY, + target_model TEXT NOT NULL, + target_provider TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + if err != nil { + return nil, fmt.Errorf("failed to create aliases table: %w", err) + } + if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_aliases_enabled ON aliases(enabled)`); err != nil { + return nil, fmt.Errorf("failed to create aliases enabled index: %w", err) + } + if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_aliases_updated_at ON aliases(updated_at DESC)`); err != nil { + return nil, fmt.Errorf("failed to create aliases updated_at index: %w", err) + } + return &SQLiteStore{db: db}, nil +} + +func (s *SQLiteStore) List(ctx context.Context) ([]Alias, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT name, target_model, target_provider, description, enabled, created_at, updated_at + FROM aliases + ORDER BY name ASC + `) + if err != nil { + return nil, fmt.Errorf("list aliases: %w", err) + } + defer rows.Close() + result, err := collectAliases(rows, scanSQLiteAlias) + if err != nil { + return nil, fmt.Errorf("iterate aliases: %w", err) + } + return result, nil +} + +func (s *SQLiteStore) Get(ctx context.Context, name string) (*Alias, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT name, target_model, target_provider, description, enabled, created_at, updated_at + FROM aliases + WHERE name = ? + `, normalizeName(name)) + alias, err := scanSQLiteAlias(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &alias, nil +} + +func (s *SQLiteStore) Upsert(ctx context.Context, alias Alias) error { + alias, err := normalizeAlias(alias) + if err != nil { + return err + } + now := time.Now().UTC().Unix() + if alias.CreatedAt.IsZero() { + alias.CreatedAt = time.Unix(now, 0).UTC() + } + alias.UpdatedAt = time.Unix(now, 0).UTC() + + _, err = s.db.ExecContext(ctx, ` + INSERT INTO aliases (name, target_model, target_provider, description, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + target_model = excluded.target_model, + target_provider = excluded.target_provider, + description = excluded.description, + enabled = excluded.enabled, + updated_at = excluded.updated_at + `, alias.Name, alias.TargetModel, alias.TargetProvider, alias.Description, boolToSQLite(alias.Enabled), alias.CreatedAt.Unix(), alias.UpdatedAt.Unix()) + if err != nil { + return fmt.Errorf("upsert alias: %w", err) + } + return nil +} + +func (s *SQLiteStore) Delete(ctx context.Context, name string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM aliases WHERE name = ?`, normalizeName(name)) + if err != nil { + return fmt.Errorf("delete alias: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read delete rows affected: %w", err) + } + if affected == 0 { + return ErrNotFound + } + return nil +} + +func (s *SQLiteStore) Close() error { + return nil +} + +func scanSQLiteAlias(scanner aliasScanner) (Alias, error) { + var alias Alias + var enabled int + var createdAt int64 + var updatedAt int64 + if err := scanner.Scan( + &alias.Name, + &alias.TargetModel, + &alias.TargetProvider, + &alias.Description, + &enabled, + &createdAt, + &updatedAt, + ); err != nil { + return Alias{}, err + } + alias.Enabled = enabled != 0 + alias.CreatedAt = time.Unix(createdAt, 0).UTC() + alias.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return alias, nil +} + +func boolToSQLite(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/internal/aliases/types.go b/internal/aliases/types.go new file mode 100644 index 00000000..180add10 --- /dev/null +++ b/internal/aliases/types.go @@ -0,0 +1,38 @@ +package aliases + +import ( + "time" + + "gomodel/internal/core" +) + +// Alias maps a gateway-visible alias name to a concrete model selector. +type Alias struct { + Name string `json:"name" bson:"name"` + TargetModel string `json:"target_model" bson:"target_model"` + TargetProvider string `json:"target_provider,omitempty" bson:"target_provider,omitempty"` + Description string `json:"description,omitempty" bson:"description,omitempty"` + Enabled bool `json:"enabled" bson:"enabled"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` + UpdatedAt time.Time `json:"updated_at" bson:"updated_at"` +} + +// TargetSelector returns the concrete selector this alias points to. +func (a Alias) TargetSelector() (core.ModelSelector, error) { + return core.ParseModelSelector(a.TargetModel, a.TargetProvider) +} + +// Resolution captures the requested selector and the concrete selector chosen after alias resolution. +type Resolution struct { + Requested core.ModelSelector `json:"requested"` + Resolved core.ModelSelector `json:"resolved"` + Alias *Alias `json:"alias,omitempty"` +} + +// View is the admin-facing representation of an alias with current validity status. +type View struct { + Alias + ResolvedModel string `json:"resolved_model"` + ProviderType string `json:"provider_type,omitempty"` + Valid bool `json:"valid"` +} diff --git a/internal/app/app.go b/internal/app/app.go index 16c9aa39..6f90453f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,6 +13,7 @@ import ( "gomodel/config" "gomodel/internal/admin" "gomodel/internal/admin/dashboard" + "gomodel/internal/aliases" "gomodel/internal/auditlog" "gomodel/internal/batch" "gomodel/internal/core" @@ -32,6 +33,7 @@ type App struct { audit *auditlog.Result usage *usage.Result batch *batch.Result + aliases *aliases.Result server *server.Server shutdownMu sync.Mutex @@ -126,6 +128,26 @@ func New(ctx context.Context, cfg Config) (*App, error) { } app.batch = batchResult + // Initialize aliases using shared storage when already available. + var aliasResult *aliases.Result + if auditResult.Storage != nil { + aliasResult, err = aliases.NewWithSharedStorage(ctx, appCfg, auditResult.Storage, providerResult.Registry) + } else if usageResult.Storage != nil { + aliasResult, err = aliases.NewWithSharedStorage(ctx, appCfg, usageResult.Storage, providerResult.Registry) + } else if batchResult.Storage != nil { + aliasResult, err = aliases.NewWithSharedStorage(ctx, appCfg, batchResult.Storage, providerResult.Registry) + } else { + aliasResult, err = aliases.New(ctx, appCfg, providerResult.Registry) + } + if err != nil { + closeErr := errors.Join(app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) + if closeErr != nil { + return nil, fmt.Errorf("failed to initialize aliases: %w (also: close error: %v)", err, closeErr) + } + return nil, fmt.Errorf("failed to initialize aliases: %w", err) + } + app.aliases = aliasResult + // Log configuration status app.logStartupInfo() @@ -134,11 +156,17 @@ func New(ctx context.Context, cfg Config) (*App, error) { if appCfg.Guardrails.Enabled { pipeline, err := buildGuardrailsPipeline(appCfg.Guardrails) if err != nil { - var batchCloseErr error + var ( + aliasCloseErr error + batchCloseErr error + ) + if app.aliases != nil { + aliasCloseErr = app.aliases.Close() + } if app.batch != nil { batchCloseErr = app.batch.Close() } - closeErr := errors.Join(batchCloseErr, app.usage.Close(), app.audit.Close(), app.providers.Close()) + closeErr := errors.Join(aliasCloseErr, batchCloseErr, app.usage.Close(), app.audit.Close(), app.providers.Close()) if closeErr != nil { return nil, fmt.Errorf("failed to build guardrails: %w (also: close error: %v)", err, closeErr) } @@ -155,23 +183,26 @@ func New(ctx context.Context, cfg Config) (*App, error) { ) } } + if app.aliases != nil && app.aliases.Service != nil { + provider = aliases.NewProvider(provider, app.aliases.Service) + } // Create server allowPassthroughV1Alias := appCfg.Server.AllowPassthroughV1Alias serverCfg := &server.Config{ - MasterKey: appCfg.Server.MasterKey, - MetricsEnabled: appCfg.Metrics.Enabled, - MetricsEndpoint: appCfg.Metrics.Endpoint, - BodySizeLimit: appCfg.Server.BodySizeLimit, - AuditLogger: auditResult.Logger, - UsageLogger: usageResult.Logger, - PricingResolver: providerResult.Registry, - BatchStore: batchResult.Store, - LogOnlyModelInteractions: appCfg.Logging.OnlyModelInteractions, - DisablePassthroughRoutes: !appCfg.Server.EnablePassthroughRoutes, - EnabledPassthroughProviders: appCfg.Server.EnabledPassthroughProviders, - AllowPassthroughV1Alias: &allowPassthroughV1Alias, - SwaggerEnabled: appCfg.Server.SwaggerEnabled, + MasterKey: appCfg.Server.MasterKey, + MetricsEnabled: appCfg.Metrics.Enabled, + MetricsEndpoint: appCfg.Metrics.Endpoint, + BodySizeLimit: appCfg.Server.BodySizeLimit, + AuditLogger: auditResult.Logger, + UsageLogger: usageResult.Logger, + PricingResolver: providerResult.Registry, + BatchStore: batchResult.Store, + LogOnlyModelInteractions: appCfg.Logging.OnlyModelInteractions, + DisablePassthroughRoutes: !appCfg.Server.EnablePassthroughRoutes, + EnabledPassthroughProviders: appCfg.Server.EnabledPassthroughProviders, + AllowPassthroughV1Alias: &allowPassthroughV1Alias, + SwaggerEnabled: appCfg.Server.SwaggerEnabled, } // Initialize admin API and dashboard (behind separate feature flags) @@ -181,7 +212,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { adminCfg.UIEnabled = false } if adminCfg.EndpointsEnabled { - adminHandler, dashHandler, adminErr := initAdmin(auditResult.Storage, usageResult.Storage, providerResult.Registry, adminCfg.UIEnabled) + adminHandler, dashHandler, adminErr := initAdmin(auditResult.Storage, usageResult.Storage, providerResult.Registry, app.aliases.Service, adminCfg.UIEnabled) if adminErr != nil { slog.Warn("failed to initialize admin", "error", adminErr) } else { @@ -209,11 +240,17 @@ func New(ctx context.Context, cfg Config) (*App, error) { rcm, err := responsecache.NewResponseCacheMiddleware(appCfg.Cache.Response) if err != nil { - var batchCloseErr error + var ( + aliasCloseErr error + batchCloseErr error + ) + if app.aliases != nil { + aliasCloseErr = app.aliases.Close() + } if app.batch != nil { batchCloseErr = app.batch.Close() } - closeErr := errors.Join(batchCloseErr, app.usage.Close(), app.audit.Close(), app.providers.Close()) + closeErr := errors.Join(aliasCloseErr, batchCloseErr, app.usage.Close(), app.audit.Close(), app.providers.Close()) if closeErr != nil { return nil, fmt.Errorf("failed to initialize response cache: %w (also: close error: %v)", err, closeErr) } @@ -346,7 +383,15 @@ func (a *App) Shutdown(ctx context.Context) error { } } - // 3. Close batch store (flushes pending entries) + // 3. Close aliases subsystem. + if a.aliases != nil { + if err := a.aliases.Close(); err != nil { + slog.Error("aliases close error", "error", err) + errs = append(errs, fmt.Errorf("aliases close: %w", err)) + } + } + + // 4. Close batch store (flushes pending entries) if a.batch != nil { if err := a.batch.Close(); err != nil { slog.Error("batch store close error", "error", err) @@ -354,7 +399,7 @@ func (a *App) Shutdown(ctx context.Context) error { } } - // 4. Close usage tracking (flushes pending entries) + // 5. Close usage tracking (flushes pending entries) if a.usage != nil { if err := a.usage.Close(); err != nil { slog.Error("usage logger close error", "error", err) @@ -362,7 +407,7 @@ func (a *App) Shutdown(ctx context.Context) error { } } - // 5. Close audit logging (flushes pending logs) + // 6. Close audit logging (flushes pending logs) if a.audit != nil { if err := a.audit.Close(); err != nil { slog.Error("audit logger close error", "error", err) @@ -427,7 +472,7 @@ func (a *App) logStartupInfo() { // initAdmin creates the admin API handler and optionally the dashboard handler. // Returns nil dashboard handler if uiEnabled is false. -func initAdmin(auditStorage, usageStorage storage.Storage, registry *providers.ModelRegistry, uiEnabled bool) (*admin.Handler, *dashboard.Handler, error) { +func initAdmin(auditStorage, usageStorage storage.Storage, registry *providers.ModelRegistry, aliasService *aliases.Service, uiEnabled bool) (*admin.Handler, *dashboard.Handler, error) { // Find a storage connection for reading usage data var store storage.Storage if auditStorage != nil { @@ -457,7 +502,7 @@ func initAdmin(auditStorage, usageStorage storage.Storage, registry *providers.M } } - adminHandler := admin.NewHandler(reader, registry, admin.WithAuditReader(auditReader)) + adminHandler := admin.NewHandler(reader, registry, admin.WithAuditReader(auditReader), admin.WithAliases(aliasService)) var dashHandler *dashboard.Handler if uiEnabled { diff --git a/internal/providers/registry.go b/internal/providers/registry.go index 193421c6..57286943 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -467,6 +467,17 @@ func (r *ModelRegistry) GetModel(model string) *ModelInfo { return nil } +// LookupModel returns a defensive copy of the concrete model for the given selector. +// Qualified selectors use the configured provider name prefix when present. +func (r *ModelRegistry) LookupModel(model string) (*core.Model, bool) { + info := r.GetModel(model) + if info == nil { + return nil, false + } + cloned := info.Model + return &cloned, true +} + // Supports returns true if the registry has a provider for the given model func (r *ModelRegistry) Supports(model string) bool { r.mu.RLock() diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 8cb12bd9..ca58618c 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -32,25 +32,29 @@ var defaultEnabledPassthroughProviders = []string{"openai", "anthropic"} // Handler holds the HTTP handlers type Handler struct { - provider core.RoutableProvider - logger auditlog.LoggerInterface - usageLogger usage.LoggerInterface - pricingResolver usage.PricingResolver - batchStore batchstore.Store - normalizePassthroughV1Prefix bool - enabledPassthroughProviders map[string]struct{} + provider core.RoutableProvider + logger auditlog.LoggerInterface + usageLogger usage.LoggerInterface + pricingResolver usage.PricingResolver + batchStore batchstore.Store + normalizePassthroughV1Prefix bool + enabledPassthroughProviders map[string]struct{} +} + +type resolvedModelProvider interface { + ResolveModel(model, provider string) (core.ModelSelector, bool, error) } // NewHandler creates a new handler with the given routable provider (typically the Router) func NewHandler(provider core.RoutableProvider, logger auditlog.LoggerInterface, usageLogger usage.LoggerInterface, pricingResolver usage.PricingResolver) *Handler { return &Handler{ - provider: provider, - logger: logger, - usageLogger: usageLogger, - pricingResolver: pricingResolver, - batchStore: batchstore.NewMemoryStore(), - normalizePassthroughV1Prefix: true, - enabledPassthroughProviders: normalizeEnabledPassthroughProviders(defaultEnabledPassthroughProviders), + provider: provider, + logger: logger, + usageLogger: usageLogger, + pricingResolver: pricingResolver, + batchStore: batchstore.NewMemoryStore(), + normalizePassthroughV1Prefix: true, + enabledPassthroughProviders: normalizeEnabledPassthroughProviders(defaultEnabledPassthroughProviders), } } @@ -195,6 +199,19 @@ func requestContextWithRequestID(req *http.Request) (context.Context, string) { return ctx, requestID } +func (h *Handler) streamingUsageModel(model, provider string) (string, error) { + if resolver, ok := h.provider.(resolvedModelProvider); ok { + selector, _, err := resolver.ResolveModel(model, provider) + if err != nil { + return "", err + } + if selector.Model != "" { + return selector.Model, nil + } + } + return model, nil +} + func sanitizePublicBatchMetadata(metadata map[string]string) map[string]string { if len(metadata) == 0 { return nil @@ -584,7 +601,11 @@ func (h *Handler) ChatCompletion(c *echo.Context) error { } streamReq.StreamOptions.IncludeUsage = true } - return h.handleStreamingResponse(c, streamReq.Model, providerType, func() (io.ReadCloser, error) { + usageModel, err := h.streamingUsageModel(streamReq.Model, streamReq.Provider) + if err != nil { + return handleError(c, err) + } + return h.handleStreamingResponse(c, usageModel, providerType, func() (io.ReadCloser, error) { return h.provider.StreamChatCompletion(ctx, streamReq) }) } @@ -1061,7 +1082,11 @@ func (h *Handler) Responses(c *echo.Context) error { if h.shouldEnforceReturningUsageData() { ctx = core.WithEnforceReturningUsageData(ctx, true) } - return h.handleStreamingResponse(c, req.Model, providerType, func() (io.ReadCloser, error) { + usageModel, err := h.streamingUsageModel(req.Model, req.Provider) + if err != nil { + return handleError(c, err) + } + return h.handleStreamingResponse(c, usageModel, providerType, func() (io.ReadCloser, error) { return h.provider.StreamResponses(ctx, req) }) } diff --git a/internal/server/http.go b/internal/server/http.go index f45617ec..6c19840c 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -32,24 +32,24 @@ type Server struct { // Config holds server configuration options type Config struct { - MasterKey string // Optional: Master key for authentication - MetricsEnabled bool // Whether to expose Prometheus metrics endpoint - MetricsEndpoint string // HTTP path for metrics endpoint (default: /metrics) - BodySizeLimit string // Max request body size (e.g., "10M", "1024K") - AuditLogger auditlog.LoggerInterface // Optional: Audit logger for request/response logging - UsageLogger usage.LoggerInterface // Optional: Usage logger for token tracking - PricingResolver usage.PricingResolver // Optional: Resolves pricing for cost calculation - BatchStore batchstore.Store // Optional: Batch lifecycle persistence store - LogOnlyModelInteractions bool // Only log AI model endpoints (default: true) - DisablePassthroughRoutes bool // Disable /p/{provider}/{endpoint} route registration - EnabledPassthroughProviders []string // Provider types enabled on /p/{provider}/... passthrough routes - AllowPassthroughV1Alias *bool // Allow /p/{provider}/v1/... aliases; nil defaults to true - AdminEndpointsEnabled bool // Whether admin API endpoints are enabled - AdminUIEnabled bool // Whether admin dashboard UI is enabled - AdminHandler *admin.Handler // Admin API handler (nil if disabled) - DashboardHandler *dashboard.Handler // Dashboard UI handler (nil if disabled) - SwaggerEnabled bool // Whether to expose the Swagger UI at /swagger/index.html - ResponseCacheMiddleware *responsecache.ResponseCacheMiddleware // Optional: response cache middleware for cacheable endpoints + MasterKey string // Optional: Master key for authentication + MetricsEnabled bool // Whether to expose Prometheus metrics endpoint + MetricsEndpoint string // HTTP path for metrics endpoint (default: /metrics) + BodySizeLimit string // Max request body size (e.g., "10M", "1024K") + AuditLogger auditlog.LoggerInterface // Optional: Audit logger for request/response logging + UsageLogger usage.LoggerInterface // Optional: Usage logger for token tracking + PricingResolver usage.PricingResolver // Optional: Resolves pricing for cost calculation + BatchStore batchstore.Store // Optional: Batch lifecycle persistence store + LogOnlyModelInteractions bool // Only log AI model endpoints (default: true) + DisablePassthroughRoutes bool // Disable /p/{provider}/{endpoint} route registration + EnabledPassthroughProviders []string // Provider types enabled on /p/{provider}/... passthrough routes + AllowPassthroughV1Alias *bool // Allow /p/{provider}/v1/... aliases; nil defaults to true + AdminEndpointsEnabled bool // Whether admin API endpoints are enabled + AdminUIEnabled bool // Whether admin dashboard UI is enabled + AdminHandler *admin.Handler // Admin API handler (nil if disabled) + DashboardHandler *dashboard.Handler // Dashboard UI handler (nil if disabled) + SwaggerEnabled bool // Whether to expose the Swagger UI at /swagger/index.html + ResponseCacheMiddleware *responsecache.ResponseCacheMiddleware // Optional: response cache middleware for cacheable endpoints } // New creates a new HTTP server @@ -230,6 +230,9 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { adminAPI.GET("/audit/conversation", cfg.AdminHandler.AuditConversation) adminAPI.GET("/models", cfg.AdminHandler.ListModels) adminAPI.GET("/models/categories", cfg.AdminHandler.ListCategories) + adminAPI.GET("/aliases", cfg.AdminHandler.ListAliases) + adminAPI.PUT("/aliases/:name", cfg.AdminHandler.UpsertAlias) + adminAPI.DELETE("/aliases/:name", cfg.AdminHandler.DeleteAlias) } // Admin dashboard UI routes (behind ADMIN_UI_ENABLED flag) From 8d50c5b4d51ce0537d0587605691766bfd682bc6 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 14 Mar 2026 21:45:18 +0100 Subject: [PATCH 02/16] fix: use typed mongo alias id filters --- internal/aliases/store_mongodb.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/aliases/store_mongodb.go b/internal/aliases/store_mongodb.go index 00db5f71..51f317c9 100644 --- a/internal/aliases/store_mongodb.go +++ b/internal/aliases/store_mongodb.go @@ -21,6 +21,10 @@ type mongoAliasDocument struct { UpdatedAt time.Time `bson:"updated_at"` } +type mongoAliasIDFilter struct { + ID string `bson:"_id"` +} + // MongoDBStore stores aliases in MongoDB. type MongoDBStore struct { collection *mongo.Collection @@ -68,7 +72,7 @@ func (s *MongoDBStore) List(ctx context.Context) ([]Alias, error) { func (s *MongoDBStore) Get(ctx context.Context, name string) (*Alias, error) { var doc mongoAliasDocument - err := s.collection.FindOne(ctx, bson.M{"_id": normalizeName(name)}).Decode(&doc) + err := s.collection.FindOne(ctx, mongoAliasIDFilter{ID: normalizeName(name)}).Decode(&doc) if err != nil { if errors.Is(err, mongo.ErrNoDocuments) { return nil, ErrNotFound @@ -102,7 +106,7 @@ func (s *MongoDBStore) Upsert(ctx context.Context, alias Alias) error { "created_at": alias.CreatedAt, }, } - _, err = s.collection.UpdateOne(ctx, bson.M{"_id": alias.Name}, update, options.UpdateOne().SetUpsert(true)) + _, err = s.collection.UpdateOne(ctx, mongoAliasIDFilter{ID: alias.Name}, update, options.UpdateOne().SetUpsert(true)) if err != nil { return fmt.Errorf("upsert alias: %w", err) } @@ -110,7 +114,7 @@ func (s *MongoDBStore) Upsert(ctx context.Context, alias Alias) error { } func (s *MongoDBStore) Delete(ctx context.Context, name string) error { - result, err := s.collection.DeleteOne(ctx, bson.M{"_id": normalizeName(name)}) + result, err := s.collection.DeleteOne(ctx, mongoAliasIDFilter{ID: normalizeName(name)}) if err != nil { return fmt.Errorf("delete alias: %w", err) } From ef236f68109988d86303d919229715da25e8d03b Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 14 Mar 2026 21:53:11 +0100 Subject: [PATCH 03/16] fix: return consistent aliases unavailable errors --- internal/admin/handler.go | 5 ++-- internal/admin/handler_aliases_test.go | 39 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 9a26b815..e8bf3e84 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -519,13 +519,14 @@ type upsertAliasRequest struct { } func (h *Handler) aliasesUnavailableError() error { - return core.NewProviderError("aliases", http.StatusInternalServerError, "aliases service is unavailable", nil) + return core.NewInvalidRequestErrorWithStatus(http.StatusServiceUnavailable, "aliases feature is unavailable", nil). + WithCode("feature_unavailable") } // ListAliases handles GET /admin/api/v1/aliases func (h *Handler) ListAliases(c *echo.Context) error { if h.aliases == nil { - return c.JSON(http.StatusOK, []aliases.View{}) + return handleError(c, h.aliasesUnavailableError()) } views := h.aliases.ListViews() if views == nil { diff --git a/internal/admin/handler_aliases_test.go b/internal/admin/handler_aliases_test.go index 285a036a..13caabba 100644 --- a/internal/admin/handler_aliases_test.go +++ b/internal/admin/handler_aliases_test.go @@ -127,6 +127,45 @@ func TestListAliases(t *testing.T) { } } +func TestAliasesEndpointsReturn503WhenServiceUnavailable(t *testing.T) { + h := NewHandler(nil, nil) + e := echo.New() + + assertUnavailable := func(name string, err error, rec *httptest.ResponseRecorder) { + t.Helper() + if err != nil { + t.Fatalf("%s error = %v", name, err) + } + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("%s status = %d, want 503", name, rec.Code) + } + + var body map[string]map[string]any + if decodeErr := json.Unmarshal(rec.Body.Bytes(), &body); decodeErr != nil { + t.Fatalf("%s decode error = %v", name, decodeErr) + } + if got := body["error"]["code"]; got != "feature_unavailable" { + t.Fatalf("%s error code = %v, want feature_unavailable", name, got) + } + } + + listCtx, listRec := newHandlerContext("/admin/api/v1/aliases") + assertUnavailable("ListAliases", h.ListAliases(listCtx), listRec) + + putReq := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases/smart", bytes.NewBufferString(`{"target_model":"gpt-4o"}`)) + putReq.Header.Set("Content-Type", "application/json") + putRec := httptest.NewRecorder() + putCtx := e.NewContext(putReq, putRec) + putCtx.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) + assertUnavailable("UpsertAlias", h.UpsertAlias(putCtx), putRec) + + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/aliases/smart", nil) + deleteRec := httptest.NewRecorder() + deleteCtx := e.NewContext(deleteReq, deleteRec) + deleteCtx.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) + assertUnavailable("DeleteAlias", h.DeleteAlias(deleteCtx), deleteRec) +} + func TestUpsertAliasAndDeleteAlias(t *testing.T) { h := newAliasHandler(t) e := echo.New() From 73c7e65b460293db7c19b042b36efd3ddb633d92 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 14 Mar 2026 21:55:48 +0100 Subject: [PATCH 04/16] fix: classify alias validation and storage errors --- internal/admin/handler.go | 14 +++- internal/admin/handler_aliases_test.go | 96 ++++++++++++++++++++++++++ internal/aliases/service.go | 10 +-- internal/aliases/store.go | 38 ++++++++-- 4 files changed, 147 insertions(+), 11 deletions(-) diff --git a/internal/admin/handler.go b/internal/admin/handler.go index e8bf3e84..3a0825d7 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -523,6 +523,16 @@ func (h *Handler) aliasesUnavailableError() error { WithCode("feature_unavailable") } +func aliasWriteError(err error) error { + if err == nil { + return nil + } + if aliases.IsValidationError(err) { + return core.NewInvalidRequestError(err.Error(), err) + } + return err +} + // ListAliases handles GET /admin/api/v1/aliases func (h *Handler) ListAliases(c *echo.Context) error { if h.aliases == nil { @@ -563,7 +573,7 @@ func (h *Handler) UpsertAlias(c *echo.Context) error { Description: req.Description, Enabled: enabled, }); err != nil { - return handleError(c, core.NewInvalidRequestError(err.Error(), err)) + return handleError(c, aliasWriteError(err)) } alias, ok := h.aliases.Get(name) @@ -588,7 +598,7 @@ func (h *Handler) DeleteAlias(c *echo.Context) error { if errors.Is(err, aliases.ErrNotFound) { return handleError(c, core.NewNotFoundError("alias not found: "+name)) } - return handleError(c, core.NewInvalidRequestError(err.Error(), err)) + return handleError(c, aliasWriteError(err)) } return c.NoContent(http.StatusNoContent) } diff --git a/internal/admin/handler_aliases_test.go b/internal/admin/handler_aliases_test.go index 13caabba..83e0903c 100644 --- a/internal/admin/handler_aliases_test.go +++ b/internal/admin/handler_aliases_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -58,6 +59,21 @@ func (s *aliasTestStore) Delete(_ context.Context, name string) error { func (s *aliasTestStore) Close() error { return nil } +type failingAliasStore struct { + listErr error + getErr error + upsertErr error + deleteErr error +} + +func (s *failingAliasStore) List(_ context.Context) ([]aliases.Alias, error) { return nil, s.listErr } +func (s *failingAliasStore) Get(_ context.Context, _ string) (*aliases.Alias, error) { + return nil, s.getErr +} +func (s *failingAliasStore) Upsert(_ context.Context, _ aliases.Alias) error { return s.upsertErr } +func (s *failingAliasStore) Delete(_ context.Context, _ string) error { return s.deleteErr } +func (s *failingAliasStore) Close() error { return nil } + type aliasTestCatalog struct { providerTypes map[string]string models map[string]core.Model @@ -107,6 +123,20 @@ func newAliasHandler(t *testing.T, items ...aliases.Alias) *Handler { return NewHandler(nil, nil, WithAliases(service)) } +func newAliasHandlerWithStore(t *testing.T, store aliases.Store) *Handler { + t.Helper() + catalog := newAliasTestCatalog() + catalog.add("gpt-4o", "openai") + service, err := aliases.NewService(store, catalog) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + return NewHandler(nil, nil, WithAliases(service)) +} + func TestListAliases(t *testing.T) { h := newAliasHandler(t, aliases.Alias{Name: "smart", TargetModel: "gpt-4o", Enabled: true}) c, rec := newHandlerContext("/admin/api/v1/aliases") @@ -195,3 +225,69 @@ func TestUpsertAliasAndDeleteAlias(t *testing.T) { t.Fatalf("delete status = %d, want 204", deleteRec.Code) } } + +func TestUpsertAliasReturns500OnStoreFailure(t *testing.T) { + h := newAliasHandlerWithStore(t, &failingAliasStore{ + upsertErr: errors.New("disk full"), + }) + e := echo.New() + + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases/smart", bytes.NewBufferString(`{"target_model":"gpt-4o"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) + + if err := h.UpsertAlias(c); err != nil { + t.Fatalf("UpsertAlias() error = %v", err) + } + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + if !containsString(rec.Body.String(), "internal_error") { + t.Fatalf("body = %s, want internal_error", rec.Body.String()) + } +} + +func TestUpsertAliasReturns400OnValidationError(t *testing.T) { + h := newAliasHandler(t) + e := echo.New() + + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/aliases/smart", bytes.NewBufferString(`{"description":"missing target"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) + + if err := h.UpsertAlias(c); err != nil { + t.Fatalf("UpsertAlias() error = %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if !containsString(rec.Body.String(), "invalid_request_error") { + t.Fatalf("body = %s, want invalid_request_error", rec.Body.String()) + } +} + +func TestDeleteAliasReturns500OnStoreFailure(t *testing.T) { + h := newAliasHandlerWithStore(t, &failingAliasStore{ + deleteErr: errors.New("disk full"), + }) + e := echo.New() + + req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/aliases/smart", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPathValues(echo.PathValues{{Name: "name", Value: "smart"}}) + + if err := h.DeleteAlias(c); err != nil { + t.Fatalf("DeleteAlias() error = %v", err) + } + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + if !containsString(rec.Body.String(), "internal_error") { + t.Fatalf("body = %s, want internal_error", rec.Body.String()) + } +} diff --git a/internal/aliases/service.go b/internal/aliases/service.go index b0df854f..c9b54ec1 100644 --- a/internal/aliases/service.go +++ b/internal/aliases/service.go @@ -221,7 +221,7 @@ func (s *Service) Upsert(ctx context.Context, alias Alias) error { func (s *Service) Delete(ctx context.Context, name string) error { name = normalizeName(name) if name == "" { - return fmt.Errorf("alias name is required") + return newValidationError("alias name is required", nil) } if err := s.store.Delete(ctx, name); err != nil { return fmt.Errorf("delete alias: %w", err) @@ -235,21 +235,21 @@ func (s *Service) Delete(ctx context.Context, name string) error { func (s *Service) validate(alias Alias) error { target, err := alias.TargetSelector() if err != nil { - return fmt.Errorf("invalid target selector: %w", err) + return newValidationError("invalid target selector: "+err.Error(), err) } if alias.Name == target.Model && target.Provider == "" { - return fmt.Errorf("alias %q cannot target itself", alias.Name) + return newValidationError(fmt.Sprintf("alias %q cannot target itself", alias.Name), nil) } s.mu.RLock() defer s.mu.RUnlock() if target.Provider == "" { if existing, ok := s.snapshot.aliases[target.Model]; ok && existing.Name != alias.Name { - return fmt.Errorf("alias target %q refers to another alias", target.Model) + return newValidationError(fmt.Sprintf("alias target %q refers to another alias", target.Model), nil) } } if !s.catalog.Supports(target.QualifiedModel()) { - return fmt.Errorf("target model not found: %s", target.QualifiedModel()) + return newValidationError("target model not found: "+target.QualifiedModel(), nil) } return nil } diff --git a/internal/aliases/store.go b/internal/aliases/store.go index 68349cc1..1a577c1b 100644 --- a/internal/aliases/store.go +++ b/internal/aliases/store.go @@ -10,6 +10,36 @@ import ( // ErrNotFound indicates a requested alias was not found. var ErrNotFound = errors.New("alias not found") +// ValidationError indicates invalid alias input or invalid alias state. +type ValidationError struct { + Message string + Err error +} + +func (e *ValidationError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func (e *ValidationError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func newValidationError(message string, err error) error { + return &ValidationError{Message: message, Err: err} +} + +// IsValidationError reports whether err is a validation error. +func IsValidationError(err error) bool { + var validationErr *ValidationError + return errors.As(err, &validationErr) +} + // Store defines persistence operations for aliases. type Store interface { List(ctx context.Context) ([]Alias, error) @@ -40,16 +70,16 @@ func normalizeAlias(alias Alias) (Alias, error) { alias.Description = strings.TrimSpace(alias.Description) if alias.Name == "" { - return Alias{}, fmt.Errorf("alias name is required") + return Alias{}, newValidationError("alias name is required", nil) } if strings.Contains(alias.Name, "/") { - return Alias{}, fmt.Errorf("alias name %q must be unqualified", alias.Name) + return Alias{}, newValidationError(fmt.Sprintf("alias name %q must be unqualified", alias.Name), nil) } if alias.TargetModel == "" { - return Alias{}, fmt.Errorf("target_model is required") + return Alias{}, newValidationError("target_model is required", nil) } if _, err := alias.TargetSelector(); err != nil { - return Alias{}, fmt.Errorf("invalid target selector: %w", err) + return Alias{}, newValidationError("invalid target selector: "+err.Error(), err) } return alias, nil } From 0d3482a085978a7725f523a914e3f70c879c9f68 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sun, 15 Mar 2026 10:11:18 +0100 Subject: [PATCH 05/16] Fix registry lookup metadata race --- internal/providers/registry.go | 55 +++++++++++++++---- internal/providers/registry_test.go | 83 +++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 11 deletions(-) diff --git a/internal/providers/registry.go b/internal/providers/registry.go index 57286943..d8a19390 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -446,7 +446,8 @@ func (r *ModelRegistry) GetProvider(model string) core.Provider { return nil } -// GetModel returns the model info for the given model, or nil if not found +// GetModel returns the registry-backed model info for the given model, or nil if not found. +// Callers must treat the returned data as read-only. func (r *ModelRegistry) GetModel(model string) *ModelInfo { r.mu.RLock() defer r.mu.RUnlock() @@ -467,15 +468,28 @@ func (r *ModelRegistry) GetModel(model string) *ModelInfo { return nil } -// LookupModel returns a defensive copy of the concrete model for the given selector. +// LookupModel returns a shallow copy of the concrete model for the given selector. // Qualified selectors use the configured provider name prefix when present. func (r *ModelRegistry) LookupModel(model string) (*core.Model, bool) { - info := r.GetModel(model) - if info == nil { - return nil, false + r.mu.RLock() + defer r.mu.RUnlock() + + providerName, modelID := splitModelSelector(model) + if providerName != "" { + if providerModels, ok := r.modelsByProvider[providerName]; ok { + if info, exists := providerModels[modelID]; exists { + cloned := info.Model + return &cloned, true + } + } + // Fall through: the slash may be part of the model ID } - cloned := info.Model - return &cloned, true + + if info, ok := r.models[model]; ok { + cloned := info.Model + return &cloned, true + } + return nil, false } // Supports returns true if the registry has a provider for the given model @@ -768,8 +782,9 @@ func (r *ModelRegistry) SetModelList(list *modeldata.ModelList, raw json.RawMess // EnrichModels re-applies model list metadata to all currently registered models. // Call this after SetModelList to update existing models with the new metadata. -// Holds the write lock for the entire operation to prevent races with concurrent -// readers (e.g. ListModels) that may read Model.Metadata. +// Holds the write lock for the entire operation and replaces published ModelInfo +// entries instead of mutating them in place so concurrent readers can safely keep +// using older snapshots after unlocking. func (r *ModelRegistry) EnrichModels() { r.mu.Lock() defer r.mu.Unlock() @@ -784,7 +799,15 @@ func (r *ModelRegistry) EnrichModels() { } accessor := ®istryAccessor{models: r.models, providerTypes: providerTypes} + accessor.replacements = make(map[*ModelInfo]*ModelInfo, len(r.models)) modeldata.Enrich(accessor, r.modelList) + for _, providerModels := range r.modelsByProvider { + for modelID, info := range providerModels { + if replacement, ok := accessor.replacements[info]; ok { + providerModels[modelID] = replacement + } + } + } r.invalidateSortedCaches() } @@ -840,11 +863,13 @@ func (r *ModelRegistry) snapshotProviderTypes() map[core.Provider]string { } // registryAccessor implements modeldata.ModelInfoAccessor. -// The models map may be either a snapshot (Initialize, LoadFromCache) or the live -// registry map (EnrichModels, which holds the write lock for the entire operation). +// The models map may be either an unpublished snapshot (Initialize, LoadFromCache) +// or the live registry map (EnrichModels, which uses replacements to preserve +// immutability of already-published ModelInfo values). type registryAccessor struct { models map[string]*ModelInfo providerTypes map[core.Provider]string + replacements map[*ModelInfo]*ModelInfo } func (a *registryAccessor) ModelIDs() []string { @@ -865,6 +890,14 @@ func (a *registryAccessor) GetProviderType(modelID string) string { func (a *registryAccessor) SetMetadata(modelID string, meta *core.ModelMetadata) { if info, ok := a.models[modelID]; ok { + if a.replacements != nil { + cloned := *info + cloned.Model.Metadata = meta + replacement := &cloned + a.models[modelID] = replacement + a.replacements[info] = replacement + return + } info.Model.Metadata = meta } } diff --git a/internal/providers/registry_test.go b/internal/providers/registry_test.go index f49a757e..7eabd109 100644 --- a/internal/providers/registry_test.go +++ b/internal/providers/registry_test.go @@ -11,6 +11,7 @@ import ( "time" "gomodel/internal/core" + "gomodel/internal/modeldata" ) // registryMockProvider is a mock implementation of core.Provider for Registry testing. @@ -220,6 +221,88 @@ func TestModelRegistry(t *testing.T) { } }) + t.Run("EnrichModelsReplacesPublishedModelInfo", func(t *testing.T) { + registry := NewModelRegistry() + + mock := ®istryMockProvider{ + name: "test-provider", + modelsResponse: &core.ModelsResponse{ + Object: "list", + Data: []core.Model{ + { + ID: "test-model", + Object: "model", + OwnedBy: "test-provider", + }, + }, + }, + } + registry.RegisterProviderWithType(mock, "openai") + _ = registry.Initialize(context.Background()) + + before := registry.GetModel("test-model") + if before == nil { + t.Fatal("expected GetModel to return a published ModelInfo") + } + if before.Model.Metadata != nil { + t.Fatalf("expected initial metadata to be nil, got %#v", before.Model.Metadata) + } + + raw := []byte(`{ + "version": 1, + "updated_at": "2025-01-01T00:00:00Z", + "providers": { + "openai": { + "display_name": "OpenAI", + "api_type": "openai", + "supported_modes": ["chat"] + } + }, + "models": { + "test-model": { + "display_name": "Test Model", + "modes": ["chat"] + } + }, + "provider_models": {} + }`) + list, err := modeldata.Parse(raw) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + registry.SetModelList(list, raw) + registry.EnrichModels() + + if before.Model.Metadata != nil { + t.Fatalf("expected previously published ModelInfo to remain unchanged, got %#v", before.Model.Metadata) + } + + after := registry.GetModel("test-model") + if after == nil { + t.Fatal("expected GetModel to return an enriched ModelInfo") + } + if after == before { + t.Fatal("expected EnrichModels to replace the published ModelInfo pointer") + } + if after.Model.Metadata == nil { + t.Fatal("expected enriched metadata to be present") + } + if after.Model.Metadata.DisplayName != "Test Model" { + t.Fatalf("registry display name = %q, want Test Model", after.Model.Metadata.DisplayName) + } + + lookup, ok := registry.LookupModel("test-model") + if !ok || lookup == nil { + t.Fatal("expected LookupModel to return the enriched model") + } + if lookup.Metadata == nil { + t.Fatal("expected LookupModel metadata to be present") + } + if lookup.Metadata.DisplayName != "Test Model" { + t.Fatalf("lookup display name = %q, want Test Model", lookup.Metadata.DisplayName) + } + }) + t.Run("DuplicateModels", func(t *testing.T) { registry := NewModelRegistry() mock1 := ®istryMockProvider{ From 49e74037d48a40afe3d9f77eba2f6dfc606aa51a Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sun, 15 Mar 2026 11:05:09 +0100 Subject: [PATCH 06/16] fix: preserve provider routing for aliases --- internal/aliases/provider.go | 80 +++++++++---- internal/aliases/provider_test.go | 15 +-- internal/core/interfaces.go | 7 ++ internal/guardrails/provider.go | 9 ++ internal/providers/registry.go | 23 ++++ internal/providers/router.go | 46 +++++++ internal/server/handlers.go | 4 + internal/server/handlers_test.go | 191 ++++++++++++++++++++++++++++-- 8 files changed, 332 insertions(+), 43 deletions(-) diff --git a/internal/aliases/provider.go b/internal/aliases/provider.go index c01111d9..8ae4c3d7 100644 --- a/internal/aliases/provider.go +++ b/internal/aliases/provider.go @@ -16,6 +16,13 @@ type Provider struct { service *Service } +type requestRewriteMode int + +const ( + rewriteForRouting requestRewriteMode = iota + rewriteForUpstream +) + // NewProvider creates an alias-aware provider wrapper. func NewProvider(inner core.RoutableProvider, service *Service) *Provider { return &Provider{inner: inner, service: service} @@ -35,7 +42,7 @@ func (p *Provider) ResolveModel(model, provider string) (core.ModelSelector, boo } func (p *Provider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { - forward, err := p.rewriteChatRequest(req) + forward, err := p.rewriteChatRequest(req, rewriteForRouting) if err != nil { return nil, err } @@ -43,7 +50,7 @@ func (p *Provider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (* } func (p *Provider) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { - forward, err := p.rewriteChatRequest(req) + forward, err := p.rewriteChatRequest(req, rewriteForRouting) if err != nil { return nil, err } @@ -81,7 +88,7 @@ func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) } func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { - forward, err := p.rewriteResponsesRequest(req) + forward, err := p.rewriteResponsesRequest(req, rewriteForRouting) if err != nil { return nil, err } @@ -89,7 +96,7 @@ func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (* } func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { - forward, err := p.rewriteResponsesRequest(req) + forward, err := p.rewriteResponsesRequest(req, rewriteForRouting) if err != nil { return nil, err } @@ -97,7 +104,7 @@ func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesReque } func (p *Provider) Embeddings(ctx context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { - forward, err := p.rewriteEmbeddingRequest(req) + forward, err := p.rewriteEmbeddingRequest(req, rewriteForRouting) if err != nil { return nil, err } @@ -127,6 +134,15 @@ func (p *Provider) ModelCount() int { return -1 } +// NativeFileProviderTypes delegates provider capability inventory to the inner +// provider when available. +func (p *Provider) NativeFileProviderTypes() []string { + if typed, ok := p.inner.(core.NativeFileProviderTypeLister); ok { + return typed.NativeFileProviderTypes() + } + return nil +} + func (p *Provider) CreateBatch(ctx context.Context, providerType string, req *core.BatchRequest) (*core.BatchResponse, error) { native, err := p.nativeBatchRouter() if err != nil { @@ -247,45 +263,45 @@ func (p *Provider) Passthrough(ctx context.Context, providerType string, req *co return passthrough.Passthrough(ctx, providerType, req) } -func (p *Provider) rewriteChatRequest(req *core.ChatRequest) (*core.ChatRequest, error) { +func (p *Provider) rewriteChatRequest(req *core.ChatRequest, mode requestRewriteMode) (*core.ChatRequest, error) { if req == nil { return nil, nil } - selector, changed, err := p.ResolveModel(req.Model, req.Provider) - if err != nil || !changed { - return req, err + selector, err := p.resolveRequestSelector(req.Model, req.Provider) + if err != nil { + return nil, err } forward := *req forward.Model = selector.Model - forward.Provider = selector.Provider + forward.Provider = providerValueForMode(selector, mode) return &forward, nil } -func (p *Provider) rewriteResponsesRequest(req *core.ResponsesRequest) (*core.ResponsesRequest, error) { +func (p *Provider) rewriteResponsesRequest(req *core.ResponsesRequest, mode requestRewriteMode) (*core.ResponsesRequest, error) { if req == nil { return nil, nil } - selector, changed, err := p.ResolveModel(req.Model, req.Provider) - if err != nil || !changed { - return req, err + selector, err := p.resolveRequestSelector(req.Model, req.Provider) + if err != nil { + return nil, err } forward := *req forward.Model = selector.Model - forward.Provider = selector.Provider + forward.Provider = providerValueForMode(selector, mode) return &forward, nil } -func (p *Provider) rewriteEmbeddingRequest(req *core.EmbeddingRequest) (*core.EmbeddingRequest, error) { +func (p *Provider) rewriteEmbeddingRequest(req *core.EmbeddingRequest, mode requestRewriteMode) (*core.EmbeddingRequest, error) { if req == nil { return nil, nil } - selector, changed, err := p.ResolveModel(req.Model, req.Provider) - if err != nil || !changed { - return req, err + selector, err := p.resolveRequestSelector(req.Model, req.Provider) + if err != nil { + return nil, err } forward := *req forward.Model = selector.Model - forward.Provider = selector.Provider + forward.Provider = providerValueForMode(selector, mode) return &forward, nil } @@ -310,7 +326,7 @@ func (p *Provider) rewriteBatchRequest(req *core.BatchRequest) (*core.BatchReque var body []byte switch typed := decoded.Request.(type) { case *core.ChatRequest: - modified, err := p.rewriteChatRequest(typed) + modified, err := p.rewriteChatRequest(typed, rewriteForUpstream) if err != nil { return nil, err } @@ -319,7 +335,7 @@ func (p *Provider) rewriteBatchRequest(req *core.BatchRequest) (*core.BatchReque return nil, core.NewInvalidRequestError("failed to encode batch item", err) } case *core.ResponsesRequest: - modified, err := p.rewriteResponsesRequest(typed) + modified, err := p.rewriteResponsesRequest(typed, rewriteForUpstream) if err != nil { return nil, err } @@ -328,7 +344,7 @@ func (p *Provider) rewriteBatchRequest(req *core.BatchRequest) (*core.BatchReque return nil, core.NewInvalidRequestError("failed to encode batch item", err) } case *core.EmbeddingRequest: - modified, err := p.rewriteEmbeddingRequest(typed) + modified, err := p.rewriteEmbeddingRequest(typed, rewriteForUpstream) if err != nil { return nil, err } @@ -344,6 +360,24 @@ func (p *Provider) rewriteBatchRequest(req *core.BatchRequest) (*core.BatchReque return &forward, nil } +func (p *Provider) resolveRequestSelector(model, provider string) (core.ModelSelector, error) { + selector, changed, err := p.ResolveModel(model, provider) + if err != nil { + return core.ModelSelector{}, err + } + if changed { + return selector, nil + } + return core.ParseModelSelector(model, provider) +} + +func providerValueForMode(selector core.ModelSelector, mode requestRewriteMode) string { + if mode == rewriteForUpstream { + return "" + } + return selector.Provider +} + func (p *Provider) nativeBatchRouter() (core.NativeBatchRoutableProvider, error) { native, ok := p.inner.(core.NativeBatchRoutableProvider) if !ok { diff --git a/internal/aliases/provider_test.go b/internal/aliases/provider_test.go index b935024b..b2aa3293 100644 --- a/internal/aliases/provider_test.go +++ b/internal/aliases/provider_test.go @@ -185,9 +185,9 @@ func TestProviderMaskingAliasOverridesConcreteModelEntry(t *testing.T) { func TestProviderRewritesBatchItemBodies(t *testing.T) { catalog := newTestCatalog() - catalog.add("gpt-4o", "openai", core.Model{ID: "gpt-4o", Object: "model"}) + catalog.add("openai/gpt-4o", "openai", core.Model{ID: "gpt-4o", Object: "model"}) - service, err := NewService(newMemoryStore(Alias{Name: "smart", TargetModel: "gpt-4o", Enabled: true}), catalog) + service, err := NewService(newMemoryStore(Alias{Name: "smart", TargetModel: "gpt-4o", TargetProvider: "openai", Enabled: true}), catalog) if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -210,13 +210,14 @@ func TestProviderRewritesBatchItemBodies(t *testing.T) { t.Fatalf("captured batch request = %#v, want one request", inner.batchReq) } - var rewritten struct { - Model string `json:"model"` - } + var rewritten map[string]json.RawMessage if err := json.Unmarshal(inner.batchReq.Requests[0].Body, &rewritten); err != nil { t.Fatalf("unmarshal rewritten batch item: %v", err) } - if rewritten.Model != "gpt-4o" { - t.Fatalf("rewritten batch model = %q, want gpt-4o", rewritten.Model) + if got := string(rewritten["model"]); got != `"gpt-4o"` { + t.Fatalf("rewritten batch model = %s, want gpt-4o", got) + } + if _, exists := rewritten["provider"]; exists { + t.Fatalf("rewritten batch item unexpectedly preserved provider hint: %s", inner.batchReq.Requests[0].Body) } } diff --git a/internal/core/interfaces.go b/internal/core/interfaces.go index a5663996..4bfdbdc8 100644 --- a/internal/core/interfaces.go +++ b/internal/core/interfaces.go @@ -86,6 +86,13 @@ type NativeFileRoutableProvider interface { GetFileContent(ctx context.Context, providerType, id string) (*FileContentResponse, error) } +// NativeFileProviderTypeLister exposes registered provider types that support +// native file operations. This is an internal capability inventory and must not +// depend on the public model catalog. +type NativeFileProviderTypeLister interface { + NativeFileProviderTypes() []string +} + // RoutableProvider extends Provider with routing capability. // This is implemented by the Router which uses a model registry // to determine if a model is supported. diff --git a/internal/guardrails/provider.go b/internal/guardrails/provider.go index 87a31283..d48c621b 100644 --- a/internal/guardrails/provider.go +++ b/internal/guardrails/provider.go @@ -59,6 +59,15 @@ func (g *GuardedProvider) ModelCount() int { return -1 } +// NativeFileProviderTypes delegates provider capability inventory to the inner +// provider when available. +func (g *GuardedProvider) NativeFileProviderTypes() []string { + if typed, ok := g.inner.(core.NativeFileProviderTypeLister); ok { + return typed.NativeFileProviderTypes() + } + return nil +} + // ChatCompletion extracts messages, applies guardrails, then routes the request. func (g *GuardedProvider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { modified, err := g.processChat(ctx, req) diff --git a/internal/providers/registry.go b/internal/providers/registry.go index d8a19390..8de0a47a 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -587,6 +587,29 @@ func (r *ModelRegistry) ProviderByType(providerType string) core.Provider { return nil } +// ProviderTypes returns the unique registered provider types in sorted order. +// This inventory is independent of discovered models. +func (r *ModelRegistry) ProviderTypes() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + seen := make(map[string]struct{}, len(r.providerTypes)) + result := make([]string, 0, len(r.providerTypes)) + for _, provider := range r.providers { + providerType := strings.TrimSpace(r.providerTypes[provider]) + if providerType == "" { + continue + } + if _, exists := seen[providerType]; exists { + continue + } + seen[providerType] = struct{}{} + result = append(result, providerType) + } + sort.Strings(result) + return result +} + func splitModelSelector(model string) (providerName, modelID string) { model = strings.TrimSpace(model) if model == "" { diff --git a/internal/providers/router.go b/internal/providers/router.go index 42c69bc8..0fb11a5b 100644 --- a/internal/providers/router.go +++ b/internal/providers/router.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "sort" "strings" "gomodel/internal/core" @@ -30,6 +31,10 @@ type initializedLookup interface { IsInitialized() bool } +type providerTypeLister interface { + ProviderTypes() []string +} + func registryUnavailableError(err error) error { return core.NewProviderError("", http.StatusServiceUnavailable, err.Error(), err) } @@ -384,6 +389,47 @@ func (r *Router) providerByTypeRegistry(providerType string) core.Provider { return r.providerByType(providerType) } +func (r *Router) providerTypes() []string { + if typed, ok := r.lookup.(providerTypeLister); ok { + return typed.ProviderTypes() + } + + seen := make(map[string]struct{}) + result := make([]string, 0) + for _, model := range r.lookup.ListModels() { + providerType := strings.TrimSpace(r.lookup.GetProviderType(model.ID)) + if providerType == "" { + continue + } + if _, exists := seen[providerType]; exists { + continue + } + seen[providerType] = struct{}{} + result = append(result, providerType) + } + sort.Strings(result) + return result +} + +// NativeFileProviderTypes returns the registered provider types that support +// native file operations. This inventory is independent of the public model +// catalog whenever the underlying lookup can expose provider types directly. +func (r *Router) NativeFileProviderTypes() []string { + providerTypes := r.providerTypes() + result := make([]string, 0, len(providerTypes)) + for _, providerType := range providerTypes { + provider := r.providerByTypeRegistry(providerType) + if provider == nil { + continue + } + if _, ok := provider.(core.NativeFileProvider); !ok { + continue + } + result = append(result, providerType) + } + return result +} + // Passthrough routes an opaque provider-native request by provider type. func (r *Router) Passthrough(ctx context.Context, providerType string, req *core.PassthroughRequest) (*core.PassthroughResponse, error) { pp, err := r.resolvePassthroughProvider(providerType) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index ca58618c..09cd7616 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -665,6 +665,10 @@ func (h *Handler) nativeFileRouter() (core.NativeFileRoutableProvider, error) { } func (h *Handler) fileProviderTypes(ctx *echo.Context) ([]string, error) { + if typed, ok := h.provider.(core.NativeFileProviderTypeLister); ok { + return typed.NativeFileProviderTypes(), nil + } + resp, err := h.provider.ListModels(ctx.Request().Context()) if err != nil { return nil, err diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 05ec2a59..5a28b992 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -9,6 +9,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "sort" "strings" "testing" "time" @@ -17,6 +18,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gomodel/internal/aliases" "gomodel/internal/auditlog" "gomodel/internal/core" provideradapter "gomodel/internal/providers" @@ -34,6 +36,76 @@ func withRequestSnapshotAndPrompt(req *http.Request, frame *core.RequestSnapshot return req.WithContext(ctx) } +type aliasesTestStore struct { + aliases []aliases.Alias +} + +func newAliasesTestStore(aliasesList ...aliases.Alias) *aliasesTestStore { + return &aliasesTestStore{aliases: append([]aliases.Alias(nil), aliasesList...)} +} + +func (s *aliasesTestStore) List(_ context.Context) ([]aliases.Alias, error) { + return append([]aliases.Alias(nil), s.aliases...), nil +} + +func (s *aliasesTestStore) Get(_ context.Context, name string) (*aliases.Alias, error) { + for _, alias := range s.aliases { + if alias.Name == name { + copy := alias + return ©, nil + } + } + return nil, aliases.ErrNotFound +} + +func (s *aliasesTestStore) Upsert(_ context.Context, alias aliases.Alias) error { + for i := range s.aliases { + if s.aliases[i].Name == alias.Name { + s.aliases[i] = alias + return nil + } + } + s.aliases = append(s.aliases, alias) + return nil +} + +func (s *aliasesTestStore) Delete(_ context.Context, name string) error { + for i := range s.aliases { + if s.aliases[i].Name == name { + s.aliases = append(s.aliases[:i], s.aliases[i+1:]...) + return nil + } + } + return aliases.ErrNotFound +} + +func (s *aliasesTestStore) Close() error { + return nil +} + +type aliasesTestCatalog struct { + supported map[string]bool + providerTypes map[string]string + models map[string]core.Model +} + +func (c *aliasesTestCatalog) Supports(model string) bool { + return c.supported[model] +} + +func (c *aliasesTestCatalog) GetProviderType(model string) string { + return c.providerTypes[model] +} + +func (c *aliasesTestCatalog) LookupModel(model string) (*core.Model, bool) { + entry, ok := c.models[model] + if !ok { + return nil, false + } + copy := entry + return ©, true +} + type chunkedReadCloser struct { chunks [][]byte index int @@ -260,6 +332,23 @@ func (m *mockProvider) GetProviderType(model string) string { return "" } +func (m *mockProvider) NativeFileProviderTypes() []string { + seen := make(map[string]struct{}) + result := make([]string, 0, len(m.providerTypes)) + for _, providerType := range m.providerTypes { + if providerType == "" { + continue + } + if _, exists := seen[providerType]; exists { + continue + } + seen[providerType] = struct{}{} + result = append(result, providerType) + } + sort.Strings(result) + return result +} + func (m *mockProvider) ChatCompletion(_ context.Context, _ *core.ChatRequest) (*core.ChatResponse, error) { if m.err != nil { return nil, m.err @@ -1156,12 +1245,12 @@ func TestGetBatch_UsesSemanticEnvelopeRouteMetadata(t *testing.T) { createCtx := e.NewContext(createReq, createRec) require.NoError(t, handler.Batches(createCtx)) - var created core.BatchResponse - require.NoError(t, json.Unmarshal(createRec.Body.Bytes(), &created)) + var created core.BatchResponse + require.NoError(t, json.Unmarshal(createRec.Body.Bytes(), &created)) - getReq := httptest.NewRequest(http.MethodGet, "/v1/batches/wrong-id", nil) - frame := core.NewRequestSnapshot(http.MethodGet, "/v1/batches/"+created.ID, map[string]string{"id": created.ID}, nil, nil, "", nil, false, "", nil) - getReq = withRequestSnapshotAndPrompt(getReq, frame) + getReq := httptest.NewRequest(http.MethodGet, "/v1/batches/wrong-id", nil) + frame := core.NewRequestSnapshot(http.MethodGet, "/v1/batches/"+created.ID, map[string]string{"id": created.ID}, nil, nil, "", nil, false, "", nil) + getReq = withRequestSnapshotAndPrompt(getReq, frame) getRec := httptest.NewRecorder() getCtx := e.NewContext(getReq, getRec) @@ -1200,10 +1289,10 @@ func TestListBatches_UsesSemanticEnvelopeQueryMetadata(t *testing.T) { createCtx := e.NewContext(createReq, createRec) require.NoError(t, handler.Batches(createCtx)) - listReq := httptest.NewRequest(http.MethodGet, "/v1/batches?limit=bad", nil) - frame := core.NewRequestSnapshot( - http.MethodGet, - "/v1/batches", + listReq := httptest.NewRequest(http.MethodGet, "/v1/batches?limit=bad", nil) + frame := core.NewRequestSnapshot( + http.MethodGet, + "/v1/batches", nil, map[string][]string{ "limit": {"1"}, @@ -1212,10 +1301,10 @@ func TestListBatches_UsesSemanticEnvelopeQueryMetadata(t *testing.T) { "", nil, false, - "", - nil, - ) - listReq = withRequestSnapshotAndPrompt(listReq, frame) + "", + nil, + ) + listReq = withRequestSnapshotAndPrompt(listReq, frame) listRec := httptest.NewRecorder() listCtx := e.NewContext(listReq, listRec) @@ -3463,6 +3552,82 @@ func TestGetFileWithoutProviderSkipsProviderErrors(t *testing.T) { } } +func TestGetFileWithoutProviderUsesProviderInventoryWhenAliasMasksModel(t *testing.T) { + catalog := aliasesTestCatalog{ + supported: map[string]bool{ + "gpt-4o": true, + "claude-3-haiku": true, + }, + providerTypes: map[string]string{ + "gpt-4o": "openai", + "claude-3-haiku": "anthropic", + }, + models: map[string]core.Model{ + "gpt-4o": {ID: "gpt-4o", Object: "model"}, + "claude-3-haiku": {ID: "claude-3-haiku", Object: "model"}, + }, + } + + service, err := aliases.NewService(newAliasesTestStore( + aliases.Alias{Name: "gpt-4o", TargetModel: "claude-3-haiku", Enabled: true}, + ), &catalog) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + mock := &mockProvider{ + supportedModels: []string{"gpt-4o", "claude-3-haiku"}, + providerTypes: map[string]string{ + "gpt-4o": "openai", + "claude-3-haiku": "anthropic", + }, + modelsResponse: &core.ModelsResponse{ + Object: "list", + Data: []core.Model{ + {ID: "gpt-4o", Object: "model"}, + {ID: "claude-3-haiku", Object: "model"}, + }, + }, + fileErrByProvider: map[string]error{ + "anthropic": core.NewNotFoundError(""), + }, + fileGetByProvider: map[string]*core.FileObject{ + "openai": { + ID: "file_ok_1", + Object: "file", + Bytes: 10, + CreatedAt: 1000, + Filename: "a.jsonl", + Purpose: "batch", + Provider: "openai", + }, + }, + } + + provider := aliases.NewProvider(mock, service) + e := echo.New() + handler := NewHandler(provider, nil, nil, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/files/file_ok_1", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/v1/files/:id") + setPathParam(c, "id", "file_ok_1") + + if err := handler.GetFile(c); err != nil { + t.Fatalf("handler returned error: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "\"provider\":\"openai\"") { + t.Fatalf("unexpected response body: %s", rec.Body.String()) + } +} + func TestMergeStoredBatchFromUpstreamPreservesGatewayMetadata(t *testing.T) { stored := &core.BatchResponse{ ID: "batch_1", From e7c746b667929038ad6c5df91f6a7290c3505821 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sun, 15 Mar 2026 11:14:46 +0100 Subject: [PATCH 07/16] refactor: require file provider inventory --- internal/server/handlers.go | 29 ++------- internal/server/handlers_test.go | 100 +++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 25 deletions(-) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 09cd7616..0c551cf9 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -665,32 +665,11 @@ func (h *Handler) nativeFileRouter() (core.NativeFileRoutableProvider, error) { } func (h *Handler) fileProviderTypes(ctx *echo.Context) ([]string, error) { - if typed, ok := h.provider.(core.NativeFileProviderTypeLister); ok { - return typed.NativeFileProviderTypes(), nil - } - - resp, err := h.provider.ListModels(ctx.Request().Context()) - if err != nil { - return nil, err - } - if resp == nil { - return []string{}, nil - } - seen := make(map[string]struct{}) - providers := make([]string, 0) - for _, model := range resp.Data { - providerType := strings.TrimSpace(h.provider.GetProviderType(model.ID)) - if providerType == "" { - continue - } - if _, exists := seen[providerType]; exists { - continue - } - seen[providerType] = struct{}{} - providers = append(providers, providerType) + typed, ok := h.provider.(core.NativeFileProviderTypeLister) + if !ok { + return nil, core.NewProviderError("", http.StatusInternalServerError, "file provider inventory is unavailable", nil) } - sort.Strings(providers) - return providers, nil + return typed.NativeFileProviderTypes(), nil } func (h *Handler) fileByID( diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 5a28b992..3e2b83e9 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -349,6 +349,62 @@ func (m *mockProvider) NativeFileProviderTypes() []string { return result } +type providerWithoutFileInventory struct { + inner *mockProvider +} + +func (p *providerWithoutFileInventory) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + return p.inner.ChatCompletion(ctx, req) +} + +func (p *providerWithoutFileInventory) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { + return p.inner.StreamChatCompletion(ctx, req) +} + +func (p *providerWithoutFileInventory) ListModels(ctx context.Context) (*core.ModelsResponse, error) { + return p.inner.ListModels(ctx) +} + +func (p *providerWithoutFileInventory) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + return p.inner.Responses(ctx, req) +} + +func (p *providerWithoutFileInventory) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { + return p.inner.StreamResponses(ctx, req) +} + +func (p *providerWithoutFileInventory) Embeddings(ctx context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + return p.inner.Embeddings(ctx, req) +} + +func (p *providerWithoutFileInventory) Supports(model string) bool { + return p.inner.Supports(model) +} + +func (p *providerWithoutFileInventory) GetProviderType(model string) string { + return p.inner.GetProviderType(model) +} + +func (p *providerWithoutFileInventory) CreateFile(ctx context.Context, providerType string, req *core.FileCreateRequest) (*core.FileObject, error) { + return p.inner.CreateFile(ctx, providerType, req) +} + +func (p *providerWithoutFileInventory) ListFiles(ctx context.Context, providerType, purpose string, limit int, after string) (*core.FileListResponse, error) { + return p.inner.ListFiles(ctx, providerType, purpose, limit, after) +} + +func (p *providerWithoutFileInventory) GetFile(ctx context.Context, providerType, id string) (*core.FileObject, error) { + return p.inner.GetFile(ctx, providerType, id) +} + +func (p *providerWithoutFileInventory) DeleteFile(ctx context.Context, providerType, id string) (*core.FileDeleteResponse, error) { + return p.inner.DeleteFile(ctx, providerType, id) +} + +func (p *providerWithoutFileInventory) GetFileContent(ctx context.Context, providerType, id string) (*core.FileContentResponse, error) { + return p.inner.GetFileContent(ctx, providerType, id) +} + func (m *mockProvider) ChatCompletion(_ context.Context, _ *core.ChatRequest) (*core.ChatResponse, error) { if m.err != nil { return nil, m.err @@ -3628,6 +3684,50 @@ func TestGetFileWithoutProviderUsesProviderInventoryWhenAliasMasksModel(t *testi } } +func TestGetFileWithoutProviderRequiresFileProviderInventory(t *testing.T) { + base := &mockProvider{ + supportedModels: []string{"gpt-4o"}, + providerTypes: map[string]string{ + "gpt-4o": "openai", + }, + fileGetByProvider: map[string]*core.FileObject{ + "openai": { + ID: "file_ok_1", + Object: "file", + Bytes: 10, + CreatedAt: 1000, + Filename: "a.jsonl", + Purpose: "batch", + Provider: "openai", + }, + }, + } + + provider := &providerWithoutFileInventory{inner: base} + e := echo.New() + handler := NewHandler(provider, nil, nil, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/files/file_ok_1", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/v1/files/:id") + setPathParam(c, "id", "file_ok_1") + + if err := handler.GetFile(c); err != nil { + t.Fatalf("handler returned error: %v", err) + } + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "provider_error") { + t.Fatalf("expected provider_error body, got: %s", body) + } + if !strings.Contains(body, "file provider inventory is unavailable") { + t.Fatalf("unexpected response body: %s", body) + } +} + func TestMergeStoredBatchFromUpstreamPreservesGatewayMetadata(t *testing.T) { stored := &core.BatchResponse{ ID: "batch_1", From 0c3d58d8397e4470350fdb22db4d6c54b3136cbd Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sun, 15 Mar 2026 14:42:47 +0100 Subject: [PATCH 08/16] Add inline alias management and early alias resolution --- internal/admin/dashboard/dashboard_test.go | 23 + .../admin/dashboard/static/css/dashboard.css | 277 +++++++++ .../admin/dashboard/static/js/dashboard.js | 7 +- .../dashboard/static/js/modules/aliases.js | 543 ++++++++++++++++++ internal/admin/dashboard/templates/index.html | 347 ++++++++--- .../admin/dashboard/templates/layout.html | 1 + internal/admin/handler.go | 25 +- internal/admin/handler_aliases_test.go | 26 + internal/aliases/service.go | 68 ++- internal/aliases/service_test.go | 57 ++ internal/aliases/store.go | 4 - internal/core/context.go | 17 + internal/core/request_model_resolution.go | 30 + internal/responsecache/middleware_test.go | 16 + internal/responsecache/simple.go | 9 +- internal/server/handlers.go | 14 +- internal/server/handlers_test.go | 109 ++++ internal/server/model_validation.go | 22 +- internal/server/model_validation_test.go | 72 ++- internal/server/request_model_resolution.go | 110 ++++ 20 files changed, 1654 insertions(+), 123 deletions(-) create mode 100644 internal/admin/dashboard/static/js/modules/aliases.js create mode 100644 internal/core/request_model_resolution.go create mode 100644 internal/server/request_model_resolution.go diff --git a/internal/admin/dashboard/dashboard_test.go b/internal/admin/dashboard/dashboard_test.go index 05953c0e..c7553908 100644 --- a/internal/admin/dashboard/dashboard_test.go +++ b/internal/admin/dashboard/dashboard_test.go @@ -121,6 +121,29 @@ func TestStatic_ServesModuleJS(t *testing.T) { } } +func TestStatic_ServesAliasesModuleJS(t *testing.T) { + h, err := New() + if err != nil { + t.Fatalf("New() returned error: %v", err) + } + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/admin/static/js/modules/aliases.js", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := h.Static(c); err != nil { + t.Fatalf("Static() returned error: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } + if rec.Body.Len() == 0 { + t.Error("expected non-empty body for aliases module JS file") + } +} + func TestStatic_ServesFavicon(t *testing.T) { h, err := New() if err != nil { diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index 9fc87c7c..9dd946d6 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -781,6 +781,12 @@ body { color: var(--warning); } +.alert-success { + background: rgba(52, 211, 153, 0.12); + border: 1px solid rgba(52, 211, 153, 0.28); + color: var(--success); +} + /* Table */ .table-toolbar { display: flex; @@ -874,6 +880,260 @@ td.col-price { font-size: 14px; } +.table-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 12px; + font-family: inherit; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.table-action-btn:hover:not(:disabled) { + background: var(--bg-surface-hover); +} + +.table-action-btn:disabled { + opacity: 0.45; + cursor: default; +} + +.table-action-btn-danger { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 50%, var(--border)); +} + +.model-alias-editor { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; + margin-bottom: 16px; +} + +.model-name-cell { + display: flex; + flex-direction: column; + gap: 8px; +} + +.model-name-primary { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.model-name-secondary { + font-size: 12px; + color: var(--text-muted); +} + +.alias-kind-badge { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 24px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--accent); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.2px; + text-transform: uppercase; + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); +} + +.inline-link { + padding: 0; + border: none; + background: transparent; + color: var(--accent); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.alias-form h3 { + font-size: 20px; + font-weight: 700; +} + +.alias-form-kicker, +.alias-form-hint { + color: var(--text-muted); + font-size: 13px; +} + +.alias-actions-cell { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.alias-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 12px; + font-family: inherit; + cursor: pointer; + transition: all 0.15s; +} + +.alias-toggle:hover:not(:disabled) { + background: var(--bg-surface-hover); +} + +.alias-toggle:disabled { + opacity: 0.45; + cursor: default; +} + +.alias-toggle-track { + position: relative; + width: 34px; + height: 20px; + border-radius: 999px; + background: color-mix(in srgb, var(--border) 80%, var(--bg)); + transition: background 0.15s; +} + +.alias-toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--text-muted); + transition: transform 0.15s, background 0.15s; +} + +.alias-toggle.enabled { + border-color: color-mix(in srgb, var(--success) 50%, var(--border)); +} + +.alias-toggle.enabled .alias-toggle-track { + background: color-mix(in srgb, var(--success) 55%, var(--bg)); +} + +.alias-toggle.enabled .alias-toggle-thumb { + transform: translateX(14px); + background: #fff; +} + +.aliases-editor-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 20px; +} + +.alias-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.alias-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.alias-form-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.alias-form-field > span { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.alias-form-textarea { + width: 100%; + min-height: 110px; + resize: vertical; + padding: 10px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + font-family: inherit; + outline: none; +} + +.alias-form-textarea:focus { + border-color: var(--accent); +} + +.alias-checkbox { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 14px; +} + +.alias-checkbox input { + width: 16px; + height: 16px; +} + +.alias-form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.model-row-actions { + width: 170px; +} + +.model-row-actions-empty { + color: var(--text-muted); +} + +.data-table tr.alias-row td { + background: color-mix(in srgb, var(--accent) 10%, var(--bg-surface)); +} + +.data-table tr.alias-row:hover td { + background: color-mix(in srgb, var(--accent) 16%, var(--bg-surface-hover)); +} + +.data-table tr.masked-model-row td { + opacity: 0.58; +} + +.data-table tr.masked-model-row:hover td { + opacity: 0.72; +} + /* Usage Mode Toggle */ .usage-mode-toggle { display: inline-flex; @@ -1751,6 +2011,23 @@ body.conversation-drawer-open { font-size: 11px; } + .alias-form-grid { + grid-template-columns: 1fr; + } + .model-alias-editor { + padding: 16px; + } + .alias-actions-cell, + .aliases-editor-header, + .model-name-primary { + flex-direction: column; + align-items: flex-start; + } + .alias-actions-cell .table-action-btn, + .aliases-editor-header .table-action-btn { + width: 100%; + } + /* Audit page mobile */ .audit-log-toolbar { gap: 8px; diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 6e30206c..c3fd69a7 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -184,6 +184,9 @@ function dashboard() { this.authError = false; this.needsAuth = false; const requests = [this.fetchUsage(), this.fetchModels(), this.fetchCategories()]; + if (typeof this.fetchAliases === 'function') { + requests.push(this.fetchAliases()); + } if (this.hasCalendarModule && typeof this.fetchCalendarData === 'function') { requests.push(this.fetchCalendarData()); } @@ -322,6 +325,7 @@ function dashboard() { typeof dashboardDatePickerModule === 'function' ? dashboardDatePickerModule : null, typeof dashboardUsageModule === 'function' ? dashboardUsageModule : null, typeof dashboardAuditListModule === 'function' ? dashboardAuditListModule : null, + typeof dashboardAliasesModule === 'function' ? dashboardAliasesModule : null, typeof dashboardConversationDrawerModule === 'function' ? dashboardConversationDrawerModule : null, calendarModuleFactory, typeof dashboardChartsModule === 'function' ? dashboardChartsModule : null @@ -329,6 +333,7 @@ function dashboard() { return moduleFactories.reduce((app, factory) => { if (!factory) return app; - return Object.assign(app, factory()); + Object.defineProperties(app, Object.getOwnPropertyDescriptors(factory())); + return app; }, base); } diff --git a/internal/admin/dashboard/static/js/modules/aliases.js b/internal/admin/dashboard/static/js/modules/aliases.js new file mode 100644 index 00000000..36f5105b --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/aliases.js @@ -0,0 +1,543 @@ +(function(global) { + function dashboardAliasesModule() { + return { + aliases: [], + aliasesAvailable: true, + aliasLoading: false, + aliasError: '', + aliasFormError: '', + aliasNotice: '', + aliasFormOpen: false, + aliasSubmitting: false, + aliasTogglingName: '', + aliasDeletingName: '', + aliasFormMode: 'create', + aliasFormOriginalName: '', + aliasForm: { + name: '', + target_model: '', + description: '', + enabled: true + }, + + get displayModels() { + const rows = this.models.map((model) => ({ + key: 'model:' + this.qualifiedModelName(model), + display_name: this.qualifiedModelName(model), + secondary_name: '', + provider_type: model.provider_type || '', + model: model.model, + is_alias: false, + alias: null, + kind_badge: '', + masking_alias: null, + alias_state_class: '', + alias_state_text: '' + })); + + if (!this.aliasesAvailable) { + return rows; + } + + const maskingAliases = new Map(); + for (const alias of this.aliases) { + const aliasName = String(alias && alias.name || '').trim().toLowerCase(); + if (!aliasName || alias.enabled === false || !alias.valid) { + continue; + } + maskingAliases.set(aliasName, alias); + } + + for (const row of rows) { + for (const key of this.modelIdentifierKeys(row.model && row.model.id, row.provider_type)) { + if (maskingAliases.has(key)) { + row.masking_alias = maskingAliases.get(key); + break; + } + } + } + + for (const alias of this.aliases) { + const targetModel = this.findConcreteModelForAlias(alias); + if (!targetModel && this.activeCategory && this.activeCategory !== 'all') { + continue; + } + + rows.push({ + key: 'alias:' + alias.name, + display_name: alias.name, + secondary_name: this.aliasTargetLabel(alias), + provider_type: targetModel ? (targetModel.provider_type || alias.provider_type || '') : (alias.provider_type || ''), + model: targetModel ? targetModel.model : { id: alias.name, object: 'model' }, + is_alias: true, + alias, + kind_badge: 'Alias', + masking_alias: null, + alias_state_class: this.aliasStateClass(alias), + alias_state_text: this.aliasStateText(alias) + }); + } + + return rows.sort((a, b) => { + if (a.is_alias !== b.is_alias) { + return a.is_alias ? -1 : 1; + } + return String(a.display_name || '').localeCompare(String(b.display_name || '')); + }); + }, + + get filteredDisplayModels() { + if (!this.modelFilter) return this.displayModels; + const filter = this.modelFilter.toLowerCase(); + return this.displayModels.filter((row) => { + const fields = [ + row.display_name, + row.secondary_name, + row.provider_type, + row.model && row.model.owned_by, + row.alias && row.alias.description, + row.alias && row.alias_state_text, + row.model && row.model.metadata && row.model.metadata.modes ? row.model.metadata.modes.join(',') : '', + row.model && row.model.metadata && row.model.metadata.categories ? row.model.metadata.categories.join(',') : '' + ]; + return fields.some((value) => String(value || '').toLowerCase().includes(filter)); + }); + }, + + defaultAliasForm() { + return { + name: '', + target_model: '', + description: '', + enabled: true + }; + }, + + async fetchAliases() { + this.aliasLoading = true; + this.aliasError = ''; + try { + const res = await fetch('/admin/api/v1/aliases', { headers: this.headers() }); + if (res.status === 503) { + this.aliasesAvailable = false; + this.aliases = []; + return; + } + this.aliasesAvailable = true; + if (!this.handleFetchResponse(res, 'aliases')) { + this.aliases = []; + return; + } + const payload = await res.json(); + this.aliases = Array.isArray(payload) ? payload : []; + } catch (e) { + console.error('Failed to fetch aliases:', e); + this.aliases = []; + this.aliasError = 'Unable to load aliases.'; + } finally { + this.aliasLoading = false; + } + }, + + qualifiedModelName(model) { + if (!model || !model.model || !model.model.id) { + return ''; + } + const modelID = String(model.model.id || '').trim(); + const providerType = String(model.provider_type || '').trim(); + if (!providerType || modelID.includes('/')) { + return modelID; + } + return providerType + '/' + modelID; + }, + + displayRowClass(row) { + if (!row) return ''; + const classes = []; + if (row.is_alias) { + classes.push('alias-row', this.aliasStateClass(row.alias)); + } + if (!row.is_alias && row.masking_alias) { + classes.push('masked-model-row'); + } + return classes.join(' '); + }, + + rowAnchorID(row) { + if (!row) return ''; + if (row.is_alias && row.alias && row.alias.name) { + return 'alias-row-' + String(row.alias.name).replace(/[^a-zA-Z0-9_-]+/g, '-'); + } + return ''; + }, + + filterByAlias(aliasName) { + this.modelFilter = String(aliasName || '').trim(); + }, + + openAliasCreate(model) { + this.aliasFormOpen = true; + this.aliasFormMode = 'create'; + this.aliasFormOriginalName = ''; + this.aliasFormError = ''; + this.aliasNotice = ''; + this.aliasForm = this.defaultAliasForm(); + if (model && model.model && model.model.id) { + this.aliasForm.target_model = this.qualifiedModelName(model); + } + }, + + openAliasEdit(alias) { + this.aliasFormOpen = true; + this.aliasFormMode = 'edit'; + this.aliasFormOriginalName = alias.name || ''; + this.aliasFormError = ''; + this.aliasNotice = ''; + this.aliasForm = { + name: alias.name || '', + target_model: alias.target_provider ? alias.target_provider + '/' + alias.target_model : (alias.target_model || ''), + description: alias.description || '', + enabled: alias.enabled !== false + }; + }, + + closeAliasForm() { + this.aliasFormOpen = false; + this.aliasFormMode = 'create'; + this.aliasFormOriginalName = ''; + this.aliasFormError = ''; + this.aliasForm = this.defaultAliasForm(); + }, + + async aliasResponseMessage(res, fallback) { + try { + const payload = await res.json(); + if (payload && payload.error && payload.error.message) { + return payload.error.message; + } + } catch (_) { + // Ignore invalid or empty responses and return the fallback message. + } + return fallback; + }, + + aliasTargetLabel(alias) { + if (alias.resolved_model) return alias.resolved_model; + if (alias.target_provider) return alias.target_provider + '/' + alias.target_model; + return alias.target_model || '\u2014'; + }, + + aliasStateClass(alias) { + if (alias.enabled === false) return 'is-disabled'; + if (!alias.valid) return 'is-invalid'; + return 'is-valid'; + }, + + aliasStateText(alias) { + if (alias.enabled === false) return 'Disabled'; + if (!alias.valid) return 'Invalid'; + return 'Active'; + }, + + async toggleAliasEnabled(alias) { + if (!alias || !alias.name || this.aliasTogglingName === alias.name) { + return; + } + + this.aliasTogglingName = alias.name; + this.aliasError = ''; + this.aliasNotice = ''; + this.aliasFormError = ''; + + const payload = { + target_model: alias.target_provider ? alias.target_provider + '/' + alias.target_model : alias.target_model, + description: String(alias.description || '').trim(), + enabled: alias.enabled === false + }; + + try { + const res = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(alias.name), { + method: 'PUT', + headers: this.headers(), + body: JSON.stringify(payload) + }); + if (res.status === 503) { + this.aliasesAvailable = false; + this.aliasError = 'Aliases feature is unavailable.'; + return; + } + if (res.status === 401) { + this.authError = true; + this.needsAuth = true; + this.aliasError = 'Authentication required.'; + return; + } + if (!res.ok) { + this.aliasError = await this.aliasResponseMessage(res, 'Failed to update alias state.'); + return; + } + + await this.fetchAliases(); + this.aliasNotice = payload.enabled ? 'Alias enabled.' : 'Alias disabled.'; + if (this.aliasFormOpen && this.aliasFormOriginalName === alias.name) { + this.closeAliasForm(); + } + } catch (e) { + console.error('Failed to toggle alias state:', e); + this.aliasError = 'Failed to update alias state.'; + } finally { + this.aliasTogglingName = ''; + } + }, + + modelKeys(model) { + return this.modelIdentifierKeys( + model && model.model ? model.model.id : '', + model ? model.provider_type : '' + ); + }, + + modelIdentifierKeys(modelID, providerType) { + const keys = new Set(); + const normalizedModelID = String(modelID || '').trim().toLowerCase(); + const provider = String(providerType || '').trim().toLowerCase(); + if (!normalizedModelID) { + return keys; + } + + keys.add(normalizedModelID); + if (provider && !normalizedModelID.includes('/')) { + keys.add(provider + '/' + normalizedModelID); + } + + const parts = normalizedModelID.split('/'); + if (parts.length === 2 && parts[1]) { + keys.add(parts[1]); + } + + return keys; + }, + + aliasKeys(alias) { + const keys = new Set(); + const resolved = String(alias.resolved_model || '').trim().toLowerCase(); + const targetModel = String(alias.target_model || '').trim().toLowerCase(); + const targetProvider = String(alias.target_provider || '').trim().toLowerCase(); + if (resolved) { + keys.add(resolved); + const resolvedParts = resolved.split('/'); + if (resolvedParts.length === 2 && resolvedParts[1]) { + keys.add(resolvedParts[1]); + } + } + if (targetModel) { + keys.add(targetModel); + const targetParts = targetModel.split('/'); + if (targetParts.length === 2 && targetParts[1]) { + keys.add(targetParts[1]); + } + } + if (targetModel && targetProvider) { + keys.add(targetProvider + '/' + targetModel); + } + return keys; + }, + + findConcreteModelForAlias(alias) { + for (const model of this.models) { + const modelKeys = this.modelKeys(model); + for (const key of this.aliasKeys(alias)) { + if (modelKeys.has(key)) { + return model; + } + } + } + return null; + }, + + normalizedAliasName(name) { + return String(name || '').trim().toLowerCase(); + }, + + sameAliasName(left, right) { + const normalizedLeft = this.normalizedAliasName(left); + const normalizedRight = this.normalizedAliasName(right); + return normalizedLeft !== '' && normalizedLeft === normalizedRight; + }, + + findExistingAliasByName(name) { + const normalizedName = this.normalizedAliasName(name); + if (!normalizedName) { + return null; + } + + for (const alias of this.aliases) { + if (this.sameAliasName(alias && alias.name, normalizedName)) { + return alias; + } + } + return null; + }, + + findConcreteModelByName(name) { + const normalizedName = this.normalizedAliasName(name); + if (!normalizedName) { + return null; + } + + for (const model of this.models) { + if (this.modelKeys(model).has(normalizedName)) { + return model; + } + } + return null; + }, + + async submitAliasForm() { + const name = String(this.aliasForm.name || '').trim(); + const targetModel = String(this.aliasForm.target_model || '').trim(); + let originalName = String(this.aliasFormOriginalName || '').trim(); + + if (!name) { + this.aliasFormError = 'Alias name is required.'; + return; + } + if (!targetModel) { + this.aliasFormError = 'Target model is required.'; + return; + } + + this.aliasFormError = ''; + this.aliasError = ''; + this.aliasNotice = ''; + + const existingAlias = this.findExistingAliasByName(name); + if (existingAlias && !this.sameAliasName(existingAlias.name, originalName)) { + const overwriteMessage = originalName + ? 'An alias named "' + existingAlias.name + '" already exists. Saving will overwrite it and remove "' + originalName + '". Continue?' + : 'An alias named "' + existingAlias.name + '" already exists. Saving will update that alias. Continue?'; + if (!window.confirm(overwriteMessage)) { + this.aliasFormError = originalName + ? 'Choose a different alias name to avoid overwriting an existing alias.' + : 'Choose a different alias name or use Edit on the existing alias.'; + return; + } + if (!originalName) { + this.aliasFormMode = 'edit'; + this.aliasFormOriginalName = existingAlias.name || name; + originalName = this.aliasFormOriginalName; + } + } + + const matchingModel = this.findConcreteModelByName(name); + if (!existingAlias && matchingModel && !this.sameAliasName(name, originalName)) { + const modelName = this.qualifiedModelName(matchingModel) || String(matchingModel.model && matchingModel.model.id || '').trim(); + if (!window.confirm('A model named "' + modelName + '" already exists. Creating this alias will mask that model in the list. Continue?')) { + this.aliasFormError = 'Choose a different alias name to avoid masking an existing model.'; + return; + } + } + + this.aliasSubmitting = true; + + const payload = { + target_model: targetModel, + description: String(this.aliasForm.description || '').trim(), + enabled: Boolean(this.aliasForm.enabled) + }; + + try { + const saveRes = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(name), { + method: 'PUT', + headers: this.headers(), + body: JSON.stringify(payload) + }); + + if (saveRes.status === 503) { + this.aliasesAvailable = false; + this.aliasFormError = 'Aliases feature is unavailable.'; + return; + } + if (saveRes.status === 401) { + this.authError = true; + this.needsAuth = true; + this.aliasFormError = 'Authentication required.'; + return; + } + if (!saveRes.ok) { + this.aliasFormError = await this.aliasResponseMessage(saveRes, 'Failed to save alias.'); + return; + } + + if (originalName && originalName !== name) { + const deleteRes = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(originalName), { + method: 'DELETE', + headers: this.headers() + }); + if (deleteRes.status !== 404 && !deleteRes.ok) { + this.aliasFormError = await this.aliasResponseMessage(deleteRes, 'The alias was saved with the new name, but the previous alias could not be removed.'); + await this.fetchAliases(); + return; + } + } + + await this.fetchAliases(); + this.closeAliasForm(); + this.aliasNotice = originalName && originalName !== name ? 'Alias renamed.' : 'Alias saved.'; + } catch (e) { + console.error('Failed to save alias:', e); + this.aliasFormError = 'Failed to save alias.'; + } finally { + this.aliasSubmitting = false; + } + }, + + async deleteAlias(alias) { + if (!alias || !alias.name) return; + if (!window.confirm('Delete alias "' + alias.name + '"? This cannot be undone.')) { + return; + } + + this.aliasDeletingName = alias.name; + this.aliasError = ''; + this.aliasNotice = ''; + this.aliasFormError = ''; + + try { + const res = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(alias.name), { + method: 'DELETE', + headers: this.headers() + }); + if (res.status === 503) { + this.aliasesAvailable = false; + this.aliasError = 'Aliases feature is unavailable.'; + return; + } + if (res.status === 401) { + this.authError = true; + this.needsAuth = true; + this.aliasError = 'Authentication required.'; + return; + } + if (!res.ok) { + this.aliasError = await this.aliasResponseMessage(res, 'Failed to remove alias.'); + return; + } + + await this.fetchAliases(); + if (this.aliasFormOriginalName === alias.name) { + this.closeAliasForm(); + } + this.aliasNotice = 'Alias removed.'; + } catch (e) { + console.error('Failed to delete alias:', e); + this.aliasError = 'Failed to remove alias.'; + } finally { + this.aliasDeletingName = ''; + } + } + }; + } + + global.dashboardAliasesModule = dashboardAliasesModule; +})(window); diff --git a/internal/admin/dashboard/templates/index.html b/internal/admin/dashboard/templates/index.html index f2eed8e5..bd42cd6f 100644 --- a/internal/admin/dashboard/templates/index.html +++ b/internal/admin/dashboard/templates/index.html @@ -210,8 +210,8 @@

Request Log