Changeset 0.28.0.9 (#181)

This commit is contained in:
2026-03-13 00:31:05 +00:00
parent aa870f1040
commit 33d76e59ab
13 changed files with 244 additions and 125 deletions

View File

@@ -3,6 +3,8 @@
What models are available to the current user and how they control What models are available to the current user and how they control
visibility. visibility.
---
### Enabled Models ### Enabled Models
The primary endpoint for populating model selectors: The primary endpoint for populating model selectors:
@@ -11,44 +13,159 @@ The primary endpoint for populating model selectors:
GET /models/enabled 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 ```json
{ {
"id": "composite-id", "id": "composite-id",
"model_id": "claude-sonnet-4-20250514",
"provider_config_id": "uuid",
"provider_config_name": "Anthropic",
"provider": "anthropic",
"display_name": "Claude Sonnet 4", "display_name": "Claude Sonnet 4",
"model_type": "chat|embedding|image", "model_id": "claude-sonnet-4-20250514",
"context_window": 200000, "model_type": "chat",
"max_output_tokens": 8192, "source": "catalog",
"supports_vision": true,
"supports_tools": true, "provider_config_id": "uuid",
"supports_thinking": true, "config_id": "uuid",
"supports_streaming": true, "provider_name": "Anthropic",
"input_price_per_m": 3.00, "provider_type": "anthropic",
"output_price_per_m": 15.00,
"provider_status": "healthy|degraded|down|null", "capabilities": {
"scope": "global|team|personal", "streaming": true,
"source": "catalog|heuristic", "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, "is_persona": false,
"persona_id": "uuid|null", "persona_id": "",
"persona_scope": "global|team|personal|null", "persona_handle": "",
"persona_avatar": "url|null", "persona_scope": "",
"persona_team_name": "string|null" "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 **Field reference:**
chain: catalog DB → heuristic inference → admin overrides (see §10.5).
When `is_persona` is `true`, the capability fields (`context_window`, | Field | Type | Description |
`max_output_tokens`, `supports_*`, pricing, `provider_status`) are |-------|------|-------------|
inherited from the persona's underlying model. The frontend renders | `id` | string | Composite key. Catalog: `{config_id}:{model_id}`. Persona: persona UUID. |
the same capability pills and badges for a persona as for its raw | `display_name` | string | Human-readable name (from catalog or persona). |
model — the persona adds identity, not capability restrictions. | `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 ### User Model Preferences
@@ -58,31 +175,84 @@ defaults. Preferences are keyed on the **composite identity**
providers can have independent visibility. providers can have independent visibility.
``` ```
GET /models/preferences → { "preferences": [PreferenceEntry objects] } GET /models/preferences → { "data": [PreferenceEntry, ...] }
PUT /models/preferences ← { "model_id": "...", "provider_config_id": "uuid", "hidden": true } PUT /models/preferences ← { "model_id": "...", "provider_config_id": "uuid", ... }
POST /models/preferences/bulk ← { "entries": [{ "model_id": "...", "provider_config_id": "uuid", "hidden": true }] } POST /models/preferences/bulk ← { "entries": [...], "hidden": bool }
``` ```
**GET /models/preferences**
Returns all preference entries for the authenticated user.
PreferenceEntry: PreferenceEntry:
```json ```json
{ {
"id": "uuid",
"user_id": "uuid",
"model_id": "grok-4.1-fast", "model_id": "grok-4.1-fast",
"provider_config_id": "uuid", "provider_config_id": "uuid",
"hidden": true, "hidden": true,
"preferred_temperature": 0.7, "preferred_temperature": 0.7,
"preferred_max_tokens": 4096, "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 **Identity rule:** `provider_config_id` is required on write. The same
bare `model_id` from two different provider configs (e.g. global Venice bare `model_id` from two different provider configs (e.g. global Venice
vs personal BYOK Venice) are independent preference entries. The vs personal BYOK Venice) are independent preference entries. The
frontend composite ID format is `{provider_config_id}:{model_id}`. frontend composite ID format is `{provider_config_id}:{model_id}`.
**Persona preferences:** Personas are not in this table. A persona's **Persona preferences:** Personas are not in this table. A persona's
visibility is controlled by the grant system (§15.3) and the visibility is controlled by the grant system and the `is_active` flag,
`is_active` flag, not by model preferences. not by model preferences.
--- ---

View File

@@ -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) - [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels)
- [ ] KB auto-injection: top-K chunk prepend, context budget aware, per-channel toggle - [ ] KB auto-injection: top-K chunk prepend, context budget aware, per-channel toggle
- [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`) - [ ] 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:** **Tier 2 — Medium value:**
- [ ] Memory compaction: summarize old memories, confidence decay, prune low-confidence - [ ] Memory compaction: summarize old memories, confidence decay, prune low-confidence
@@ -1103,7 +1103,7 @@ based on need.
**UX / Multi-Seat** **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. - "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. - 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. - 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.

View File

@@ -13,7 +13,6 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store" "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. // 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 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
}

View File

@@ -829,7 +829,7 @@ func TestIntegration_ModelVisibilityResolution(t *testing.T) {
} }
var modelsResp map[string]interface{} var modelsResp map[string]interface{}
decode(w, &modelsResp) decode(w, &modelsResp)
modelsList := modelsResp["models"].([]interface{}) modelsList := modelsResp["data"].([]interface{})
if len(modelsList) != 0 { if len(modelsList) != 0 {
t.Errorf("disabled model should not appear, got %d models", len(modelsList)) 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()) t.Fatalf("models/enabled after enable: want 200, got %d: %s", w.Code, w.Body.String())
} }
decode(w, &modelsResp) decode(w, &modelsResp)
modelsList = modelsResp["models"].([]interface{}) modelsList = modelsResp["data"].([]interface{})
if len(modelsList) != 1 { if len(modelsList) != 1 {
t.Errorf("enabled model should appear, want 1 got %d", len(modelsList)) 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{} var userResp map[string]interface{}
decode(w, &userResp) decode(w, &userResp)
userModels := userResp["models"].([]interface{}) userModels := userResp["data"].([]interface{})
if len(userModels) != 0 { if len(userModels) != 0 {
t.Errorf("disabled models must not appear for user, got %d", len(userModels)) t.Errorf("disabled models must not appear for user, got %d", len(userModels))
} }
// Verify user response is non-null array // 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") 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()) t.Fatalf("user models/enabled after enable: want 200, got %d: %s", w.Code, w.Body.String())
} }
decode(w, &userResp) decode(w, &userResp)
userModels = userResp["models"].([]interface{}) userModels = userResp["data"].([]interface{})
if len(userModels) != 1 { if len(userModels) != 1 {
t.Fatalf("user should see 1 enabled model, got %d", len(userModels)) 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{} var resp map[string]interface{}
decode(w, &resp) decode(w, &resp)
if resp["models"] == nil { if resp["data"] == nil {
h.t.Fatal("models response must never be null") h.t.Fatal("models response must never be null")
} }
return resp["models"].([]interface{}) return resp["data"].([]interface{})
} }
func getModelIDs(models []interface{}) []string { func getModelIDs(models []interface{}) []string {

View File

@@ -444,7 +444,7 @@ func TestLive_ProviderFullFlow(t *testing.T) {
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil) w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
var enabledResp map[string]interface{} var enabledResp map[string]interface{}
decode(w, &enabledResp) decode(w, &enabledResp)
enabledModels := enabledResp["models"].([]interface{}) enabledModels := enabledResp["data"].([]interface{})
if len(enabledModels) < 1 { if len(enabledModels) < 1 {
t.Fatal("models/enabled should return at least 1 model") 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) w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
var userResp map[string]interface{} var userResp map[string]interface{}
decode(w, &userResp) decode(w, &userResp)
userModels := userResp["models"].([]interface{}) userModels := userResp["data"].([]interface{})
if len(userModels) < 1 { if len(userModels) < 1 {
t.Fatal("regular user should see at least 1 enabled model") 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) w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
var resp map[string]interface{} var resp map[string]interface{}
decode(w, &resp) decode(w, &resp)
userModels := resp["models"].([]interface{}) userModels := resp["data"].([]interface{})
personalCount := 0 personalCount := 0
for _, raw := range userModels { for _, raw := range userModels {

View File

@@ -28,7 +28,11 @@ func (h *ModelPrefsHandler) GetPreferences(c *gin.Context) {
return 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. // SetPreference upserts a single model preference.
@@ -37,7 +41,7 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
var req struct { var req struct {
ModelID string `json:"model_id" binding:"required"` 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"` Hidden *bool `json:"hidden,omitempty"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"` PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"` PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
@@ -48,15 +52,16 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
return return
} }
pcid := req.ProviderConfigID
patch := models.UserModelSettingPatch{ patch := models.UserModelSettingPatch{
ProviderConfigID: req.ProviderConfigID, ProviderConfigID: &pcid,
Hidden: req.Hidden, Hidden: req.Hidden,
PreferredTemperature: req.PreferredTemperature, PreferredTemperature: req.PreferredTemperature,
PreferredMaxTokens: req.PreferredMaxTokens, PreferredMaxTokens: req.PreferredMaxTokens,
SortOrder: req.SortOrder, 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"})
return return
} }

View File

@@ -3,7 +3,6 @@ package postgres
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
) )
@@ -148,5 +147,3 @@ func patchIntOrNil(i *int) interface{} {
return *i return *i
} }
// unused but keeping for reference - will be used in ListOptions-based queries
var _ = strings.Join

