From e5ee78c4989a372c43aafc057178d5d1e2197017 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Tue, 24 Feb 2026 22:01:20 +0000 Subject: [PATCH] Changeset 0.10.4 (#60) --- CHANGELOG.md | 29 +++++++++++++ ROADMAP.md | 42 ++++++++++++++----- VERSION | 2 +- server/capabilities/resolver.go | 3 ++ server/database/migrations/005_model_type.sql | 14 +++++++ server/database/testhelper.go | 13 ++++++ server/handlers/model_sync.go | 1 + server/models/models.go | 4 +- server/providers/openai.go | 2 + server/providers/provider.go | 1 + server/providers/venice.go | 14 +++++++ server/store/interfaces.go | 1 + server/store/postgres/catalog.go | 28 ++++++++----- src/js/admin-handlers.js | 10 ++++- src/js/app.js | 1 + src/js/settings-handlers.js | 8 +++- src/js/ui-admin.js | 6 ++- src/js/ui-settings.js | 4 +- 18 files changed, 152 insertions(+), 31 deletions(-) create mode 100644 server/database/migrations/005_model_type.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 820e51a..66e280d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to Chat Switchboard. +## [0.10.4] — 2026-02-24 + +### Added +- **`model_type` field across the full pipeline.** Models now carry a type + classification (`chat`, `embedding`, `image`) sourced from provider APIs + at sync time — no hardcoded lists. Venice's `/v1/models` returns `type` + per model; OpenAI-compatible APIs pass through the field when present. + - New DB column: `model_catalog.model_type VARCHAR(20) DEFAULT 'chat'` + - Migration: `005_model_type.sql` + - Propagation: `providers.Model.Type` → `CatalogSyncEntry.ModelType` → + `CatalogEntry.ModelType` → `UserModel.ModelType` → frontend `model_type` + +### Fixed +- **Admin role save didn't refresh UI.** `adminSaveRole()` showed "✓ Saved" + but never called `UI.loadAdminRoles()`, so dropdowns appeared stale after + save. Now reloads the roles panel after a successful save. +- **Role model dropdowns showed all models regardless of type.** Embedding + role showed chat models, utility role showed embedding models. Both admin + and user role UIs now filter the model dropdown by `model_type`: + - "embedding" role → only `model_type === 'embedding'` models + - "utility" role → only `model_type === 'chat'` models + +### Changed +- Venice provider now reads the `type` field from each model in the API + response and normalizes it (`text` → `chat`, `embedding` → `embedding`, + `image` → `image`). +- OpenAI provider wire type extended with optional `type` field for + OpenAI-compatible APIs that include it. + ## [0.10.3] — 2026-02-24 ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 567414e..aa7ae03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,6 +27,8 @@ v0.10.2 Summarize & Continue (first utility role consumer) │ v0.10.3 Frontend Refactor (no features, code health) │ +v0.10.4 Model Type Pipeline + Role Fixes + │ ┌───────┴──────────────┐ │ │ v0.11.0 Extension v0.12.0 File Handling @@ -467,17 +469,35 @@ Vanilla JS, no modules, no build step, no function renaming. See [REFACTOR-0.10.3.md](REFACTOR-0.10.3.md) for full plan. -- [ ] Extract `ui-format.js` — markdown, code blocks, `esc()`, helpers -- [ ] Extract `ui-settings.js` — settings tabs, teams, providers, user prefs -- [ ] Extract `ui-admin.js` — admin modal tabs, all admin rendering -- [ ] Extract `tokens.js` — context tracking, token estimation -- [ ] Extract `notes.js` — notes panel, editor, multi-select -- [ ] Extract `chat.js` — chat ops, send, regen, edit, branch, summarize -- [ ] Extract `settings-handlers.js` — settings save, provider CRUD, cmd palette -- [ ] Extract `admin-handlers.js` — admin actions, presets, team management -- [ ] Slim `app.js` to orchestrator — state, init, boot, auth, listener dispatch -- [ ] Update `sw.js` SHELL_FILES, `index.html` script tags -- [ ] All 159+ frontend tests pass, `node --check` on all files +- [x] Extract `ui-format.js` — markdown, code blocks, `esc()`, helpers +- [x] Extract `ui-settings.js` — settings tabs, teams, providers, user prefs +- [x] Extract `ui-admin.js` — admin modal tabs, all admin rendering +- [x] Extract `tokens.js` — context tracking, token estimation +- [x] Extract `notes.js` — notes panel, editor, multi-select +- [x] Extract `chat.js` — chat ops, send, regen, edit, branch, summarize +- [x] Extract `settings-handlers.js` — settings save, provider CRUD, cmd palette +- [x] Extract `admin-handlers.js` — admin actions, presets, team management +- [x] Slim `app.js` to orchestrator — state, init, boot, auth, listener dispatch +- [x] Update `sw.js` SHELL_FILES, `index.html` script tags +- [x] All 159+ frontend tests pass, `node --check` on all files + +--- + +## v0.10.4 — Model Type Pipeline + Role Fixes ✅ + +Provider APIs report model types (chat, embedding, image) — now captured +end-to-end through sync → catalog → resolver → frontend. Role dropdowns +filter models by type instead of showing everything. + +- [x] Migration `005_model_type.sql` — `model_catalog.model_type` column +- [x] `providers.Model.Type` — captured from provider wire response +- [x] Venice: reads `type` field, normalizes (`text`→`chat`, `embedding`→`embedding`) +- [x] OpenAI: wire struct extended with optional `type` for compatible APIs +- [x] `CatalogSyncEntry.ModelType` → `CatalogEntry.ModelType` → `UserModel.ModelType` +- [x] Frontend `model_type` mapped through `fetchModels()` +- [x] Admin role dropdowns: filter by `model_type` matching role +- [x] User role dropdowns: filter by `model_type` matching role +- [x] `adminSaveRole()` reloads UI after save (was showing "Saved" without refresh) --- diff --git a/VERSION b/VERSION index a3f5a8e..9b40aa6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.10.3 +0.10.4 diff --git a/server/capabilities/resolver.go b/server/capabilities/resolver.go index dd0fd84..93d9c12 100644 --- a/server/capabilities/resolver.go +++ b/server/capabilities/resolver.go @@ -70,6 +70,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m ID: entry.ModelID, DisplayName: displayName(entry.DisplayName, entry.ModelID), ModelID: entry.ModelID, + ModelType: entry.ModelType, Source: "catalog", ProviderConfigID: entry.ProviderConfigID, ConfigID: entry.ProviderConfigID, @@ -104,6 +105,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m ID: entry.ModelID, DisplayName: displayName(entry.DisplayName, entry.ModelID), ModelID: entry.ModelID, + ModelType: entry.ModelType, Source: "catalog", ProviderConfigID: prov.ID, ConfigID: prov.ID, @@ -141,6 +143,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m ID: entry.ModelID, DisplayName: displayName(entry.DisplayName, entry.ModelID), ModelID: entry.ModelID, + ModelType: entry.ModelType, Source: "catalog", ProviderConfigID: prov.ID, ConfigID: prov.ID, diff --git a/server/database/migrations/005_model_type.sql b/server/database/migrations/005_model_type.sql new file mode 100644 index 0000000..9945e3f --- /dev/null +++ b/server/database/migrations/005_model_type.sql @@ -0,0 +1,14 @@ +-- Migration 005: Add model_type to model_catalog +-- +-- Providers like Venice return a "type" field per model (e.g. "text", "embedding", "image"). +-- This column captures that classification so role dropdowns can filter appropriately: +-- - "embedding" role → only embedding models +-- - "utility" role → only chat/text models +-- +-- Default is "chat" (the overwhelming majority of models). Provider sync will +-- populate from the wire response when available — NO hardcoding of model types. + +ALTER TABLE model_catalog ADD COLUMN IF NOT EXISTS model_type VARCHAR(20) DEFAULT 'chat'; + +-- Index for role UI filtering (e.g. "show me all embedding models for this provider") +CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type); diff --git a/server/database/testhelper.go b/server/database/testhelper.go index 1a0db70..1f948ed 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -196,6 +196,19 @@ func TruncateAll(t *testing.T) { }'::jsonb) ON CONFLICT (key) DO NOTHING `) + + // Re-seed platform_policies — also wiped by CASCADE from users truncation + // (platform_policies.updated_by REFERENCES users(id)). + DB.Exec(` + INSERT INTO platform_policies (key, value) VALUES + ('allow_user_byok', 'false'), + ('allow_user_personas', 'false'), + ('allow_raw_model_access', 'true'), + ('allow_registration', 'true'), + ('default_user_active', 'false'), + ('allow_team_providers', 'true') + ON CONFLICT (key) DO NOTHING + `) } // SeedTestUser creates a test user and returns the user ID. diff --git a/server/handlers/model_sync.go b/server/handlers/model_sync.go index 6500a36..bfcf9b6 100644 --- a/server/handlers/model_sync.go +++ b/server/handlers/model_sync.go @@ -66,6 +66,7 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr syncEntries[i] = store.CatalogSyncEntry{ ModelID: m.ID, DisplayName: m.Name, + ModelType: m.Type, Capabilities: m.Capabilities, Pricing: m.Pricing, } diff --git a/server/models/models.go b/server/models/models.go index a9ef731..7338d10 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -157,6 +157,7 @@ type CatalogEntry struct { ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"` ModelID string `json:"model_id" db:"model_id"` DisplayName string `json:"display_name,omitempty" db:"display_name"` + ModelType string `json:"model_type" db:"model_type"` // "chat", "embedding", "image", etc. Capabilities ModelCapabilities `json:"capabilities" db:"capabilities"` Pricing *ModelPricing `json:"pricing,omitempty" db:"pricing"` Visibility string `json:"visibility" db:"visibility"` @@ -470,7 +471,8 @@ type UserModel struct { ID string `json:"id"` DisplayName string `json:"display_name"` ModelID string `json:"model_id"` - Source string `json:"source"` // "catalog", "persona", "live" + ModelType string `json:"model_type"` // "chat", "embedding", "image", etc. + Source string `json:"source"` // "catalog", "persona", "live" ProviderConfigID string `json:"provider_config_id"` ConfigID string `json:"config_id"` // Alias of ProviderConfigID for frontend compat diff --git a/server/providers/openai.go b/server/providers/openai.go index 11c5287..3a153d5 100644 --- a/server/providers/openai.go +++ b/server/providers/openai.go @@ -263,6 +263,7 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([] out = append(out, Model{ ID: m.ID, + Type: m.Type, // passthrough from API if present (empty → "chat" at sync time) OwnedBy: m.OwnedBy, Capabilities: caps, }) @@ -506,6 +507,7 @@ type openaiModelsResponse struct { Data []struct { ID string `json:"id"` OwnedBy string `json:"owned_by"` + Type string `json:"type,omitempty"` // Some APIs (e.g. Venice-compat) include model type ContextLength int `json:"context_length,omitempty"` // Ollama, OpenRouter } `json:"data"` } diff --git a/server/providers/provider.go b/server/providers/provider.go index ffc4917..18d73b1 100644 --- a/server/providers/provider.go +++ b/server/providers/provider.go @@ -141,6 +141,7 @@ type EmbeddingResponse struct { type Model struct { ID string `json:"id"` Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` // "chat", "embedding", "image" — from provider API OwnedBy string `json:"owned_by,omitempty"` Capabilities models.ModelCapabilities `json:"capabilities"` Pricing *models.ModelPricing `json:"pricing,omitempty"` diff --git a/server/providers/venice.go b/server/providers/venice.go index 9574163..5c3fe4a 100644 --- a/server/providers/venice.go +++ b/server/providers/venice.go @@ -99,9 +99,23 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([] name = m.ID } + // Normalize Venice type → our model type + // Venice returns: "text", "embedding", "image", "code" + // We normalize to: "chat", "embedding", "image" + modelType := "chat" + switch m.Type { + case "embedding": + modelType = "embedding" + case "image": + modelType = "image" + default: + modelType = "chat" // "text", "code", etc. are all chat-capable + } + out = append(out, Model{ ID: m.ID, Name: name, + Type: modelType, OwnedBy: "venice", Capabilities: caps, Pricing: pricing, diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 9dde26c..8561763 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -86,6 +86,7 @@ type CatalogStore interface { type CatalogSyncEntry struct { ModelID string DisplayName string + ModelType string // "chat", "embedding", "image" — from provider API Capabilities models.ModelCapabilities Pricing *models.ModelPricing } diff --git a/server/store/postgres/catalog.go b/server/store/postgres/catalog.go index a8e6d9a..9584277 100644 --- a/server/store/postgres/catalog.go +++ b/server/store/postgres/catalog.go @@ -15,12 +15,12 @@ type CatalogStore struct{} func NewCatalogStore() *CatalogStore { return &CatalogStore{} } -const catalogCols = `id, provider_config_id, model_id, display_name, +const catalogCols = `id, provider_config_id, model_id, display_name, model_type, capabilities, pricing, visibility, last_synced_at, created_at, updated_at` // catalogColsMC is catalogCols with mc. prefix for use in JOINs // where id/created_at/updated_at are ambiguous. -const catalogColsMC = `mc.id, mc.provider_config_id, mc.model_id, mc.display_name, +const catalogColsMC = `mc.id, mc.provider_config_id, mc.model_id, mc.display_name, mc.model_type, mc.capabilities, mc.pricing, mc.visibility, mc.last_synced_at, mc.created_at, mc.updated_at` // UpsertFromSync bulk-inserts or updates catalog entries from a provider API fetch. @@ -31,6 +31,12 @@ func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID stri capsJSON := ToJSON(e.Capabilities) pricingJSON := ToJSON(e.Pricing) // nil → "{}", always valid JSONB + // Normalize model type: empty → "chat" (the default) + modelType := e.ModelType + if modelType == "" { + modelType = "chat" + } + var existingID string err := DB.QueryRowContext(ctx, "SELECT id FROM model_catalog WHERE provider_config_id = $1 AND model_id = $2", @@ -40,10 +46,10 @@ func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID stri if err == sql.ErrNoRows { // Insert new (disabled by default) _, err = DB.ExecContext(ctx, ` - INSERT INTO model_catalog (provider_config_id, model_id, display_name, + INSERT INTO model_catalog (provider_config_id, model_id, display_name, model_type, capabilities, pricing, visibility, last_synced_at) - VALUES ($1, $2, $3, $4, $5, 'disabled', $6)`, - providerConfigID, e.ModelID, e.DisplayName, capsJSON, pricingJSON, now) + VALUES ($1, $2, $3, $4, $5, $6, 'disabled', $7)`, + providerConfigID, e.ModelID, e.DisplayName, modelType, capsJSON, pricingJSON, now) if err != nil { return added, updated, fmt.Errorf("insert %s: %w", e.ModelID, err) } @@ -51,10 +57,10 @@ func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID stri } else if err == nil { // Update existing (preserve visibility) _, err = DB.ExecContext(ctx, ` - UPDATE model_catalog SET display_name = $1, capabilities = $2, - pricing = $3, last_synced_at = $4 - WHERE id = $5`, - e.DisplayName, capsJSON, pricingJSON, now, existingID) + UPDATE model_catalog SET display_name = $1, model_type = $2, capabilities = $3, + pricing = $4, last_synced_at = $5 + WHERE id = $6`, + e.DisplayName, modelType, capsJSON, pricingJSON, now, existingID) if err != nil { return added, updated, fmt.Errorf("update %s: %w", e.ModelID, err) } @@ -190,7 +196,7 @@ func scanCatalogEntry(row *sql.Row) (*models.CatalogEntry, error) { var displayName sql.NullString var lastSynced sql.NullTime err := row.Scan( - &e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, + &e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, &e.ModelType, &capsJSON, &pricingJSON, &e.Visibility, &lastSynced, &e.CreatedAt, &e.UpdatedAt, ) @@ -217,7 +223,7 @@ func scanCatalogEntries(rows *sql.Rows) ([]models.CatalogEntry, error) { var displayName sql.NullString var lastSynced sql.NullTime err := rows.Scan( - &e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, + &e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, &e.ModelType, &capsJSON, &pricingJSON, &e.Visibility, &lastSynced, &e.CreatedAt, &e.UpdatedAt, ) diff --git a/src/js/admin-handlers.js b/src/js/admin-handlers.js index daaa568..818542f 100644 --- a/src/js/admin-handlers.js +++ b/src/js/admin-handlers.js @@ -204,7 +204,12 @@ async function adminRoleProviderChanged(role, slot) { modelSelect.innerHTML = ''; if (!provId || !window._adminModelList) return; - const models = window._adminModelList.filter(m => m.provider_config_id === provId); + // Filter by provider AND model type matching the role + const typeForRole = role === 'embedding' ? 'embedding' : 'chat'; + const models = window._adminModelList.filter(m => + m.provider_config_id === provId && + (m.model_type || 'chat') === typeForRole + ); models.forEach(m => { const opt = document.createElement('option'); opt.value = m.model_id; @@ -226,7 +231,8 @@ async function adminSaveRole(role) { fallback: getBinding('fallback') }); if (status) { status.textContent = '✓ Saved'; status.style.color = 'var(--success)'; } - setTimeout(() => { if (status) status.textContent = ''; }, 3000); + // Reload to reflect saved state in dropdowns + setTimeout(() => UI.loadAdminRoles(), 500); } catch (e) { if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; } } diff --git a/src/js/app.js b/src/js/app.js index 1a10d29..3ef930a 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -75,6 +75,7 @@ async function fetchModels() { name: m.display_name || baseModelId, provider: m.provider_name || m.provider || '', configId: m.config_id || m.provider_config_id || null, + model_type: m.model_type || 'chat', capabilities: resolveCapabilities(m.capabilities, baseModelId), isPreset, presetId: m.preset_id || m.persona_id || null, diff --git a/src/js/settings-handlers.js b/src/js/settings-handlers.js index b887115..1e78ebe 100644 --- a/src/js/settings-handlers.js +++ b/src/js/settings-handlers.js @@ -220,9 +220,13 @@ async function userRoleProviderChanged(role) { modelSel.innerHTML = ''; if (!providerId) return; - // Load models for the selected provider (App.models uses configId, not provider_config_id) + // Filter by provider AND model type matching the role + const typeForRole = role === 'embedding' ? 'embedding' : 'chat'; const allModels = App.models || []; - const provModels = allModels.filter(m => m.configId === providerId); + const provModels = allModels.filter(m => + m.configId === providerId && + (m.model_type || 'chat') === typeForRole + ); provModels.forEach(m => { modelSel.innerHTML += ``; }); diff --git a/src/js/ui-admin.js b/src/js/ui-admin.js index 1ee3c9d..83cd142 100644 --- a/src/js/ui-admin.js +++ b/src/js/ui-admin.js @@ -145,6 +145,8 @@ Object.assign(UI, { const roleNames = ['utility', 'embedding']; el.innerHTML = roleNames.map(role => { const cfg = roles[role] || { primary: null, fallback: null }; + const typeForRole = role === 'embedding' ? 'embedding' : 'chat'; + const typeFilter = m => (m.model_type || 'chat') === typeForRole; return `

${role}

@@ -156,7 +158,7 @@ Object.assign(UI, {
@@ -167,7 +169,7 @@ Object.assign(UI, {
diff --git a/src/js/ui-settings.js b/src/js/ui-settings.js index d0ed3ef..db36558 100644 --- a/src/js/ui-settings.js +++ b/src/js/ui-settings.js @@ -391,6 +391,8 @@ Object.assign(UI, { const roleNames = ['utility', 'embedding']; el.innerHTML = roleNames.map(role => { const cfg = userRoles[role] || { primary: null }; + const typeForRole = role === 'embedding' ? 'embedding' : 'chat'; + const typeFilter = m => (m.model_type || 'chat') === typeForRole; return `

${role} @@ -404,7 +406,7 @@ Object.assign(UI, { ${cfg.primary ? `` : ''}