Changeset 0.28.0.9 (#181)
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:[]}', () => {
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = '<div class="empty-hint">No models available</div>';
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user