diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 359e43f..f4c772a 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -14,7 +14,6 @@ import ( "os" "os/signal" "path/filepath" - "strings" "syscall" "time" @@ -110,12 +109,6 @@ func main() { configs := []registry.ProviderConfig{ {ID: "pioneer", Type: registry.TypeOpenAICompat, Name: "Pioneer", BaseURL: cfg.Pioneer.BaseURL}, } - if cfg.FreeProvider.Enabled() { - freeBase := strings.TrimSuffix(strings.TrimRight(cfg.FreeProvider.BaseURL, "/"), "/v1") - configs = append(configs, registry.ProviderConfig{ - ID: "free", Type: registry.TypeFree, Name: "Free", BaseURL: freeBase, - }) - } reg = registry.New(configs) } log.Info("provider registry loaded", "providers", len(reg.List())) diff --git a/server/db/queries/providers.sql b/server/db/queries/providers.sql index 20fbf6b..175a7c1 100644 --- a/server/db/queries/providers.sql +++ b/server/db/queries/providers.sql @@ -10,8 +10,8 @@ SELECT * FROM providers ORDER BY id; SELECT * FROM providers WHERE id = ?; -- name: CreateProvider :exec -INSERT INTO providers (id, type, name, base_url, config_json, active, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?); +INSERT INTO providers (id, type, name, base_url, config_json, active, is_free, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateProvider :exec UPDATE providers SET diff --git a/server/internal/api/v1/chat.go b/server/internal/api/v1/chat.go index d656b0d..a5df2da 100644 --- a/server/internal/api/v1/chat.go +++ b/server/internal/api/v1/chat.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/http" - "strings" "time" "charm.land/fantasy" @@ -43,13 +42,12 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { } // Resolve provider and upstream model name. - isFree := strings.HasPrefix(oaiReq.Model, "free/") - var providerID, upstreamModel string - if isFree { - providerID = "free" - upstreamModel = strings.TrimPrefix(oaiReq.Model, "free/") - } else { - providerID, upstreamModel = s.Registry.ResolveModel(oaiReq.Model) + providerID, upstreamModel := s.Registry.ResolveModel(oaiReq.Model) + + // Check if this provider is free (bypasses pool gate). + isFree := false + if cfg, ok := s.Registry.Get(providerID); ok { + isFree = cfg.IsFree } // Paid path: enforce pool gate before picking a key. @@ -59,8 +57,8 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { writeError(w, gr.Status, gr.Code, gr.Message) return } - } else if _, ok := s.Registry.Get("free"); !ok { - writeError(w, http.StatusBadRequest, "invalid_request", "free provider not configured") + } else if _, ok := s.Registry.Get(providerID); !ok { + writeError(w, http.StatusBadRequest, "invalid_request", "provider not configured") return } diff --git a/server/internal/api/web/chat.go b/server/internal/api/web/chat.go index 8a487e9..e4a8a4c 100644 --- a/server/internal/api/web/chat.go +++ b/server/internal/api/web/chat.go @@ -104,13 +104,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } // Resolve provider and upstream model name. - isFree := strings.HasPrefix(req.Model, "free/") - var providerID, upstreamModel string - if isFree { - providerID = "free" - upstreamModel = strings.TrimPrefix(req.Model, "free/") - } else { - providerID, upstreamModel = s.Registry.ResolveModel(req.Model) + providerID, upstreamModel := s.Registry.ResolveModel(req.Model) + + // Check if this provider is free (bypasses pool gate). + isFree := false + if cfg, ok := s.Registry.Get(providerID); ok { + isFree = cfg.IsFree } if !isFree { @@ -118,8 +117,8 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { writeErr(w, gr.Status, gr.Code, gr.Message) return } - } else if _, ok := s.Registry.Get("free"); !ok { - writeErr(w, 400, "invalid_request", "free provider not configured") + } else if _, ok := s.Registry.Get(providerID); !ok { + writeErr(w, 400, "invalid_request", "provider not configured") return } diff --git a/server/internal/api/web/pool_keys.go b/server/internal/api/web/pool_keys.go index f0a034c..e526ea3 100644 --- a/server/internal/api/web/pool_keys.go +++ b/server/internal/api/web/pool_keys.go @@ -28,10 +28,11 @@ func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) { out := make([]map[string]any, 0, len(rows)) for _, p := range rows { out = append(out, map[string]any{ - "id": p.ID, - "type": p.Type, - "name": p.Name, - "active": p.Active == 1, + "id": p.ID, + "type": p.Type, + "name": p.Name, + "active": p.Active == 1, + "is_free": p.IsFree == 1, }) } writeJSON(w, 200, out) @@ -44,6 +45,7 @@ func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) { Type string `json:"type"` Name string `json:"name"` BaseURL string `json:"base_url"` + IsFree bool `json:"is_free"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, "invalid_request", err.Error()) @@ -54,6 +56,11 @@ func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) { return } + isFreeInt := int64(0) + if req.IsFree { + isFreeInt = 1 + } + now := time.Now().Unix() err := s.Q.CreateProvider(r.Context(), store.CreateProviderParams{ ID: req.ID, @@ -62,6 +69,7 @@ func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) { BaseUrl: req.BaseURL, ConfigJson: "{}", Active: 1, + IsFree: isFreeInt, CreatedAt: now, }) if err != nil { diff --git a/server/internal/migrations/files/00012_provider_free_flag.sql b/server/internal/migrations/files/00012_provider_free_flag.sql new file mode 100644 index 0000000..04b10eb --- /dev/null +++ b/server/internal/migrations/files/00012_provider_free_flag.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin + +-- Add a free flag to providers. Free providers bypass the pool gate, +-- don't require pool keys, and hide the shared budget slider. +ALTER TABLE providers ADD COLUMN is_free INTEGER NOT NULL DEFAULT 0; + +-- Mark OMLX as free. +UPDATE providers SET is_free = 1 WHERE id = 'omlx'; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- SQLite doesn't support DROP COLUMN before 3.35; column remains on rollback. +-- +goose StatementEnd diff --git a/server/internal/pool/free_models.go b/server/internal/pool/free_models.go index 8f81d54..b49d20e 100644 --- a/server/internal/pool/free_models.go +++ b/server/internal/pool/free_models.go @@ -96,7 +96,7 @@ func (r *FreeModelsRefresher) refresh(ctx context.Context) { now := time.Now().Unix() upserted := 0 for _, m := range out.Data { - catalogID := "free/" + m.ID + catalogID := "omlx/" + m.ID _ = r.q.UpsertModelCatalog(ctx, store.UpsertModelCatalogParams{ ID: catalogID, Label: prettifyFreeLabel(m.ID), diff --git a/server/internal/provider/registry/registry.go b/server/internal/provider/registry/registry.go index 1fa77d3..55cddf6 100644 --- a/server/internal/provider/registry/registry.go +++ b/server/internal/provider/registry/registry.go @@ -36,6 +36,7 @@ type ProviderConfig struct { Name string BaseURL string Active bool + IsFree bool Config map[string]string // provider-specific config from config_json } @@ -69,6 +70,7 @@ func LoadFromDB(ctx context.Context, q *store.Queries) (*Registry, error) { Name: r.Name, BaseURL: r.BaseUrl, Active: r.Active == 1, + IsFree: r.IsFree == 1, // TODO: parse config_json into map when needed }) } diff --git a/server/internal/store/models.go b/server/internal/store/models.go index b067992..99c5ed7 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -151,6 +151,7 @@ type Provider struct { ConfigJson string `json:"config_json"` Active int64 `json:"active"` CreatedAt int64 `json:"created_at"` + IsFree int64 `json:"is_free"` } type Session struct { diff --git a/server/internal/store/providers.sql.go b/server/internal/store/providers.sql.go index 23e50d0..c5f3d55 100644 --- a/server/internal/store/providers.sql.go +++ b/server/internal/store/providers.sql.go @@ -10,8 +10,8 @@ import ( ) const createProvider = `-- name: CreateProvider :exec -INSERT INTO providers (id, type, name, base_url, config_json, active, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?) +INSERT INTO providers (id, type, name, base_url, config_json, active, is_free, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) ` type CreateProviderParams struct { @@ -21,6 +21,7 @@ type CreateProviderParams struct { BaseUrl string `json:"base_url"` ConfigJson string `json:"config_json"` Active int64 `json:"active"` + IsFree int64 `json:"is_free"` CreatedAt int64 `json:"created_at"` } @@ -32,6 +33,7 @@ func (q *Queries) CreateProvider(ctx context.Context, arg CreateProviderParams) arg.BaseUrl, arg.ConfigJson, arg.Active, + arg.IsFree, arg.CreatedAt, ) return err @@ -47,7 +49,7 @@ func (q *Queries) DeleteProvider(ctx context.Context, id string) error { } const getProvider = `-- name: GetProvider :one -SELECT id, type, name, base_url, config_json, active, created_at FROM providers WHERE id = ? +SELECT id, type, name, base_url, config_json, active, created_at, is_free FROM providers WHERE id = ? ` func (q *Queries) GetProvider(ctx context.Context, id string) (Provider, error) { @@ -61,13 +63,14 @@ func (q *Queries) GetProvider(ctx context.Context, id string) (Provider, error) &i.ConfigJson, &i.Active, &i.CreatedAt, + &i.IsFree, ) return i, err } const listActiveProviders = `-- name: ListActiveProviders :many -SELECT id, type, name, base_url, config_json, active, created_at FROM providers WHERE active = 1 ORDER BY id +SELECT id, type, name, base_url, config_json, active, created_at, is_free FROM providers WHERE active = 1 ORDER BY id ` // Provider registry queries for multi-provider support. @@ -88,6 +91,7 @@ func (q *Queries) ListActiveProviders(ctx context.Context) ([]Provider, error) { &i.ConfigJson, &i.Active, &i.CreatedAt, + &i.IsFree, ); err != nil { return nil, err } @@ -103,7 +107,7 @@ func (q *Queries) ListActiveProviders(ctx context.Context) ([]Provider, error) { } const listAllProviders = `-- name: ListAllProviders :many -SELECT id, type, name, base_url, config_json, active, created_at FROM providers ORDER BY id +SELECT id, type, name, base_url, config_json, active, created_at, is_free FROM providers ORDER BY id ` func (q *Queries) ListAllProviders(ctx context.Context) ([]Provider, error) { @@ -123,6 +127,7 @@ func (q *Queries) ListAllProviders(ctx context.Context) ([]Provider, error) { &i.ConfigJson, &i.Active, &i.CreatedAt, + &i.IsFree, ); err != nil { return nil, err } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d13bd1c..d5e477a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -262,10 +262,11 @@ export interface Provider { type: string; name: string; active: boolean; + is_free: boolean; } export const listProviders = () => api.get('/api/providers'); -export const createProvider = (id: string, type: string, name: string, baseUrl: string) => - api.post('/api/providers', { id, type, name, base_url: baseUrl }); +export const createProvider = (id: string, type: string, name: string, baseUrl: string, isFree: boolean) => + api.post('/api/providers', { id, type, name, base_url: baseUrl, is_free: isFree }); export const updateProvider = (id: string, updates: { type?: string; name?: string; base_url?: string; active?: boolean }) => api.patch(`/api/providers/${id}`, updates); export const deleteProvider = (id: string) => diff --git a/web/src/routes/admin/providers/+page.svelte b/web/src/routes/admin/providers/+page.svelte index 5c879e2..f2f3d9a 100644 --- a/web/src/routes/admin/providers/+page.svelte +++ b/web/src/routes/admin/providers/+page.svelte @@ -18,6 +18,7 @@ let newType = $state('openai_compat'); let newName = $state(''); let newBaseUrl = $state(''); + let newIsFree = $state(false); let adding = $state(false); let addErr = $state(null); @@ -50,11 +51,12 @@ adding = true; addErr = null; try { - await createProvider(newId.trim(), newType, newName.trim(), newBaseUrl.trim()); + await createProvider(newId.trim(), newType, newName.trim(), newBaseUrl.trim(), newIsFree); showAddForm = false; newId = ''; newName = ''; newBaseUrl = ''; + newIsFree = false; await reload(); } catch (e: unknown) { addErr = e instanceof Error ? e.message : 'failed to create'; @@ -129,6 +131,12 @@ +
+ +
{#if addErr}
{addErr}
{/if} @@ -152,6 +160,9 @@ {p.name} {p.id} {p.type} + {#if p.is_free} + free + {/if}