diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index f4c772a..8191c5f 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -40,6 +40,7 @@ import ( // Register provider capabilities via init(). _ "github.com/taciturnaxolotl/potluck/internal/pool/providers/generic" _ "github.com/taciturnaxolotl/potluck/internal/pool/providers/nvidia" + _ "github.com/taciturnaxolotl/potluck/internal/pool/providers/omlx" _ "github.com/taciturnaxolotl/potluck/internal/pool/providers/pioneer" ) diff --git a/server/db/queries/providers.sql b/server/db/queries/providers.sql index 175a7c1..48aa03b 100644 --- a/server/db/queries/providers.sql +++ b/server/db/queries/providers.sql @@ -19,7 +19,8 @@ UPDATE providers SET name = ?, base_url = ?, config_json = ?, - active = ? + active = ?, + is_free = ? WHERE id = ?; -- name: DeleteProvider :exec diff --git a/server/internal/api/web/pool_keys.go b/server/internal/api/web/pool_keys.go index e526ea3..3630095 100644 --- a/server/internal/api/web/pool_keys.go +++ b/server/internal/api/web/pool_keys.go @@ -101,6 +101,7 @@ func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) { Name *string `json:"name,omitempty"` BaseURL *string `json:"base_url,omitempty"` Active *bool `json:"active,omitempty"` + IsFree *bool `json:"is_free,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, "invalid_request", err.Error()) @@ -130,17 +131,26 @@ func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) { if req.Active != nil { newActive = *req.Active } + newIsFree := current.IsFree == 1 + if req.IsFree != nil { + newIsFree = *req.IsFree + } activeInt := int64(0) if newActive { activeInt = 1 } + isFreeInt := int64(0) + if newIsFree { + isFreeInt = 1 + } _ = s.Q.UpdateProvider(r.Context(), store.UpdateProviderParams{ Type: newType, Name: newName, BaseUrl: newBaseURL, ConfigJson: current.ConfigJson, Active: activeInt, + IsFree: isFreeInt, ID: id, }) diff --git a/server/internal/pool/model_fetcher.go b/server/internal/pool/model_fetcher.go index 7b0b4cc..44da22d 100644 --- a/server/internal/pool/model_fetcher.go +++ b/server/internal/pool/model_fetcher.go @@ -73,17 +73,15 @@ type ModelFetcher interface { } // GetModelFetcher returns the appropriate model fetcher for a provider type. -// Uses the capabilities registry if available, falls back to defaults. +// Uses the capabilities registry if available, falls back to pioneer for +// openai_compat, nil for unknown types. func GetModelFetcher(providerType string) ModelFetcher { if caps := GetProviderCapabilities(providerType); caps != nil { return caps.ModelFetcher() } - // Fallback for unregistered types. switch providerType { case "openai_compat": return PioneerModelFetcher{} - case "free": - return OpenAICompatModelFetcher{} default: return nil } diff --git a/server/internal/pool/providers/omlx/omlx.go b/server/internal/pool/providers/omlx/omlx.go new file mode 100644 index 0000000..ae53e56 --- /dev/null +++ b/server/internal/pool/providers/omlx/omlx.go @@ -0,0 +1,141 @@ +// Package omlx implements ProviderCapabilities for the OMLX self-hosted +// model endpoint. Models are fetched from /v1/models/status which provides +// context windows, max tokens, and load status. No billing or health checks. +package omlx + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/taciturnaxolotl/potluck/internal/pool" + "github.com/taciturnaxolotl/potluck/internal/store" +) + +func init() { + pool.RegisterProvider("omlx", &Omlx{}) +} + +// Omlx implements pool.ProviderCapabilities for the OMLX endpoint. +type Omlx struct{} + +func (Omlx) HealthChecker() pool.HealthChecker { return pool.NoopHealthChecker{} } +func (Omlx) BillingIngestor() pool.BillingIngestor { return pool.NoopBillingIngestor{} } +func (Omlx) AcceptedPlan(string) bool { return true } + +func (Omlx) ValidateKey(ctx context.Context, httpClient *http.Client, baseURL, apiKey string) (*pool.KeyValidation, error) { + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/models/status", nil) + if err != nil { + return nil, err + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + + if resp.StatusCode/100 == 2 { + return &pool.KeyValidation{Valid: true}, nil + } + return nil, fmt.Errorf("OMLX returned HTTP %d", resp.StatusCode) +} + +func (Omlx) ModelFetcher() pool.ModelFetcher { + return OmlxModelFetcher{} +} + +// OmlxModelFetcher fetches models from the OMLX /v1/models/status endpoint, +// which provides rich metadata including context window and max tokens. +type OmlxModelFetcher struct{} + +type omlxStatusResponse struct { + Models []omlxModelInfo `json:"models"` +} + +type omlxModelInfo struct { + ID string `json:"id"` + MaxContextWindow int64 `json:"max_context_window"` + MaxTokens int64 `json:"max_tokens"` + Loaded bool `json:"loaded"` + ModelType string `json:"model_type"` + ConfigModelType string `json:"config_model_type"` + EngineType string `json:"engine_type"` +} + +func (OmlxModelFetcher) FetchModels(ctx context.Context, httpClient *http.Client, baseURL, apiKey string) ([]store.UpsertModelCatalogParams, error) { + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + + // Try /v1/models/status first (rich metadata). + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/models/status", nil) + if err != nil { + return nil, err + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("/v1/models/status: HTTP %d", resp.StatusCode) + } + + b, _ := io.ReadAll(resp.Body) + var statusResp omlxStatusResponse + if err := json.Unmarshal(b, &statusResp); err != nil { + return nil, fmt.Errorf("decode status response: %w", err) + } + + now := time.Now().Unix() + var params []store.UpsertModelCatalogParams + for _, m := range statusResp.Models { + if m.ModelType != "llm" { + continue // skip embedding, vision-only, etc. + } + + rawJSON, _ := json.Marshal(m) + p := store.UpsertModelCatalogParams{ + ID: "omlx/" + m.ID, + Label: prettifyLabel(m.ID), + Description: "", + IsChat: 1, + Tier: sql.NullString{String: "free", Valid: true}, + RawJson: string(rawJSON), + RefreshedAt: now, + } + if m.MaxContextWindow > 0 { + p.ContextWindow = sql.NullInt64{Int64: m.MaxContextWindow, Valid: true} + } + if m.MaxTokens > 0 { + p.MaxOutputTokens = sql.NullInt64{Int64: m.MaxTokens, Valid: true} + } + params = append(params, p) + } + + return params, nil +} + +// prettifyLabel converts slug-style model IDs into readable labels. +func prettifyLabel(id string) string { + parts := strings.FieldsFunc(id, func(r rune) bool { + return r == '-' || r == '_' + }) + var words []string + for _, p := range parts { + if p == "" { + continue + } + words = append(words, strings.ToUpper(p[:1])+p[1:]) + } + return strings.Join(words, " ") +} diff --git a/server/internal/provider/registry/registry.go b/server/internal/provider/registry/registry.go index 55cddf6..00cdbf6 100644 --- a/server/internal/provider/registry/registry.go +++ b/server/internal/provider/registry/registry.go @@ -26,7 +26,6 @@ const ( TypeAnthropic ProviderType = "anthropic" TypeGoogle ProviderType = "google" TypeOpenRouter ProviderType = "openrouter" - TypeFree ProviderType = "free" // no auth, openai-compat shape ) // ProviderConfig holds the configuration for a single upstream provider. diff --git a/server/internal/provider/registry/registry_test.go b/server/internal/provider/registry/registry_test.go index 1576ef0..c46cdc5 100644 --- a/server/internal/provider/registry/registry_test.go +++ b/server/internal/provider/registry/registry_test.go @@ -54,7 +54,7 @@ func TestGet(t *testing.T) { func TestList(t *testing.T) { r := New([]ProviderConfig{ {ID: "pioneer", Type: TypeOpenAICompat, Name: "Pioneer", BaseURL: "https://api.pioneer.ai"}, - {ID: "free", Type: TypeFree, Name: "Free", BaseURL: "http://localhost:11434"}, + {ID: "free", Type: "openai_compat", Name: "Free", BaseURL: "http://localhost:11434"}, }) list := r.List() @@ -82,7 +82,7 @@ func TestToFantasy_OpenAICompat(t *testing.T) { func TestToFantasy_Free(t *testing.T) { r := New([]ProviderConfig{ - {ID: "free", Type: TypeFree, Name: "Free", BaseURL: "http://localhost:11434"}, + {ID: "free", Type: "openai_compat", Name: "Free", BaseURL: "http://localhost:11434"}, }) fp, err := r.ToFantasy("free", "") diff --git a/server/internal/store/providers.sql.go b/server/internal/store/providers.sql.go index c5f3d55..5898151 100644 --- a/server/internal/store/providers.sql.go +++ b/server/internal/store/providers.sql.go @@ -148,7 +148,8 @@ UPDATE providers SET name = ?, base_url = ?, config_json = ?, - active = ? + active = ?, + is_free = ? WHERE id = ? ` @@ -158,6 +159,7 @@ type UpdateProviderParams struct { BaseUrl string `json:"base_url"` ConfigJson string `json:"config_json"` Active int64 `json:"active"` + IsFree int64 `json:"is_free"` ID string `json:"id"` } @@ -168,6 +170,7 @@ func (q *Queries) UpdateProvider(ctx context.Context, arg UpdateProviderParams) arg.BaseUrl, arg.ConfigJson, arg.Active, + arg.IsFree, arg.ID, ) return err diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d5e477a..3a0ac3c 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -267,7 +267,7 @@ export interface Provider { export const listProviders = () => api.get('/api/providers'); 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 }) => +export const updateProvider = (id: string, updates: { type?: string; name?: string; base_url?: string; active?: boolean; is_free?: boolean }) => api.patch(`/api/providers/${id}`, updates); export const deleteProvider = (id: string) => api.delete(`/api/providers/${id}`); diff --git a/web/src/routes/admin/providers/+page.svelte b/web/src/routes/admin/providers/+page.svelte index f2f3d9a..28921ab 100644 --- a/web/src/routes/admin/providers/+page.svelte +++ b/web/src/routes/admin/providers/+page.svelte @@ -22,14 +22,21 @@ let adding = $state(false); let addErr = $state(null); + // Inline edit state + let editingId = $state(null); + let editName = $state(''); + let editType = $state(''); + let editBaseUrl = $state(''); + let editIsFree = $state(false); + let saving = $state(false); + const providerTypes = [ { value: 'openai_compat', label: 'OpenAI Compatible' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'google', label: 'Google / Gemini' }, { value: 'openrouter', label: 'OpenRouter' }, { value: 'nvidia', label: 'NVIDIA NIM' }, - { value: 'generic', label: 'Generic (no billing)' }, - { value: 'free', label: 'Free / Self-hosted' } + { value: 'omlx', label: 'OMLX (self-hosted)' } ]; onMount(async () => { @@ -65,6 +72,36 @@ } } + function startEdit(p: Provider) { + editingId = p.id; + editName = p.name; + editType = p.type; + editBaseUrl = (p as any).base_url ?? ''; + editIsFree = p.is_free; + } + + function cancelEdit() { + editingId = null; + } + + async function saveEdit(id: string) { + saving = true; + try { + await updateProvider(id, { + name: editName.trim(), + type: editType, + base_url: editBaseUrl.trim(), + is_free: editIsFree + }); + editingId = null; + await reload(); + } catch (e: unknown) { + err = e instanceof Error ? e.message : 'failed to save'; + } finally { + saving = false; + } + } + async function toggleActive(p: Provider) { try { await updateProvider(p.id, { active: !p.active }); @@ -155,29 +192,54 @@ {:else}
{#each providers as p (p.id)} -
-
- {p.name} - {p.id} - {p.type} - {#if p.is_free} - free - {/if} + {#if editingId === p.id} +
+
+ + + + +
+
+ + +
-
- - + {:else} +
+
startEdit(p)} role="button" tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter') startEdit(p); }}> + {p.name} + {p.id} + {p.type} + {#if p.is_free} + free + {/if} +
+
+ + +
-
+ {/if} {/each}
{/if} @@ -312,6 +374,27 @@ margin-top: 0.4rem; } + .form-row-check { + margin-top: 0.25rem; + } + .form-check { + display: flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; + font-size: 0.82rem; + color: var(--text); + } + .form-check input[type="checkbox"] { + accent-color: var(--accent); + } + .form-check-label { + user-select: none; + } + .form-check-sm { + font-size: 0.75rem; + } + .loading, .empty { padding: 2rem; text-align: center; @@ -336,18 +419,30 @@ .provider-row.inactive { opacity: 0.5; } + .provider-row.editing { + background: var(--bg-sidebar); + flex-wrap: wrap; + gap: 0.5rem; + } .provider-info { display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap; + cursor: pointer; + flex: 1; + min-width: 0; + } + .provider-info:hover .provider-name { + color: var(--accent); } .provider-name { font-size: 0.88rem; font-weight: 500; color: var(--text); + transition: color 80ms; } .provider-id { @@ -374,27 +469,33 @@ color: light-dark(oklch(40% 0.1 145), oklch(75% 0.1 145)); } - .form-row-check { - margin-top: 0.25rem; - } - .form-check { + .edit-fields { display: flex; align-items: center; - gap: 0.4rem; - cursor: pointer; - font-size: 0.82rem; - color: var(--text); + gap: 0.5rem; + flex-wrap: wrap; + flex: 1; + min-width: 0; } - .form-check input[type="checkbox"] { - accent-color: var(--accent); + .edit-input { + padding: 0.3rem 0.5rem; + font-size: 0.8rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-page); + color: var(--text); + min-width: 0; + flex: 1; } - .form-check-label { - user-select: none; + .edit-input:focus { + outline: none; + border-color: var(--accent); } .provider-actions { display: flex; gap: 0.4rem; + flex-shrink: 0; } .action-btn { @@ -414,6 +515,9 @@ .action-btn.danger:hover { color: var(--danger); } + .action-btn.approve:hover { + color: light-dark(oklch(40% 0.1 145), oklch(75% 0.1 145)); + } /* Toggle switch */ .toggle {