View File

@@ -3,7 +3,6 @@ package sqlite
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/store"
@@ -149,5 +148,3 @@ func patchIntOrNil(i *int) interface{} {
return *i return *i
} }
// unused but keeping for reference - will be used in ListOptions-based queries
var _ = strings.Join

View File

@@ -54,15 +54,14 @@ describe('GET /admin/users response contract', () => {
}); });
// ── /models/enabled response ───────────────── // ── /models/enabled response ─────────────────
// Bug: 500 from ambiguous SQL columns in JOIN // Returns {data: [...], default_model: "..."}
// After fix: returns {models: [...]}
describe('GET /models/enabled response contract', () => { describe('GET /models/enabled response contract', () => {
// ACTUAL backend UserModel struct sends BOTH provider_config_id AND config_id. // ACTUAL backend UserModel struct sends BOTH provider_config_id AND config_id.
// Mock uses provider_config_id as primary (Go struct canonical name) // Mock uses provider_config_id as primary (Go struct canonical name)
// and config_id as the alias (populated by resolver for frontend compat). // and config_id as the alias (populated by resolver for frontend compat).
const backendResponse = { const backendResponse = {
models: [ data: [
{ {
id: 'cfg1:gpt-4o', id: 'cfg1:gpt-4o',
model_id: 'gpt-4o', model_id: 'gpt-4o',
@@ -77,21 +76,22 @@ describe('GET /models/enabled response contract', () => {
pricing: {}, pricing: {},
}, },
], ],
default_model: '',
}; };
it('response has "models" array', () => { it('response has "data" array', () => {
assert.ok(Array.isArray(backendResponse.models)); assert.ok(Array.isArray(backendResponse.data));
}); });
it('each model has required fields for selector', () => { 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.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'); assert.ok(m.display_name || m.model_id, 'must have display_name or model_id');
} }
}); });
it('catalog models have provider_config_id (backend canonical)', () => { 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) { for (const m of catalogModels) {
assert.ok(m.provider_config_id, assert.ok(m.provider_config_id,
'catalog model must have provider_config_id (Go struct canonical name)'); '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', () => { 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) { for (const m of catalogModels) {
assert.ok(m.config_id, assert.ok(m.config_id,
'catalog model must have config_id (frontend alias)'); '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)', () => { 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.equal(typeof m.capabilities, 'object');
assert.notEqual(m.capabilities, null); assert.notEqual(m.capabilities, null);
} }
@@ -421,9 +421,9 @@ describe('Response shape consistency', () => {
assert.ok('data' in shape, 'teams/members uses "data" key'); assert.ok('data' in shape, 'teams/members uses "data" key');
}); });
it('models/enabled uses {models:[]}', () => { it('models/enabled uses {data:[]}', () => {
const shape = { models: [] }; const shape = { data: [] };
assert.ok('models' in shape); assert.ok('data' in shape);
}); });
it('admin/models uses {models:[]}', () => { it('admin/models uses {models:[]}', () => {

View File

@@ -128,7 +128,7 @@ function loadAppModules(overrides = {}) {
* v0.22.8: unified persona naming — no more preset aliases. * v0.22.8: unified persona naming — no more preset aliases.
*/ */
function processModelsResponse(data, hiddenModels = new Set()) { function processModelsResponse(data, hiddenModels = new Set()) {
return (data.models || []).map(m => { return (data.data || data.models || []).map(m => {
const isPersona = !!m.is_persona; const isPersona = !!m.is_persona;
const baseModelId = m.model_id || m.id; const baseModelId = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id || ''; const cfgId = m.config_id || m.provider_config_id || '';

View File

@@ -81,7 +81,7 @@ async function fetchModels() {
try { try {
const prefData = await API.getModelPreferences(); const prefData = await API.getModelPreferences();
App.hiddenModels = new Set( 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 (p.provider_config_id || '') + ':' + p.model_id
) )
); );
@@ -89,7 +89,7 @@ async function fetchModels() {
if (!App.hiddenModels) App.hiddenModels = new Set(); 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 isPersona = !!m.is_persona;
const baseModelId = m.model_id || m.id; const baseModelId = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id || ''; const cfgId = m.config_id || m.provider_config_id || '';

View File

@@ -941,7 +941,7 @@ Object.assign(UI, {
document.getElementById('adminGroupBudgetMonthly').value = g.token_budget_monthly != null ? g.token_budget_monthly : ''; document.getElementById('adminGroupBudgetMonthly').value = g.token_budget_monthly != null ? g.token_budget_monthly : '';
// ── Model allowlist checkboxes ── // ── 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 allowedSet = g.allowed_models ? new Set(g.allowed_models) : null;
const modelEl = document.getElementById('adminGroupModels'); const modelEl = document.getElementById('adminGroupModels');
const seen = new Set(); const seen = new Set();

View File

@@ -782,7 +782,7 @@ Object.assign(UI, {
try { try {
const prefData = await API.getModelPreferences(); const prefData = await API.getModelPreferences();
App.hiddenModels = new Set( 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 (p.provider_config_id || '') + ':' + p.model_id
) )
); );
@@ -792,7 +792,7 @@ Object.assign(UI, {
} }
const data = await API.listEnabledModels(); 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) { if (!models.length) {
el.innerHTML = '<div class="empty-hint">No models available</div>'; el.innerHTML = '<div class="empty-hint">No models available</div>';
return; return;