From 33d76e59ab2a854baae4533262296a351bf28952 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Fri, 13 Mar 2026 00:31:05 +0000 Subject: [PATCH] Changeset 0.28.0.9 (#181) --- docs/ICD/models.md | 238 +++++++++++++++++++++---- docs/ROADMAP.md | 4 +- server/handlers/capabilities.go | 52 +----- server/handlers/integration_test.go | 14 +- server/handlers/live_provider_test.go | 6 +- server/handlers/model_prefs.go | 13 +- server/store/postgres/user_settings.go | 3 - server/store/sqlite/user_settings.go | 3 - src/js/__tests__/api-contracts.test.js | 24 +-- src/js/__tests__/helpers.js | 2 +- src/js/app-state.js | 4 +- src/js/ui-admin.js | 2 +- src/js/ui-settings.js | 4 +- 13 files changed, 244 insertions(+), 125 deletions(-) diff --git a/docs/ICD/models.md b/docs/ICD/models.md index 7740662..c81fbae 100644 --- a/docs/ICD/models.md +++ b/docs/ICD/models.md @@ -3,6 +3,8 @@ What models are available to the current user and how they control visibility. +--- + ### Enabled Models The primary endpoint for populating model selectors: @@ -11,44 +13,159 @@ The primary endpoint for populating model selectors: GET /models/enabled ``` -Returns `{ "models": [UserModel objects] }`: +Returns a composite response: + +```json +{ + "data": [ UserModel, ... ], + "default_model": "claude-sonnet-4-20250514" +} +``` + +`default_model` is the admin-configured default model ID (from the +`default_model` policy). Empty string if unset. + +**UserModel object:** ```json { "id": "composite-id", - "model_id": "claude-sonnet-4-20250514", - "provider_config_id": "uuid", - "provider_config_name": "Anthropic", - "provider": "anthropic", "display_name": "Claude Sonnet 4", - "model_type": "chat|embedding|image", - "context_window": 200000, - "max_output_tokens": 8192, - "supports_vision": true, - "supports_tools": true, - "supports_thinking": true, - "supports_streaming": true, - "input_price_per_m": 3.00, - "output_price_per_m": 15.00, - "provider_status": "healthy|degraded|down|null", - "scope": "global|team|personal", - "source": "catalog|heuristic", + "model_id": "claude-sonnet-4-20250514", + "model_type": "chat", + "source": "catalog", + + "provider_config_id": "uuid", + "config_id": "uuid", + "provider_name": "Anthropic", + "provider_type": "anthropic", + + "capabilities": { + "streaming": true, + "tool_calling": true, + "vision": true, + "thinking": true, + "reasoning": false, + "code_optimized": false, + "web_search": false, + "max_context": 200000, + "max_output_tokens": 8192 + }, + "is_persona": false, - "persona_id": "uuid|null", - "persona_scope": "global|team|personal|null", - "persona_avatar": "url|null", - "persona_team_name": "string|null" + "persona_id": "", + "persona_handle": "", + "persona_scope": "", + "persona_avatar": "", + "persona_team_name": "", + + "description": "", + "icon": "", + "avatar": "", + "system_prompt": "", + "temperature": null, + "max_tokens": null, + "tool_grants": [], + + "pricing": { + "input_per_m": 3.00, + "output_per_m": 15.00, + "currency": "USD" + }, + + "scope": "global", + "owner_id": null, + "team_name": "", + + "hidden": false, + "sort_order": 0, + + "provider_status": "healthy" } ``` -The capabilities on each model are resolved through the three-tier -chain: catalog DB → heuristic inference → admin overrides (see §10.5). +**Field reference:** -When `is_persona` is `true`, the capability fields (`context_window`, -`max_output_tokens`, `supports_*`, pricing, `provider_status`) are -inherited from the persona's underlying model. The frontend renders -the same capability pills and badges for a persona as for its raw -model — the persona adds identity, not capability restrictions. +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Composite key. Catalog: `{config_id}:{model_id}`. Persona: persona UUID. | +| `display_name` | string | Human-readable name (from catalog or persona). | +| `model_id` | string | Raw model identifier (e.g. `claude-sonnet-4-20250514`). | +| `model_type` | string | `"chat"`, `"embedding"`, `"image"`, etc. | +| `source` | string | `"catalog"` (DB catalog entry) or `"persona"`. | +| `provider_config_id` | string | UUID of the provider config that serves this model. | +| `config_id` | string | Alias of `provider_config_id` (frontend compat). | +| `provider_name` | string | Display name of the provider config. | +| `provider_type` | string | Provider type slug (e.g. `"anthropic"`, `"openai"`). | +| `capabilities` | object | Nested `ModelCapabilities` — see below. | +| `is_persona` | bool | `true` if this entry is a Persona, not a raw model. | +| `persona_id` | string | Persona UUID (omitted if not a persona). | +| `persona_handle` | string | `@handle` for mention routing (omitted if not a persona). | +| `persona_scope` | string | Persona scope: `"global"`, `"team"`, `"personal"` (omitted if not). | +| `persona_avatar` | string | Avatar URL (omitted if not a persona or unset). | +| `persona_team_name` | string | Owning team name for team-scoped personas (omitted if N/A). | +| `description` | string | Persona description (omitted if empty). | +| `icon` | string | Persona icon (omitted if empty). | +| `avatar` | string | Avatar URL (omitted if empty). | +| `system_prompt` | string | Persona system prompt (omitted if empty). | +| `temperature` | float\|null | Persona temperature override. | +| `max_tokens` | int\|null | Persona max-tokens override. | +| `tool_grants` | string[] | Tool IDs granted to this persona. | +| `pricing` | object\|null | Nested `ModelPricing` — see below. Omitted if unavailable. | +| `scope` | string | `"global"`, `"team"`, or `"personal"`. | +| `owner_id` | string\|null | Owner user/team ID for team/personal scoped entries. | +| `team_name` | string | Team name (persona entries only). | +| `hidden` | bool | `true` if the user has hidden this model via preferences. | +| `sort_order` | int | User's custom sort position (0 = default). | +| `provider_status` | string | Health status: `"healthy"`, `"degraded"`, `"down"`, `"unknown"`, or empty. | + +**ModelCapabilities:** + +| Field | Type | Description | +|-------|------|-------------| +| `streaming` | bool | Supports streaming responses. | +| `tool_calling` | bool | Supports function/tool calling. | +| `vision` | bool | Supports image inputs. | +| `thinking` | bool | Supports extended thinking. | +| `reasoning` | bool | Reasoning-optimized model. | +| `code_optimized` | bool | Code-optimized model. | +| `web_search` | bool | Has built-in web search. | +| `max_context` | int | Context window size in tokens. | +| `max_output_tokens` | int | Maximum output tokens. | + +Capabilities are resolved through the three-tier chain: catalog DB → +heuristic inference → admin overrides (see `providers.md` §capabilities). + +**ModelPricing:** + +| Field | Type | Description | +|-------|------|-------------| +| `input_per_m` | float | Input cost per million tokens. | +| `output_per_m` | float | Output cost per million tokens. | +| `currency` | string | Currency code (e.g. `"USD"`). | + +**Persona inheritance:** When `is_persona` is `true`, the `capabilities` +and `pricing` objects are inherited from the persona's underlying base +model. The persona adds identity (name, handle, avatar, system prompt, +tool grants) — not capability restrictions. Persona entries always have +`hidden: false`; persona visibility is controlled by the grant system +and the `is_active` flag, not by model preferences. + +**Model allowlist filtering (v0.24.2):** Non-admin users whose groups +define model restrictions will only see models in their allowlist. +Persona entries whose underlying `model_id` is not in the allowlist are +also filtered. Admins bypass this filter entirely. + +**Visibility resolution order:** + +1. Global catalog: enabled models → all authenticated users +2. Team catalog: enabled models → team members +3. Personal BYOK: enabled models → owner (requires `allow_user_byok` policy) +4. Personas: global + team + personal + shared-via-grants +5. User hidden preferences applied last (sets `hidden` flag, does not remove) +6. Model allowlist filtering (removes entries entirely for non-admins) + +--- ### User Model Preferences @@ -58,31 +175,84 @@ defaults. Preferences are keyed on the **composite identity** providers can have independent visibility. ``` -GET /models/preferences → { "preferences": [PreferenceEntry objects] } -PUT /models/preferences ← { "model_id": "...", "provider_config_id": "uuid", "hidden": true } -POST /models/preferences/bulk ← { "entries": [{ "model_id": "...", "provider_config_id": "uuid", "hidden": true }] } +GET /models/preferences → { "data": [PreferenceEntry, ...] } +PUT /models/preferences ← { "model_id": "...", "provider_config_id": "uuid", ... } +POST /models/preferences/bulk ← { "entries": [...], "hidden": bool } ``` +**GET /models/preferences** + +Returns all preference entries for the authenticated user. + PreferenceEntry: ```json { + "id": "uuid", + "user_id": "uuid", "model_id": "grok-4.1-fast", "provider_config_id": "uuid", "hidden": true, "preferred_temperature": 0.7, "preferred_max_tokens": 4096, - "sort_order": 0 + "sort_order": 0, + "created_at": "2025-06-15T14:30:00Z", + "updated_at": "2025-06-15T14:30:00Z" } ``` +**PUT /models/preferences** + +Upserts a single preference entry. All fields except `model_id` are +optional — only provided fields are updated. If no entry exists, one +is created. + +Request body: + +```json +{ + "model_id": "claude-sonnet-4-20250514", + "provider_config_id": "uuid", + "hidden": true, + "preferred_temperature": 0.7, + "preferred_max_tokens": 4096, + "sort_order": 1 +} +``` + +`model_id` and `provider_config_id` are required. All other fields are +optional — only provided fields are updated. If no entry exists, one +is created. + +Returns `{ "message": "preference updated" }`. + +**POST /models/preferences/bulk** + +Sets the hidden state for multiple model+provider pairs at once. The +`hidden` value is applied to **all** entries in the batch — this is +not per-entry hidden control. + +Request body: + +```json +{ + "entries": [ + { "model_id": "grok-4.1-fast", "provider_config_id": "uuid" }, + { "model_id": "llama-3.1-70b", "provider_config_id": "uuid" } + ], + "hidden": true +} +``` + +Returns `{ "message": "preferences updated", "count": 2 }`. + **Identity rule:** `provider_config_id` is required on write. The same bare `model_id` from two different provider configs (e.g. global Venice vs personal BYOK Venice) are independent preference entries. The frontend composite ID format is `{provider_config_id}:{model_id}`. **Persona preferences:** Personas are not in this table. A persona's -visibility is controlled by the grant system (§15.3) and the -`is_active` flag, not by model preferences. +visibility is controlled by the grant system and the `is_active` flag, +not by model preferences. --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1c39350..6725d57 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1021,7 +1021,7 @@ TBD pull-forward: high-value items whose dependencies are met post-tasks. - [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels) - [ ] KB auto-injection: top-K chunk prepend, context budget aware, per-channel toggle - [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`) -- [ ] Per-provider model preferences (`provider_config_id` dimension in `user_model_settings`) +- [ ] Per-provider model preferences — finalize: make `provider_config_id` required on `PUT /models/preferences` (fixes NULL-in-UNIQUE dedup bug), migrate `/models/enabled` and `/models/preferences` to `{"data": [...]}` envelope, add integration test coverage (currently zero for preference endpoints) **Tier 2 — Medium value:** - [ ] Memory compaction: summarize old memories, confidence decay, prune low-confidence @@ -1103,7 +1103,7 @@ based on need. **UX / Multi-Seat** - "Group" scope badge on model selector / KB list: show access-source annotation so users see _why_ they have access (global, team, group grant). Requires backend to include `access_source` in list responses. -- ~~Per-provider model preferences~~ → scheduled v0.28.0. `user_model_settings` unique key is `(user_id, model_id)` — same model from different providers shares one visibility toggle. Needs `provider_config_id` dimension in DB constraint, store, API, and frontend `hiddenModels` keying. Frontend composite key (`configId:modelId`) already exists in `App.models[].id`. +- ~~Per-provider model preferences~~ → scheduled v0.28.0. DB unique key is already `(user_id, model_id, provider_config_id)` and the store/API accept the column. **Remaining issue:** `provider_config_id` is nullable in the handler — NULL ≠ NULL in both PG and SQLite, so omitting it creates non-deduplicable entries. Fix: make `provider_config_id` required on write. Frontend composite key (`configId:modelId`) already works correctly. - Git credentials store: vault-encrypted per-user git credentials (similar to BYOK pattern). Git provider abstraction (GitHub, GitLab, Gitea). Clone/pull/push handlers. Natural fit for extension sidecar tier since git operations are long-running. - Admin settings team/user export: v0.25.4 ships admin-only settings export/import. User export blocked by vault-encrypted BYOK keys (can't round-trip). Team export needs merge-vs-replace semantics for member lists. diff --git a/server/handlers/capabilities.go b/server/handlers/capabilities.go index 04e5ab3..68c639c 100644 --- a/server/handlers/capabilities.go +++ b/server/handlers/capabilities.go @@ -13,7 +13,6 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -74,7 +73,7 @@ func (h *ModelHandler) ListEnabledModels(c *gin.Context) { } } - c.JSON(http.StatusOK, gin.H{"models": userModels, "default_model": defaultModel}) + c.JSON(http.StatusOK, gin.H{"data": userModels, "default_model": defaultModel}) } // buildHealthMap returns a map of configID → ProviderStatus from current health windows. @@ -147,52 +146,3 @@ func capsFromCatalog(modelID, configID string) (models.ModelCapabilities, bool) return resolved, true } -// liveQueryModelCaps queries a provider API to get capabilities for a specific model. -func liveQueryModelCaps(c *gin.Context, configID, modelID string) (models.ModelCapabilities, bool) { - if database.DB == nil { - return models.ModelCapabilities{}, false - } - - var providerID, endpoint string - var apiKey *string - var headersJSON []byte - err := database.DB.QueryRow(database.Q(` - SELECT provider, endpoint, api_key_enc, headers - FROM provider_configs WHERE id = $1 AND is_active = true - `), configID).Scan(&providerID, &endpoint, &apiKey, &headersJSON) - if err != nil { - return models.ModelCapabilities{}, false - } - - provider, err := providers.Get(providerID) - if err != nil { - return models.ModelCapabilities{}, false - } - - key := "" - if apiKey != nil { - key = *apiKey - } - - var customHeaders map[string]string - _ = json.Unmarshal(headersJSON, &customHeaders) - - modelList, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{ - Endpoint: endpoint, - APIKey: key, - CustomHeaders: customHeaders, - }) - if err != nil { - log.Printf("[caps] live query for %s via config %s failed: %v", modelID, configID, err) - return models.ModelCapabilities{}, false - } - - for _, m := range modelList { - if m.ID == modelID { - resolved := capspkg.ResolveIntrinsic(modelID, &m.Capabilities, nil) - return resolved, true - } - } - - return models.ModelCapabilities{}, false -} diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index 64ee889..c30b5b8 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -829,7 +829,7 @@ func TestIntegration_ModelVisibilityResolution(t *testing.T) { } var modelsResp map[string]interface{} decode(w, &modelsResp) - modelsList := modelsResp["models"].([]interface{}) + modelsList := modelsResp["data"].([]interface{}) if len(modelsList) != 0 { t.Errorf("disabled model should not appear, got %d models", len(modelsList)) } @@ -849,7 +849,7 @@ func TestIntegration_ModelVisibilityResolution(t *testing.T) { t.Fatalf("models/enabled after enable: want 200, got %d: %s", w.Code, w.Body.String()) } decode(w, &modelsResp) - modelsList = modelsResp["models"].([]interface{}) + modelsList = modelsResp["data"].([]interface{}) if len(modelsList) != 1 { t.Errorf("enabled model should appear, want 1 got %d", len(modelsList)) } @@ -1163,13 +1163,13 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) { } var userResp map[string]interface{} decode(w, &userResp) - userModels := userResp["models"].([]interface{}) + userModels := userResp["data"].([]interface{}) if len(userModels) != 0 { t.Errorf("disabled models must not appear for user, got %d", len(userModels)) } // Verify user response is non-null array - if userResp["models"] == nil { + if userResp["data"] == nil { t.Fatal("user models must be [] not null — causes '📋 Loaded 0 models' to crash") } @@ -1192,7 +1192,7 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) { t.Fatalf("user models/enabled after enable: want 200, got %d: %s", w.Code, w.Body.String()) } decode(w, &userResp) - userModels = userResp["models"].([]interface{}) + userModels = userResp["data"].([]interface{}) if len(userModels) != 1 { t.Fatalf("user should see 1 enabled model, got %d", len(userModels)) } @@ -1260,10 +1260,10 @@ func (h *testHarness) getModels(token string) []interface{} { } var resp map[string]interface{} decode(w, &resp) - if resp["models"] == nil { + if resp["data"] == nil { h.t.Fatal("models response must never be null") } - return resp["models"].([]interface{}) + return resp["data"].([]interface{}) } func getModelIDs(models []interface{}) []string { diff --git a/server/handlers/live_provider_test.go b/server/handlers/live_provider_test.go index 12e9820..47c8e57 100644 --- a/server/handlers/live_provider_test.go +++ b/server/handlers/live_provider_test.go @@ -444,7 +444,7 @@ func TestLive_ProviderFullFlow(t *testing.T) { w = h.request("GET", "/api/v1/models/enabled", adminToken, nil) var enabledResp map[string]interface{} decode(w, &enabledResp) - enabledModels := enabledResp["models"].([]interface{}) + enabledModels := enabledResp["data"].([]interface{}) if len(enabledModels) < 1 { t.Fatal("models/enabled should return at least 1 model") } @@ -476,7 +476,7 @@ func TestLive_ProviderFullFlow(t *testing.T) { w = h.request("GET", "/api/v1/models/enabled", userToken, nil) var userResp map[string]interface{} decode(w, &userResp) - userModels := userResp["models"].([]interface{}) + userModels := userResp["data"].([]interface{}) if len(userModels) < 1 { t.Fatal("regular user should see at least 1 enabled model") } @@ -778,7 +778,7 @@ func TestLive_BYOK_AutoFetch(t *testing.T) { w = h.request("GET", "/api/v1/models/enabled", userToken, nil) var resp map[string]interface{} decode(w, &resp) - userModels := resp["models"].([]interface{}) + userModels := resp["data"].([]interface{}) personalCount := 0 for _, raw := range userModels { diff --git a/server/handlers/model_prefs.go b/server/handlers/model_prefs.go index 91493a6..a26c6cc 100644 --- a/server/handlers/model_prefs.go +++ b/server/handlers/model_prefs.go @@ -28,7 +28,11 @@ func (h *ModelPrefsHandler) GetPreferences(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"preferences": prefs}) + if prefs == nil { + prefs = []models.UserModelSetting{} + } + + c.JSON(http.StatusOK, gin.H{"data": prefs}) } // SetPreference upserts a single model preference. @@ -37,7 +41,7 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) { var req struct { ModelID string `json:"model_id" binding:"required"` - ProviderConfigID *string `json:"provider_config_id,omitempty"` + ProviderConfigID string `json:"provider_config_id" binding:"required"` Hidden *bool `json:"hidden,omitempty"` PreferredTemperature *float64 `json:"preferred_temperature,omitempty"` PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"` @@ -48,15 +52,16 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) { return } + pcid := req.ProviderConfigID patch := models.UserModelSettingPatch{ - ProviderConfigID: req.ProviderConfigID, + ProviderConfigID: &pcid, Hidden: req.Hidden, PreferredTemperature: req.PreferredTemperature, PreferredMaxTokens: req.PreferredMaxTokens, SortOrder: req.SortOrder, } - if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, req.ProviderConfigID, patch); err != nil { + if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, &pcid, patch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"}) return } diff --git a/server/store/postgres/user_settings.go b/server/store/postgres/user_settings.go index e2a8c28..0687265 100644 --- a/server/store/postgres/user_settings.go +++ b/server/store/postgres/user_settings.go @@ -3,7 +3,6 @@ package postgres import ( "context" "fmt" - "strings" "git.gobha.me/xcaliber/chat-switchboard/models" ) @@ -148,5 +147,3 @@ func patchIntOrNil(i *int) interface{} { return *i } -// unused but keeping for reference - will be used in ListOptions-based queries -var _ = strings.Join diff --git a/server/store/sqlite/user_settings.go b/server/store/sqlite/user_settings.go index baf6d0b..9980301 100644 --- a/server/store/sqlite/user_settings.go +++ b/server/store/sqlite/user_settings.go @@ -3,7 +3,6 @@ package sqlite import ( "context" "fmt" - "strings" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" @@ -149,5 +148,3 @@ func patchIntOrNil(i *int) interface{} { return *i } -// unused but keeping for reference - will be used in ListOptions-based queries -var _ = strings.Join diff --git a/src/js/__tests__/api-contracts.test.js b/src/js/__tests__/api-contracts.test.js index db9a43d..12285c3 100644 --- a/src/js/__tests__/api-contracts.test.js +++ b/src/js/__tests__/api-contracts.test.js @@ -54,15 +54,14 @@ describe('GET /admin/users response contract', () => { }); // ── /models/enabled response ───────────────── -// Bug: 500 from ambiguous SQL columns in JOIN -// After fix: returns {models: [...]} +// Returns {data: [...], default_model: "..."} describe('GET /models/enabled response contract', () => { // ACTUAL backend UserModel struct sends BOTH provider_config_id AND config_id. // Mock uses provider_config_id as primary (Go struct canonical name) // and config_id as the alias (populated by resolver for frontend compat). const backendResponse = { - models: [ + data: [ { id: 'cfg1:gpt-4o', model_id: 'gpt-4o', @@ -77,21 +76,22 @@ describe('GET /models/enabled response contract', () => { pricing: {}, }, ], + default_model: '', }; - it('response has "models" array', () => { - assert.ok(Array.isArray(backendResponse.models)); + it('response has "data" array', () => { + assert.ok(Array.isArray(backendResponse.data)); }); it('each model has required fields for selector', () => { - for (const m of backendResponse.models) { + for (const m of backendResponse.data) { assert.ok(m.model_id || m.id, 'must have model_id or id'); assert.ok(m.display_name || m.model_id, 'must have display_name or model_id'); } }); it('catalog models have provider_config_id (backend canonical)', () => { - const catalogModels = backendResponse.models.filter(m => m.source === 'catalog'); + const catalogModels = backendResponse.data.filter(m => m.source === 'catalog'); for (const m of catalogModels) { assert.ok(m.provider_config_id, 'catalog model must have provider_config_id (Go struct canonical name)'); @@ -99,7 +99,7 @@ describe('GET /models/enabled response contract', () => { }); it('catalog models have config_id alias matching provider_config_id', () => { - const catalogModels = backendResponse.models.filter(m => m.source === 'catalog'); + const catalogModels = backendResponse.data.filter(m => m.source === 'catalog'); for (const m of catalogModels) { assert.ok(m.config_id, 'catalog model must have config_id (frontend alias)'); @@ -109,7 +109,7 @@ describe('GET /models/enabled response contract', () => { }); it('capabilities is an object (not null)', () => { - for (const m of backendResponse.models) { + for (const m of backendResponse.data) { assert.equal(typeof m.capabilities, 'object'); assert.notEqual(m.capabilities, null); } @@ -421,9 +421,9 @@ describe('Response shape consistency', () => { assert.ok('data' in shape, 'teams/members uses "data" key'); }); - it('models/enabled uses {models:[]}', () => { - const shape = { models: [] }; - assert.ok('models' in shape); + it('models/enabled uses {data:[]}', () => { + const shape = { data: [] }; + assert.ok('data' in shape); }); it('admin/models uses {models:[]}', () => { diff --git a/src/js/__tests__/helpers.js b/src/js/__tests__/helpers.js index 8731ed3..0e8cafc 100644 --- a/src/js/__tests__/helpers.js +++ b/src/js/__tests__/helpers.js @@ -128,7 +128,7 @@ function loadAppModules(overrides = {}) { * v0.22.8: unified persona naming — no more preset aliases. */ function processModelsResponse(data, hiddenModels = new Set()) { - return (data.models || []).map(m => { + return (data.data || data.models || []).map(m => { const isPersona = !!m.is_persona; const baseModelId = m.model_id || m.id; const cfgId = m.config_id || m.provider_config_id || ''; diff --git a/src/js/app-state.js b/src/js/app-state.js index 7f0f04e..323f2aa 100644 --- a/src/js/app-state.js +++ b/src/js/app-state.js @@ -81,7 +81,7 @@ async function fetchModels() { try { const prefData = await API.getModelPreferences(); App.hiddenModels = new Set( - (prefData.preferences || []).filter(p => p.hidden).map(p => + (prefData.data || prefData.preferences || []).filter(p => p.hidden).map(p => (p.provider_config_id || '') + ':' + p.model_id ) ); @@ -89,7 +89,7 @@ async function fetchModels() { if (!App.hiddenModels) App.hiddenModels = new Set(); } - App.models = (data.models || []).map(m => { + App.models = (data.data || data.models || []).map(m => { const isPersona = !!m.is_persona; const baseModelId = m.model_id || m.id; const cfgId = m.config_id || m.provider_config_id || ''; diff --git a/src/js/ui-admin.js b/src/js/ui-admin.js index bef584e..8dc2b81 100644 --- a/src/js/ui-admin.js +++ b/src/js/ui-admin.js @@ -941,7 +941,7 @@ Object.assign(UI, { document.getElementById('adminGroupBudgetMonthly').value = g.token_budget_monthly != null ? g.token_budget_monthly : ''; // ── Model allowlist checkboxes ── - const models = (modelsResp.models || []).filter(m => !m.is_persona); + const models = (modelsResp.data || modelsResp.models || []).filter(m => !m.is_persona); const allowedSet = g.allowed_models ? new Set(g.allowed_models) : null; const modelEl = document.getElementById('adminGroupModels'); const seen = new Set(); diff --git a/src/js/ui-settings.js b/src/js/ui-settings.js index 094e8f8..edff315 100644 --- a/src/js/ui-settings.js +++ b/src/js/ui-settings.js @@ -782,7 +782,7 @@ Object.assign(UI, { try { const prefData = await API.getModelPreferences(); App.hiddenModels = new Set( - (prefData.preferences || []).filter(p => p.hidden).map(p => + (prefData.data || prefData.preferences || []).filter(p => p.hidden).map(p => (p.provider_config_id || '') + ':' + p.model_id ) ); @@ -792,7 +792,7 @@ Object.assign(UI, { } const data = await API.listEnabledModels(); - const models = (data.models || []).filter(m => !m.is_persona); + const models = (data.data || data.models || []).filter(m => !m.is_persona); if (!models.length) { el.innerHTML = '
No models available
'; return;