diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a296989..a1dcb78 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -43,7 +43,7 @@ Three Docker images support different deployment scenarios:
4. **Scope Model**: Provider configs, personas, and model settings all use a three-value `scope` column: `global` (admin-managed, visible to all), `team` (team-admin-managed, visible to team), `personal` (user-managed, visible to owner). The `owner_id` column points to the owning user or team depending on scope.
-5. **Capabilities Resolution**: Model capabilities (vision, tool calling, thinking, context window) are resolved through a priority chain: catalog DB → known model table → heuristic inference. This ensures every model has capabilities even if the provider API doesn't report them.
+5. **Capabilities Resolution**: Model capabilities (vision, tool calling, thinking, context window) are resolved through a priority chain: catalog DB (provider API sync, per-provider authoritative) → heuristic inference (regex patterns on model ID). No static model table — the same model can have different capabilities on different providers. The catalog is populated by auto-fetch on provider creation and manual refresh.
## Package Structure
@@ -70,7 +70,7 @@ server/
│ └── ...
├── models/models.go # Shared domain types
├── capabilities/
-│ ├── intrinsic.go # Known model table + heuristics
+│ ├── intrinsic.go # Heuristic detection + resolution
│ └── resolver.go # ModelsForUser() unified resolver
├── handlers/ # HTTP handlers (Gin)
│ ├── auth.go # Login, register, refresh, logout
@@ -133,9 +133,7 @@ When the system needs to know what a model can do (vision? tools? thinking?):
↓ miss
2. model_catalog DB (any provider: same model_id)
↓ miss
-3. Known model table (static, curated in capabilities/intrinsic.go)
- ↓ miss
-4. Heuristic inference (name-based: "gpt-4-vision" → vision=true)
+3. Heuristic inference (name-based: "gpt-4-vision" → vision=true)
```
The `capabilities.ModelsForUser()` function combines catalog entries, team personas, and user preferences into a single unified model list for the frontend.
@@ -158,8 +156,6 @@ Single consolidated migration (`001_v09_schema.sql`) replaces the previous 21 in
2. Checks which migration files have been applied
3. Applies any new `.sql` files in order
-For v0.8 → v0.9 upgrades, a future `002_v08_to_v09.sql` migration will handle the transition. Fresh installs use `001_v09_schema.sql` directly.
-
## Frontend Architecture
Vanilla JavaScript, no build step. Five files with clear responsibilities:
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..4db56c3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,68 @@
+# Changelog
+
+All notable changes to Chat Switchboard.
+
+## [0.9.1] — 2026-02-23
+
+### Removed
+- **Static known model table**: Deleted `knownModels` map from backend and
+ `KNOWN_MODELS` from frontend. The same model ID can have different capabilities
+ depending on the provider (e.g. DeepSeek has tool_calling on OpenRouter but
+ not on Venice). A hardcoded table can't represent this.
+- **Frontend `lookupKnownCaps()`**: Removed client-side capability guessing.
+ Backend is the sole source of truth via catalog → heuristic chain.
+
+### Changed
+- **Resolution chain simplified**: catalog (provider API sync) → heuristic inference.
+ No intermediate known table. Providers that report capabilities via API are
+ authoritative; heuristics are best-effort for unsynced models.
+- **All providers updated**: OpenAI, OpenRouter, Anthropic, Venice now call
+ `InferCapabilities()` directly instead of the dead known table lookup.
+- **Frontend `resolveCapabilities()`**: Now passes through backend caps as-is.
+ No client-side merging with a static table.
+- [x] EXTENSIONS.md recovered into repo, updated with Appendix A (Custom
+ Renderers) and Appendix B (Model Roles with utility/embedding/generation slots)
+- [x] ROADMAP.md restructured: extension foundation pulled to v0.11.0,
+ model roles to v0.10.0, dependency graph, TBD replaces post-1.0,
+ removed v0.8→v0.9 migration (OBE — no public release, no test path)
+
+### Added
+- **Heuristic patterns**: Updated to detect qwen3, gpt-5, grok, kimi, minimax,
+ glm-5, gemma-3 model families. Vision expanded to claude-opus/sonnet (not
+ just claude-3). Reasoning expanded for thinking, grok, glm patterns.
+
+### Fixed
+- **Preset capability pills**: Presets with auto-resolve (no `provider_config_id`)
+ now inherit base model capabilities via `GetByModelIDAny` catalog fallback.
+- **Venice `optimizedForCode`**: Added mapping to `CodeOptimized` capability.
+- **CI test stability**: BYOK journey tests use unreachable endpoints so auto-fetch
+ doesn't race with simulated data injection.
+
+## [0.9.0] — 2026-02-22
+
+### Added
+- **Schema consolidation**: 21 migrations collapsed to single `001_initial.sql`
+- **Store layer**: All database access through typed interfaces (no raw SQL in handlers)
+- **Persona model**: Trust-boundary model replacing old presets; scoped global/team/personal
+- **Capabilities resolver**: Three-tier chain — catalog → known table → heuristic inference
+- **Three-state model visibility**: enabled / disabled / team-only
+- **BYOK auto-fetch**: Creating a personal provider triggers model discovery from provider API
+- **User model refresh**: `POST /api-configs/:id/models/fetch` endpoint + UI button
+- **Composite model IDs**: `configId:modelId` format prevents cross-provider collisions
+- **Audit log foundation**: All admin operations logged with actor, action, resource
+- **Journey integration tests**: API-driven test suite replacing fake-data tests
+- **Frontend test suite**: 107 tests, 27 suites validating model processing pipeline
+- **Live Venice API test**: Proves real BYOK → auto-fetch → models visible flow
+
+### Fixed
+- **API key storage**: `json:"-"` tag on `ProviderConfig.APIKeyEnc` silently dropped
+ keys during admin create/update. Fixed with wrapper structs that bypass the tag.
+- **NULL model_default scan**: `scanProviders()` crashed on NULL `model_default` column,
+ silently hiding all team and BYOK models. Fixed with `sql.NullString`.
+- **Nil slice serialization**: Go nil slices serialized as JSON `null` instead of `[]`,
+ breaking frontend fallback chains. Fixed with `make([]T, 0)`.
+- **Frontend error swallowing**: API responses with `errors` field were silently ignored.
+
+### Changed
+- Backward-compatible API routes with v0.8 field name aliases
+- User model preferences table (`user_model_settings`)
diff --git a/EXTENSIONS.md b/EXTENSIONS.md
new file mode 100644
index 0000000..d440160
--- /dev/null
+++ b/EXTENSIONS.md
@@ -0,0 +1,685 @@
+# Chat Switchboard — Extension System Specification
+
+**Version:** 0.2
+**Status:** Design (pre-implementation)
+**See also:** [ARCHITECTURE.md](ARCHITECTURE.md), [ROADMAP.md](ROADMAP.md)
+
+---
+
+## 1. Philosophy
+
+Chat Switchboard is a substrate, not an application. The core provides:
+authentication, provider routing, message persistence, an event bus, and a
+rendering surface. Everything else — editing, writing, cluster management,
+cost tracking, custom renderers — is an extension.
+
+The goal is that someone with a problem and some JS (or Go, or Python) can
+solve it without forking the project. The "modes" — Editor, Article, Chat,
+Cluster Manager — are extensions that register surfaces, tools, and event
+handlers. This project doesn't have to build all of them. It has to make
+them possible.
+
+---
+
+## 2. Extension Tiers
+
+| | Tier 0: Browser | Tier 1: Starlark | Tier 2: Sidecar |
+|---|---|---|---|
+| **Runs in** | User's browser | Go server (embedded) | Separate container |
+| **Language** | JavaScript | Starlark (Python subset) | Any |
+| **Deployed by** | User or Admin push | Admin | Admin |
+| **Latency** | Zero (client-side) | Low (in-process) | Network hop |
+| **Can access** | DOM, EventBus, user context | Message data, DB reads (sandboxed) | Anything (HTTP, filesystem, network) |
+| **Cannot access** | Server internals, other users' data | Network, filesystem, raw SQL | N/A (full access) |
+| **Trust model** | Same-origin; user-scoped or admin-pushed | Starlark sandbox (no I/O) | Container isolation |
+| **Use cases** | UI, rendering, shortcuts, client tools, modes | Routing rules, message transforms, logging | RAG, external APIs, webhooks, heavy compute |
+
+All three tiers communicate through the EventBus. A browser extension
+publishes `tool.result.{callId}` the same way a sidecar does — the bus
+doesn't care where the event originated.
+
+**Admin-pushed vs User-installed (Tier 0):**
+Admin-pushed extensions load for all users (like a managed browser
+extension policy). User-installed extensions load from user settings
+and only affect that user's session. Both use the same runtime, same
+manifest format. The difference is governance, not execution.
+
+---
+
+## 3. Manifest Format
+
+Every extension, regardless of tier, is described by a manifest:
+
+```json
+{
+ "id": "cost-tracker",
+ "name": "Cost Tracker",
+ "version": "1.0.0",
+ "tier": "browser",
+ "author": "jeff",
+ "description": "Real-time token counting and cost estimation",
+
+ "permissions": [
+ "events:chat.message.*",
+ "events:model.selected",
+ "dom:input-area",
+ "storage:local"
+ ],
+
+ "entry": "cost-tracker.js",
+
+ "hooks": {
+ "chat.message.send": { "priority": 10, "async": false },
+ "chat.message.received": { "priority": 50, "async": true }
+ },
+
+ "tools": [],
+ "surfaces": [],
+
+ "settings": {
+ "showInline": {
+ "type": "boolean",
+ "label": "Show cost inline",
+ "default": true
+ }
+ }
+}
+```
+
+### 3.1 Fields
+
+- **id**: Unique identifier. Namespaced by convention (`jeff.cost-tracker`).
+- **tier**: `browser` | `starlark` | `sidecar`
+- **permissions**: What the extension needs. The loader enforces these.
+ Undeclared access is blocked (Tier 0 via proxy, Tier 1 via sandbox,
+ Tier 2 via API scoping).
+- **entry**: Browser: JS file path. Starlark: `.star` file. Sidecar:
+ endpoint URL or Docker image.
+- **hooks**: EventBus events this extension subscribes to, with priority
+ (lower = runs first) and whether the hook is async.
+- **tools**: LLM-callable tools this extension provides (see §5).
+- **surfaces**: UI surfaces this extension registers (see §6).
+- **settings**: User-configurable options, rendered as a form in the
+ extension settings UI.
+
+---
+
+## 4. Browser Extensions (Tier 0)
+
+### 4.1 Lifecycle
+
+```
+Install → Load → Init → Active → Disable → Unload
+```
+
+**Install**: Admin pushes manifest + JS to server, or user adds from
+settings. Stored in `extensions` table with `tier = 'browser'`.
+
+**Load**: On page load, after `events.js` but before `app.js`, the
+extension loader injects `
+
+
+
+
+
+
+
+
+
+
+```
+
+### 7.2 extensions.js (Core)
+
+The extension loader and registry. ~200 lines. Responsibilities:
+
+- Fetch enabled extensions from `/api/v1/extensions?tier=browser`
+- Inject script tags in dependency order
+- Provide `Extensions.register()` API
+- Build scoped `ctx` objects per extension (permission enforcement)
+- Manage surface activation/deactivation
+- Collect browser tool schemas for the completion handler
+- Route `tool.call.*` events to the correct handler
+
+### 7.3 Backend Support
+
+New tables (migration):
+
+```sql
+CREATE TABLE extensions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ ext_id VARCHAR(100) NOT NULL UNIQUE, -- manifest id
+ name VARCHAR(200) NOT NULL,
+ tier VARCHAR(20) NOT NULL DEFAULT 'browser',
+ manifest JSONB NOT NULL,
+ assets_path TEXT, -- filesystem path for browser JS
+ endpoint TEXT, -- URL for sidecar
+ script TEXT, -- inline Starlark source
+ installed_by UUID REFERENCES users(id),
+ is_system BOOLEAN DEFAULT false,
+ is_enabled BOOLEAN DEFAULT true,
+ scope VARCHAR(20) DEFAULT 'global', -- global, team, personal
+ team_id UUID REFERENCES teams(id),
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE TABLE extension_user_settings (
+ extension_id UUID REFERENCES extensions(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE CASCADE,
+ settings JSONB DEFAULT '{}',
+ is_enabled BOOLEAN DEFAULT true,
+ PRIMARY KEY (extension_id, user_id)
+);
+```
+
+New endpoints:
+
+```
+GET /api/v1/extensions — list enabled for current user
+GET /api/v1/extensions/:id/manifest — get manifest
+GET /api/v1/extensions/:id/assets/*path — serve browser JS
+POST /api/v1/extensions/:id/settings — update user settings
+
+POST /api/v1/admin/extensions — install extension
+DELETE /api/v1/admin/extensions/:id — uninstall
+PUT /api/v1/admin/extensions/:id — enable/disable, config
+```
+
+---
+
+## 8. EventBus Integration
+
+The routing table from `events/types.go` expands:
+
+```go
+var Routes = map[string]Direction{
+ // Core
+ "chat.message.*": DirBoth,
+ "user.presence": DirToClient,
+
+ // Tool execution
+ "tool.call.*": DirToClient, // Server → specific client
+ "tool.result.*": DirFromClient, // Client → server
+
+ // Surfaces
+ "surface.activated": DirLocal, // Client-only
+ "surface.deactivated": DirLocal,
+
+ // Extension lifecycle
+ "extension.loaded": DirLocal,
+ "extension.error": DirLocal,
+
+ // Cross-extension (cluster manager example)
+ "cluster.node.*": DirLocal, // Between browser extensions
+ "cluster.alert.*": DirBoth, // Could notify server too
+}
+```
+
+Browser extensions use `Events.on()` and `Events.emit()` — the same API
+the core app uses. `DirLocal` events stay in the browser. `DirBoth` and
+`DirFromClient` events cross the WebSocket.
+
+---
+
+## 9. Built-in Tools
+
+These ship with core because multiple modes and services depend on them:
+
+| Tool | Tier | Description |
+|---|---|---|
+| `web_search` | Sidecar | Search provider abstraction (SearXNG, Brave, DuckDuckGo) |
+| `url_fetch` | Server | Retrieve and extract content from a URL |
+| `note_create` | Server | Create a note with title, content, folder, tags |
+| `note_search` | Server | Full-text search across user's notes |
+| `note_update` | Server | Update note content |
+| `note_list` | Server | List notes with optional folder filter |
+| `kb_search` | Server | Semantic search across knowledge bases (future) |
+| `task_create` | Server | Schedule a new task (future) |
+
+Extension-provided tools (not core, expected early extensions):
+- `read_file`, `write_file`, `search_replace` (Editor mode)
+- `kubectl_get`, `ceph_status`, `node_ssh` (Cluster manager)
+- `generate_image` (Image gen, browser or sidecar)
+- `speak_text`, `transcribe_audio` (STT/TTS, browser via Web Speech API)
+
+---
+
+## 10. Design Principles
+
+1. **Extensions are first-class.** A mode or feature implemented as an
+ extension is indistinguishable from one built into core. No second-class
+ citizens.
+
+2. **The EventBus is the spine.** Extensions don't import each other.
+ They publish and subscribe to events. This is how cluster manager
+ extensions cooperate without knowing about each other at build time.
+
+3. **Tools are location-transparent.** The LLM sees a tool schema. It
+ doesn't know if the tool runs in the browser, in a Starlark sandbox,
+ or in a container. The routing is the platform's problem.
+
+4. **Permissions are declared, not discovered.** An extension says what it
+ needs upfront. The loader enforces it. No ambient authority.
+
+5. **The core stays small.** Auth, provider routing, message persistence,
+ event bus, extension loader. That's core. Everything else is an
+ extension — even if it ships with the project.
+
+6. **Progressive capability.** A browser extension with zero tools and
+ zero surfaces is just an EventBus subscriber. Add a tool and the LLM
+ can call it. Add a surface and it becomes a mode. The same manifest
+ format scales from "show token count" to "full IDE."
+
+---
+
+## Appendix A: Custom Renderers
+
+Custom renderers are the simplest useful browser extension. They intercept
+message content rendering to handle specific content types — no tools, no
+surfaces, just a pattern match and a render function.
+
+```js
+Extensions.register({
+ id: 'collapsible-code',
+ init(ctx) {
+ ctx.renderers.register('collapsible-code', {
+ // Match fenced code blocks with >10 lines
+ pattern: /^```(\w+)?\n([\s\S]{10,}?)```$/gm,
+ render(match, container) {
+ const lang = match[1] || 'text';
+ const code = match[2];
+ const lines = code.split('\n');
+ const details = document.createElement('details');
+ details.innerHTML = `
+ ${lang} — ${lines.length} lines
+
${escapeHtml(code)}
+ `;
+ container.replaceWith(details);
+ }
+ });
+ }
+});
+```
+
+Expected first-party renderer extensions (ship with core or as official):
+
+| Renderer | Matches | Renders |
+|---|---|---|
+| `collapsible-code` | Code blocks > N lines | `` with expand/collapse |
+| `html-preview` | ```html blocks | Sandboxed iframe with live preview |
+| `mermaid` | ```mermaid blocks | SVG diagram via mermaid.js |
+| `latex-math` | `$...$` and `$$...$$` | Rendered math via KaTeX |
+| `doc-preview` | Generated HTML/PDF content | Preview panel with download link |
+
+These are all ~30-50 line browser extensions. They don't need tool calling
+or surfaces. They just pattern-match content in rendered messages.
+
+---
+
+## Appendix B: Model Roles
+
+Extensions (and core services like compaction) may need to call LLMs for
+their own purposes — summarization, embedding, classification — independent
+of the user's selected chat model. **Model roles** are named slots that
+resolve to a specific provider+model at runtime.
+
+### B.1 Role Definition
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Role │ Purpose │ Typical Model │
+├──────────────────────────────────────────────────────────────┤
+│ utility │ Summarization, routing │ gpt-4o-mini │
+│ │ classification, triage │ deepseek-chat │
+│ embedding │ Vector generation for │ text-embedding-3 │
+│ │ KB search, note search │ nomic-embed-text │
+│ generation │ Image/media generation │ dall-e-3 │
+│ │ │ stable-diffusion │
+└──────────────────────────────────────────────────────────────┘
+```
+
+### B.2 Configuration
+
+Admin configures roles in global settings, with optional fallback chain:
+
+```json
+{
+ "model_roles": {
+ "utility": {
+ "primary": { "provider_config_id": "abc", "model_id": "gpt-4o-mini" },
+ "fallback": { "provider_config_id": "def", "model_id": "deepseek-chat" }
+ },
+ "embedding": {
+ "primary": { "provider_config_id": "abc", "model_id": "text-embedding-3-small" },
+ "fallback": null
+ }
+ }
+}
+```
+
+Teams can override roles for their scope. Personal overrides TBD.
+
+### B.3 Extension API
+
+```js
+// Browser extension requests a utility completion
+const result = await ctx.models.complete('utility', {
+ messages: [{ role: 'user', content: 'Summarize this in 2 sentences: ...' }]
+});
+
+// Server-side: extension requests an embedding
+vec, err := models.Embed(ctx, "embedding", "text to embed")
+```
+
+The caller doesn't know which model or provider is being used. The role
+system resolves it, tries fallback if primary fails, and logs usage
+against the role for cost tracking.
+
+### B.4 Core Consumers
+
+| Consumer | Role | Purpose |
+|---|---|---|
+| Compaction service | `utility` | Summarize long conversations |
+| Smart routing | `utility` | Classify intent, pick best model |
+| Knowledge bases | `embedding` | Generate vectors for similarity search |
+| Note search (future) | `embedding` | Semantic search across notes |
+| Image gen extension | `generation` | Create images from descriptions |
+
+### B.5 Relationship to Smart Routing
+
+Model roles and smart routing are complementary:
+
+- **Model roles**: "I need *a* cheap model for summarization" → resolves
+ to a specific provider+model based on admin config.
+- **Smart routing**: "Route *this user's chat* to the best model" → policy
+ engine that picks based on capabilities, cost, latency, team rules.
+
+Roles are for background/system tasks. Routing is for user-facing chat.
+Both use the same provider infrastructure and cost tracking.
diff --git a/README.md b/README.md
index ec2165a..80243c2 100644
--- a/README.md
+++ b/README.md
@@ -90,8 +90,8 @@ docker compose --profile dev up # include Adminer DB UI
Build and push both images:
```bash
-docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.0 server/
-docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.0 .
+docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.1 server/
+docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.1 .
```
**Backend deployment:**
@@ -99,7 +99,7 @@ docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.0 .
```yaml
containers:
- name: api
- image: your-registry/switchboard-api:0.9.0
+ image: your-registry/switchboard-api:0.9.1
ports:
- containerPort: 8080
env:
@@ -119,7 +119,7 @@ containers:
```yaml
containers:
- name: frontend
- image: your-registry/switchboard-fe:0.9.0
+ image: your-registry/switchboard-fe:0.9.1
ports:
- containerPort: 80
env:
@@ -194,9 +194,20 @@ All endpoints under `/api/v1/`. Authentication via `Authorization: Bearer .` — hotfixes use quad: `0.x.y.z`
+No compatibility guarantees before 1.0.
+
+---
+
+## Dependency Graph
+
+Features have real dependencies. This ordering respects them.
+
+```
+v0.9.x Stability + Quick UX Wins
+ │
+v0.9.3 API Key Encryption + Vault
+ │
+v0.10.0 Model Roles (utility + embedding) + Usage Tracking
+ │
+v0.10.1 Summarize & Continue (first utility role consumer)
+ │
+ ┌───────┴──────────────┐
+ │ │
+v0.11.0 Extension v0.12.0 File Handling
+ Foundation + Vision
+ (browser tier) │
+ │ │
+ ├── Custom renderers ├── Doc gen outputs
+ ├── Image gen tool ├── KB documents
+ ├── STT/TTS │
+ │ │
+ ├───────────────────────┤
+ │ │
+v0.13.0 Web Search + Tools Expansion
+ │
+v0.14.0 Knowledge Bases (embedding role + file storage + pgvector)
+ │
+v0.15.0 Compaction (utility role + background job)
+ │
+v0.16.0 @mention Routing + Multi-model
+ │
+v0.17.0 Extension Surfaces + Modes
+ │
+v0.18.0 Smart Routing (policy on model roles)
+ │
+v0.19.0 Live Collaboration (presence, co-editing)
+ │
+v0.20.0 Auth Strategy (mTLS/OIDC) + Full RBAC
+ │
+v0.21.0 Tasks / Autonomous Agents
+```
+
+---
+
+## ✅ v0.9.0 — Schema Consolidation + BYOK
+
+The "rewrite" release: 21 migrations collapsed to 1, store layer abstraction,
+and the model pipeline made real for end users.
+
+**Schema & Architecture**
+- [x] Consolidated schema (21 migrations → single 001_initial.sql)
+- [x] Store layer abstraction (all DB via typed interfaces, no raw SQL in handlers)
+- [x] Persona-as-trust-boundary model (replaces old presets concept)
- [x] Scope model (global/team/personal) for configs, personas, models
-- [x] Capabilities resolver chain (catalog → known → heuristic)
-- [x] Three-state model visibility (enabled/disabled/team-only)
-- [x] User model preferences (hide, sort)
-- [x] Audit log foundation
-- [x] Backward-compatible API routes
+- [x] Backward-compatible API routes (v0.8 → v0.9 field aliases)
-## v0.9.1 (Next)
+**Capabilities System**
+- [x] Two-tier resolution: catalog (provider API, per-provider) → heuristic inference
+- [x] Venice provider: full capability mapping incl. `optimizedForCode`
+- [x] `ResolveIntrinsic` with gap-fill merging (catalog authoritative, heuristic fills gaps)
+- [x] Preset capability inheritance via `GetByModelIDAny` fallback
-- [ ] Chat search (full-text search across messages)
-- [ ] Keyboard shortcuts + command palette (Ctrl+K)
-- [ ] PWA enhancements (offline indicator, install prompt)
-- [ ] API key at-rest encryption
-- [ ] v0.8 → v0.9 migration script (002_v08_to_v09.sql)
+**BYOK (Bring Your Own Key)**
+- [x] Auto-fetch models on provider creation
+- [x] User-facing refresh endpoint + "Refresh Models" button
+- [x] Personal models in selector with `scope=personal` badge
+- [x] Composite model IDs (`configId:modelId`) prevent cross-provider collisions
-## v0.10
+**Bug Fixes (0.9.0.x)**
+- [x] API key storage (`json:"-"` tag silently dropped keys)
+- [x] NULL model_default scan crash (sql.NullString)
+- [x] Nil slice → JSON null (make([]T, 0))
+- [x] Frontend error swallowing
-- [ ] Grant management UI (admin assigns model access per team)
-- [ ] Model visibility toggle in admin panel
-- [ ] Team/group notes (shared note folders)
-- [ ] Conversation export (markdown, JSON)
-- [ ] Plugin system (tool registration API)
+**Testing**
+- [x] Journey-based integration tests (API-only, 4-actor × 3-provider matrix)
+- [x] Live Venice API integration test
+- [x] Frontend JS test suite (107 tests, 27 suites)
-## v0.11
+---
-- [ ] OIDC/Keycloak authentication mode
-- [ ] mTLS client certificate authentication
-- [ ] SSO integration patterns
-- [ ] Rate limiting per user/team (configurable quotas)
+## ✅ v0.9.1 — Capability Architecture + Docs
-## Future
+- [x] Removed static known model table (same model ≠ same capabilities across providers)
+- [x] Resolution chain: catalog → heuristic only, no hardcoded table
+- [x] Frontend KNOWN_MODELS removed — backend is sole source of truth
+- [x] Heuristic patterns expanded (qwen3, gpt-5, grok, kimi, minimax, glm-5, gemma-3)
+- [x] All providers updated to use `InferCapabilities()` directly
+- [x] EXTENSIONS.md recovered and updated with Appendix A (Custom Renderers)
+ and Appendix B (Model Roles)
+- [x] ROADMAP.md, CHANGELOG.md, README.md comprehensive rewrite
-- [ ] SQLite backend option (for single-user / dev deployments)
-- [ ] Multi-model conversations (different models per message)
-- [ ] Conversation templates (reusable multi-turn starters)
-- [ ] Agent mode (multi-step tool use with human-in-the-loop)
-- [ ] Model cost tracking and reporting
+---
+
+## v0.9.2 — Quick UX Wins + Hardening
+
+Polish that doesn't need new infrastructure. Ship fast, stabilize.
+
+**UX**
+- [x] Collapsible code blocks (long outputs get `` wrapper)
+- [x] HTML preview for generated content (sandboxed iframe toggle)
+- [x] Token count estimate in input area (before send)
+- [x] "Conversation is getting long" warning (context % thresholds)
+
+**Hardening**
+- [x] Proxy interception detection (Content-Type checks, actionable error messages)
+- [x] Environment injection to frontend (`window.__ENV__`)
+- [x] Enhanced debug diagnostics (NET:PROXY log type, Content-Type capture, env info)
+- [ ] Team admin audit scoping (see only their team's audit entries)
+
+---
+
+## v0.9.3 — API Key Encryption + Vault
+
+Two-tier encryption with a trust boundary between organizational keys (admin-
+accessible) and personal BYOK keys (user-only). Personal keys are protected
+by a per-user encryption key (UEK) that the platform admin cannot recover
+without the user's passphrase.
+
+### Threat Model
+
+| Threat | Global/Team keys | Personal BYOK keys |
+|--------|-----------------|---------------------|
+| DB backup theft | ✅ Protected (env-var AES) | ✅ Protected (UEK AES) |
+| SQL injection exfil | ✅ Protected | ✅ Protected |
+| Admin reads DB directly | ⚠️ Admin can decrypt (owns env var) | ✅ Admin cannot decrypt (lacks user passphrase) |
+| Admin modifies server code | ⚠️ Out of scope (runtime access) | ⚠️ Out of scope (runtime access) |
+
+### Crypto Design
+
+**Two encryption paths, one mechanism (AES-256-GCM):**
+
+1. **Global/Team keys** — encrypted with a key derived from `ENCRYPTION_KEY`
+ env var. Admin-owned, admin-recoverable. Simple.
+
+2. **Personal keys** — encrypted with a per-user **User Encryption Key (UEK)**.
+ The UEK is a random 256-bit key generated at account creation, itself
+ encrypted with a key derived from the user's password via Argon2id.
+
+```
+Password → Argon2id(salt) → Password-Derived Key (PDK)
+PDK → AES-256-GCM-Decrypt(encrypted_uek) → UEK (plaintext, session-only)
+UEK → AES-256-GCM-Decrypt(api_key_enc) → API key (plaintext, per-request)
+```
+
+The UEK lives in server memory for the duration of the session (alongside the
+JWT). It is never written to disk, logs, or the database in plaintext.
+
+### Schema Changes (migration)
+
+```sql
+-- Users: vault support
+ALTER TABLE users ADD COLUMN encrypted_uek BYTEA;
+ALTER TABLE users ADD COLUMN uek_salt BYTEA; -- Argon2id salt
+ALTER TABLE users ADD COLUMN uek_nonce BYTEA; -- GCM nonce for UEK wrap
+ALTER TABLE users ADD COLUMN vault_set BOOLEAN NOT NULL DEFAULT false;
+
+-- API configs: encrypted key storage
+ALTER TABLE api_configs ADD COLUMN api_key_enc BYTEA;
+ALTER TABLE api_configs ADD COLUMN key_nonce BYTEA;
+ALTER TABLE api_configs ADD COLUMN key_scope TEXT NOT NULL DEFAULT 'global';
+-- key_scope: 'global' | 'team' | 'personal'
+-- global/team: decrypt with env-var-derived key
+-- personal: decrypt with user's UEK
+
+-- Team providers: same pattern
+ALTER TABLE team_providers ADD COLUMN api_key_enc BYTEA;
+ALTER TABLE team_providers ADD COLUMN key_nonce BYTEA;
+```
+
+Migration backfills: encrypt existing plaintext keys using the env var key
+(all existing keys are global/team scope). Drop plaintext `api_key` column
+after backfill. If `ENCRYPTION_KEY` is not set, migration refuses to run
+(fail-safe).
+
+### Auth Mode Integration
+
+**Builtin auth** — password serves double duty: authentication + UEK
+derivation. No additional UX. On login, derive PDK → unwrap UEK → cache
+in session. User never knows the vault exists.
+
+**mTLS / OIDC** (v0.20.0) — authentication is external (cert/token).
+Vault passphrase is conditionally required:
+
+```
+External auth → auto-authenticated
+ ├── Personal providers DISABLED → straight to app, no prompt
+ └── Personal providers ENABLED
+ ├── First visit → "Set a vault passphrase to protect your API keys"
+ └── Returning → "Unlock your vault" (passphrase field, pre-filled username)
+```
+
+Terminology: **"vault passphrase"** for mTLS/OIDC users (distinct from their
+non-existent account password). Builtin auth users never see this term.
+
+### Backend Implementation
+
+**`server/crypto/vault.go`** — pure encryption/decryption, no DB awareness:
+- `DeriveKey(password, salt) → key` (Argon2id, 64MB memory, 3 iterations)
+- `GenerateUEK() → (uek, error)` (crypto/rand, 32 bytes)
+- `WrapUEK(uek, pdk) → (ciphertext, nonce, error)`
+- `UnwrapUEK(ciphertext, nonce, pdk) → (uek, error)`
+- `Encrypt(plaintext, key) → (ciphertext, nonce, error)`
+- `Decrypt(ciphertext, nonce, key) → (plaintext, error)`
+- `DeriveEnvKey(envVar) → key` (SHA-256 of env var, simple deterministic)
+
+**Session UEK caching** — UEK stored in a sync.Map keyed by user ID, evicted
+on logout / token expiry. Completion handler retrieves UEK from cache to
+decrypt personal provider keys at request time.
+
+**Provider resolution** — `key_scope` determines decryption path:
+```go
+func (s *Store) DecryptAPIKey(config ApiConfig, uekCache *sync.Map) (string, error) {
+ switch config.KeyScope {
+ case "global", "team":
+ return crypto.Decrypt(config.APIKeyEnc, config.KeyNonce, envDerivedKey)
+ case "personal":
+ uek, ok := uekCache.Load(config.UserID)
+ if !ok { return "", ErrVaultLocked }
+ return crypto.Decrypt(config.APIKeyEnc, config.KeyNonce, uek.([]byte))
+ }
+}
+```
+
+### Edge Cases
+
+| Scenario | Behavior |
+|----------|----------|
+| **User changes password** (builtin) | Re-derive PDK, re-encrypt UEK with new PDK. Personal API keys unchanged (keyed to UEK, not password). |
+| **Admin resets user password** | Old UEK unrecoverable. Personal keys destroyed. Admin UI warns before confirming. |
+| **mTLS user forgets passphrase** | Self-service reset: destroys encrypted personal keys. UX: "This will erase your stored API keys." |
+| **Admin disables personal providers** | Keys stay encrypted in DB (inert). Re-enable later → keys accessible if user remembers passphrase. |
+| **`ENCRYPTION_KEY` not set** | Startup refuses to run if encrypted keys exist in DB. Fresh install: keys stored plaintext with deprecation warning in logs + admin panel. |
+| **`ENCRYPTION_KEY` rotated** | Admin CLI tool: `switchboard vault rekey` — re-encrypts all global/team keys with new env key. Personal keys unaffected (keyed to UEK). |
+
+### Frontend Changes
+
+- Provider management (Settings → Providers): no change to UX for builtin auth
+- mTLS/OIDC splash: conditional vault passphrase prompt (new component)
+- Admin → Users → Reset Password: warning dialog about personal key destruction
+- Admin → Settings: indicator for `ENCRYPTION_KEY` status (set / not set)
+
+### Checklist
+
+**Crypto core**
+- [ ] `server/crypto/vault.go` — Argon2id derivation, AES-256-GCM wrap/unwrap
+- [ ] `server/crypto/vault_test.go` — round-trip, wrong-password, corruption
+- [ ] Env-var key derivation + validation on startup
+
+**Schema + migration**
+- [ ] Migration: add encrypted columns, backfill existing keys, drop plaintext
+- [ ] `ENCRYPTION_KEY` enforcement (refuse to start if keys exist but no env var)
+
+**Auth integration**
+- [ ] UEK generation on user creation (builtin auth)
+- [ ] UEK unwrap on login, cache in session
+- [ ] UEK re-wrap on password change
+- [ ] UEK destruction on admin password reset (with confirmation)
+- [ ] Vault passphrase prompt for mTLS/OIDC (when personal providers enabled)
+
+**Provider key lifecycle**
+- [ ] Encrypt on provider create/update
+- [ ] Decrypt on completion request (per key_scope)
+- [ ] `ErrVaultLocked` handling (prompt user to unlock)
+
+**Admin tooling**
+- [ ] `switchboard vault rekey` CLI command
+- [ ] Admin UI: encryption status indicator
+- [ ] Admin UI: password reset warning
+
+---
+
+Two pieces of infrastructure that everything downstream depends on.
+
+**Model Roles** (see [EXTENSIONS.md Appendix B](EXTENSIONS.md#appendix-b-model-roles))
+- [ ] `model_roles` in global_settings: named slots (utility, embedding, generation)
+- [ ] Each role: primary provider+model, optional fallback provider+model
+- [ ] Team-level role overrides (team.settings JSONB)
+- [ ] Backend API: `roles.Complete(ctx, "utility", messages)` with automatic fallback
+- [ ] Backend API: `roles.Embed(ctx, "embedding", text)` with automatic fallback
+- [ ] Admin UI: role configuration (select provider + model per role)
+
+**Usage Tracking**
+- [ ] Capture from provider responses: prompt_tokens, completion_tokens,
+ cache_creation_tokens, cache_read_tokens
+- [ ] `usage_log` table: channel_id, user_id, model_id, provider_id,
+ role (nullable — "utility", "embedding", or NULL for user chat),
+ token counts, timestamp
+- [ ] Model pricing from catalog sync (Venice/OpenAI report pricing)
+- [ ] Admin override pricing (per M tokens, decimal)
+- [ ] Cost calculated at query time (join usage × pricing)
+- [ ] Per-user and per-team usage dashboard in admin panel
+
+---
+
+## v0.10.1 — Summarize & Continue
+
+First consumer of the utility model role. User-triggered conversation
+compaction — not automatic (that's v0.15.0).
+
+- [ ] "Summarize and continue" button (appears alongside context length warning)
+- [ ] Calls utility role to summarize conversation history
+- [ ] Summary inserted as system message, earlier messages hidden from context
+- [ ] Original messages preserved in DB (viewable via "show full history" toggle)
+- [ ] Respects team-level role overrides for utility model selection
+
+---
+
+## v0.11.0 — Extension Foundation (Browser Tier)
+
+The platform play. See [EXTENSIONS.md](EXTENSIONS.md) for full spec.
+
+**Core Loader** (EXTENSIONS.md §4, §7)
+- [ ] `extensions.js` — loader, registry, scoped context (~200 lines)
+- [ ] `Extensions.register()` API with permission enforcement
+- [ ] Manifest format parser and validator
+- [ ] `extensions` + `extension_user_settings` tables
+- [ ] Admin endpoints: install, uninstall, enable/disable
+- [ ] Asset serving: `/api/v1/extensions/:id/assets/*path`
+- [ ] Extension settings UI in Settings modal
+
+**Browser Tool Bridge** (EXTENSIONS.md §5)
+- [ ] `ctx.tools.handle()` API for browser tool registration
+- [ ] Tool schema collection: merge built-in + extension tools
+- [ ] `tool.call.*` / `tool.result.*` WebSocket routing
+- [ ] 30-second timeout with LLM error recovery
+- [ ] Tool execution via EventBus `WaitFor` pattern
+
+**Custom Renderer API** (EXTENSIONS.md Appendix A)
+- [ ] `ctx.renderers.register()` — pattern + render function
+- [ ] Renderer pipeline in message display (after markdown, before DOM insert)
+- [ ] First-party renderers: collapsible code, HTML preview, mermaid, LaTeX
+ (migrate core v0.9.2 implementations to extension format)
+
+**First Extensions**
+- [ ] Image generation tool (browser or sidecar, uses generation model role)
+- [ ] STT/TTS (browser extension, Web Speech API — personal use case)
+
+---
+
+## v0.12.0 — File Handling + Vision
+
+File input into chat — table stakes for serious use.
+
+**Storage Backend**
+- [ ] S3-compatible API (MinIO, Ceph RGW, AWS — anything with S3 API)
+- [ ] Local PVC fallback (single-node / dev)
+- [ ] Admin config: backend selection, endpoint, credentials, size limits
+- [ ] Reused by KBs (v0.14.0) and compaction snapshots (v0.15.0)
+
+**Chat Integration**
+- [ ] `attachments` table (metadata in PG, blobs in object store)
+- [ ] Image/file upload in chat messages
+- [ ] Multimodal message assembly for vision-capable models
+- [ ] Text extraction on upload (PDF, DOCX, TXT → tsvector)
+- [ ] Paste-to-upload (clipboard image support)
+- [ ] Document generation output storage (extension → attachment → download)
+
+---
+
+## v0.13.0 — Web Search + Tools Expansion
+
+Built-in tools that use the tool framework shipped in v0.7.x.
+
+- [ ] `web_search` tool: search provider abstraction (SearXNG, Brave, DuckDuckGo)
+- [ ] `url_fetch` tool: retrieve and extract content from URLs
+- [ ] Sidecar or direct HTTP from backend (configurable)
+- [ ] Results injected into context
+- [ ] "Research X and save to my notes" workflow
+
+---
+
+## v0.14.0 — Knowledge Bases
+
+Depends on: embedding model role (v0.10.0), file storage (v0.12.0).
+
+- [ ] Embedding pipeline: chunking (recursive, semantic), generation via embedding role
+- [ ] `pgvector` storage and similarity search
+- [ ] `knowledge_bases` table with team scoping
+- [ ] KB document storage via v0.12.0 storage backend
+- [ ] `kb_search` tool (uses existing tool framework)
+- [ ] Context injection in completion flow
+- [ ] Notes embedded too (once pipeline exists)
+- [ ] Per-channel KB toggle
+
+---
+
+## v0.15.0 — Compaction
+
+Depends on: utility model role (v0.10.0).
+
+- [ ] Auto-compaction service: background job that calls utility model to summarize
+- [ ] Channel-scoped: triggers when context exceeds threshold
+- [ ] Summaries stored as system messages
+- [ ] Configurable: per-channel opt-in/out, summary model override
+- [ ] Admin controls for resource limits
+
+---
+
+## v0.16.0 — @mention Routing + Multi-model
+
+The channel schema already supports multiple models. This adds routing.
+
+- [ ] @mention parsing in messages (users and AI models)
+- [ ] Resolve mentions against `channel_models`
+- [ ] Multi-model channels: fire completions per mentioned model
+- [ ] "Add a model" UI per channel
+- [ ] Enables: second opinions, cross-model conversations
+
+---
+
+## v0.17.0 — Extension Surfaces + Modes
+
+Depends on: extension foundation (v0.11.0).
+See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes).
+
+- [ ] Surface registration and activation API
+- [ ] Mode selector in sidebar (below brand, above chat history)
+- [ ] `ctx.ui.replace()` / `ctx.ui.restore()` for region management
+- [ ] `surface.activated` / `surface.deactivated` events
+- [ ] Editor mode (extension: file tree + code editor + AI chat split pane)
+- [ ] Article mode (extension: outline + rich text + AI assistant panel)
+
+---
+
+## v0.18.0 — Smart Routing
+
+Depends on: model roles (v0.10.0), usage tracking (v0.10.0).
+Rules-based routing engine — policy, not ML.
+
+- [ ] Admin routing policies: "cheapest model with required capabilities",
+ "prefer private providers, fallback to cloud", "for team X, use Y"
+- [ ] Fallback chains: primary provider down → next with same model
+- [ ] Cost-aware: factor pricing into routing decisions
+- [ ] Latency-aware: track response times per provider, prefer faster
+
+---
+
+## v0.19.0 — Live Collaboration
+
+Depends on: extension surfaces (v0.17.0), WebSocket infrastructure (already exists).
+
+- [ ] Typing indicators and presence (who's online, who's in this channel)
+- [ ] Co-editing for extension surfaces (editor mode, article mode)
+- [ ] Enables: IDE extension with pair programming, collaborative doc editing
+- [ ] Cursor sharing, selection highlighting
+
+---
+
+## v0.20.0 — Auth Strategy (mTLS/OIDC) + Full RBAC
+
+Enterprise auth modes on top of the teams foundation.
+Depends on: vault passphrase support from v0.9.3 (conditional unlock for
+personal providers under external auth).
+
+- [ ] `AUTH_MODE` env var: `builtin` | `mtls` | `oidc`
+- [ ] All three resolve to the same internal user model
+- [ ] `auth_source` + `external_id` columns on users table
+- [ ] mTLS: header trust, auto-provision from cert DN
+- [ ] OIDC: Keycloak/Okta token validation, claim extraction, role mapping
+- [ ] Per-source auto-activate policy: auto_activate, default_team, default_role
+- [ ] Vault passphrase prompt for mTLS/OIDC users (v0.9.3 conditional unlock)
+- [ ] Fine-grained permissions: model access, KB write, task create, token budgets
+
+---
+
+## v0.21.0 — Tasks / Autonomous Agents
+
+The capstone: everything combined into autonomous workflows.
+
+- [ ] Scheduler + task runner
+- [ ] `task_create` tool
+- [ ] `type: 'service'` channels with no human members
+- [ ] Depends on: completion handler, tool execution, notes, web search
+- [ ] Admin controls for resource limits, execution budgets
+
+---
+
+## TBD (unscheduled — real features, no immediate need)
+
+Items that are real but don't yet have a version assignment. Pull left
+based on need.
+
+**Extension System — Server Tiers**
+- Starlark runtime integration (Tier 1 — server sandbox)
+- Sidecar HTTP tool protocol (Tier 2 — container isolation)
+- Server-side tool execution in completion handler
+
+**Desktop + Mobile**
+- Desktop app (Tauri)
+- Full PWA with offline capability
+- Mobile-optimized layouts (beyond current responsive)
+
+**Data + Portability**
+- Bulk export/import (account data, conversations, settings)
+- ChatGPT/other tool import
+- GDPR-style "download my data"
+- Backup/restore CronJob manifests
+
+**Platform**
+- Rate limiting per user/team/tier (token budgets)
+- Provider health monitoring + key rotation
+- Multi-tenant SaaS mode
+- Workflow builder (visual DAG for chaining models + tools)
+- Plugin/extension marketplace
+- Virtual scroll for long conversations
+- SQLite backend option (single-user / dev)
diff --git a/VERSION b/VERSION
index ac39a10..f514a2f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.9.0
+0.9.1
\ No newline at end of file
diff --git a/docker-entrypoint-fe.sh b/docker-entrypoint-fe.sh
index 4b5092c..605284b 100644
--- a/docker-entrypoint-fe.sh
+++ b/docker-entrypoint-fe.sh
@@ -36,11 +36,15 @@ else
echo "ℹ️ No branding mount — using defaults"
fi
+# ── Read environment name ─────────────────
+ENVIRONMENT="${ENVIRONMENT:-production}"
+
# ── Inject into index.html ──────────────────
sed -i \
-e "s|%%BASE_PATH%%|${BASE_PATH}|g" \
-e "s|%%BASE_HREF%%|${BASE_HREF}|g" \
-e "s|%%APP_VERSION%%|${APP_VERSION}|g" \
+ -e "s|%%ENVIRONMENT%%|${ENVIRONMENT}|g" \
-e "s|%%BRANDING_JSON%%|${BRANDING_JSON}|g" \
/usr/share/nginx/html/index.html
@@ -49,7 +53,7 @@ sed -i \
-e "s|%%APP_VERSION%%|${APP_VERSION}|g" \
/usr/share/nginx/html/sw.js
-echo "✅ Frontend configured: BASE_PATH=${BASE_PATH:-/} VERSION=${APP_VERSION}"
+echo "✅ Frontend configured: BASE_PATH=${BASE_PATH:-/} VERSION=${APP_VERSION} ENV=${ENVIRONMENT}"
# ── Generate nginx config ───────────────────
# If BASE_PATH is set, serve under that prefix with alias.
diff --git a/k8s/frontend.yaml b/k8s/frontend.yaml
index 53a81ce..227b834 100644
--- a/k8s/frontend.yaml
+++ b/k8s/frontend.yaml
@@ -38,6 +38,8 @@ spec:
env:
- name: BASE_PATH
value: "${BASE_PATH}"
+ - name: ENVIRONMENT
+ value: "${ENVIRONMENT}"
volumeMounts:
- name: branding
mountPath: /branding
diff --git a/server/capabilities/capabilities_test.go b/server/capabilities/capabilities_test.go
index 61cf4d8..8fb35fe 100644
--- a/server/capabilities/capabilities_test.go
+++ b/server/capabilities/capabilities_test.go
@@ -6,46 +6,15 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/models"
)
-func TestLookupKnownModel_ExactMatch(t *testing.T) {
- caps, ok := LookupKnownModel("gpt-4o")
- if !ok {
- t.Fatal("expected gpt-4o to be found")
- }
- if caps.MaxOutputTokens != 16384 {
- t.Errorf("gpt-4o max output: got %d, want 16384", caps.MaxOutputTokens)
- }
- if !caps.ToolCalling {
- t.Error("gpt-4o should support tool calling")
- }
- if !caps.Vision {
- t.Error("gpt-4o should support vision")
- }
-}
-
-func TestLookupKnownModel_PrefixMatch(t *testing.T) {
- caps, ok := LookupKnownModel("claude-sonnet-4-20250514")
- if !ok {
- t.Fatal("expected claude-sonnet-4-20250514 to match prefix claude-sonnet-4")
- }
- if caps.MaxOutputTokens != 16000 {
- t.Errorf("claude-sonnet-4 max output: got %d, want 16000", caps.MaxOutputTokens)
- }
-}
-
-func TestLookupKnownModel_ProviderPrefix(t *testing.T) {
- caps, ok := LookupKnownModel("anthropic/claude-sonnet-4-20250514")
- if !ok {
- t.Fatal("expected provider-prefixed model to match")
- }
- if caps.MaxOutputTokens != 16000 {
- t.Errorf("got %d, want 16000", caps.MaxOutputTokens)
- }
-}
-
-func TestLookupKnownModel_NotFound(t *testing.T) {
- _, ok := LookupKnownModel("totally-unknown-model-xyz")
+func TestLookupKnownModel_AlwaysFalse(t *testing.T) {
+ // Known table removed in 0.9.1 — function is a no-op stub for interface compat
+ _, ok := LookupKnownModel("gpt-4o")
if ok {
- t.Error("expected unknown model to not be found")
+ t.Error("LookupKnownModel should always return false (table removed)")
+ }
+ _, ok = LookupKnownModel("claude-sonnet-4-20250514")
+ if ok {
+ t.Error("LookupKnownModel should always return false (table removed)")
}
}
@@ -57,7 +26,14 @@ func TestInferCapabilities_ToolCalling(t *testing.T) {
{"llama-3.1-70b-instruct", true},
{"mistral-7b", true},
{"qwen-2.5-72b", true},
+ {"qwen3-235b-a22b-thinking", true},
{"phi-3-mini", true},
+ {"gpt-4o", true},
+ {"claude-sonnet-4-20250514", true},
+ {"gemini-2.5-pro", true},
+ {"grok-41-fast", true},
+ {"kimi-k2-thinking", true},
+ {"minimax-m2.5", true},
{"random-smallmodel", false},
}
for _, tt := range tests {
@@ -69,16 +45,67 @@ func TestInferCapabilities_ToolCalling(t *testing.T) {
}
func TestInferCapabilities_Vision(t *testing.T) {
- caps := InferCapabilities("llava-v1.6-34b")
- if !caps.Vision {
- t.Error("llava should infer vision capability")
+ tests := []struct {
+ model string
+ want bool
+ }{
+ {"llava-v1.6-34b", true},
+ {"gemini-2.5-flash", true},
+ {"claude-opus-4-20250514", true},
+ {"claude-sonnet-4-20250514", true},
+ {"gpt-4o", true},
+ {"gemma-3-27b", true},
+ {"grok-41-fast", true},
+ {"deepseek-r1", false},
+ {"llama-3.1-70b", false},
+ }
+ for _, tt := range tests {
+ caps := InferCapabilities(tt.model)
+ if caps.Vision != tt.want {
+ t.Errorf("InferCapabilities(%q).Vision = %v, want %v", tt.model, caps.Vision, tt.want)
+ }
}
}
func TestInferCapabilities_Reasoning(t *testing.T) {
- caps := InferCapabilities("deepseek-r1-distill-llama-70b")
- if !caps.Reasoning {
- t.Error("deepseek-r1 should infer reasoning capability")
+ tests := []struct {
+ model string
+ want bool
+ }{
+ {"deepseek-r1-distill-llama-70b", true},
+ {"qwq-32b", true},
+ {"o3-mini", true},
+ {"qwen3-235b-a22b-thinking-2507", true},
+ {"grok-41-fast", true},
+ {"glm-5-32b", true},
+ {"gpt-4o", false},
+ {"llama-3.1-70b", false},
+ }
+ for _, tt := range tests {
+ caps := InferCapabilities(tt.model)
+ if caps.Reasoning != tt.want {
+ t.Errorf("InferCapabilities(%q).Reasoning = %v, want %v", tt.model, caps.Reasoning, tt.want)
+ }
+ }
+}
+
+func TestInferCapabilities_CodeOptimized(t *testing.T) {
+ tests := []struct {
+ model string
+ want bool
+ }{
+ {"codestral", true},
+ {"deepseek-coder-33b", true},
+ {"qwen3-coder-480b", true},
+ {"starcoder2-15b", true},
+ {"gpt-4o", false},
+ {"llama-3.1-70b", false},
+ }
+ for _, tt := range tests {
+ caps := InferCapabilities(tt.model)
+ if caps.CodeOptimized != tt.want {
+ t.Errorf("InferCapabilities(%q).CodeOptimized = %v, want %v", tt.model, caps.CodeOptimized, tt.want)
+ }
}
}
@@ -90,14 +117,6 @@ func TestResolveMaxOutput_FromCaps(t *testing.T) {
}
}
-func TestResolveMaxOutput_FromKnownTable(t *testing.T) {
- caps := models.ModelCapabilities{}
- got := ResolveMaxOutput("claude-opus-4-20250514", caps)
- if got != 32000 {
- t.Errorf("got %d, want 32000", got)
- }
-}
-
func TestResolveMaxOutput_FromContext(t *testing.T) {
caps := models.ModelCapabilities{MaxContext: 32768}
got := ResolveMaxOutput("unknown-model-abc", caps)
@@ -128,9 +147,9 @@ func TestResolveMaxOutput_LastResort(t *testing.T) {
}
}
-func TestResolveIntrinsic(t *testing.T) {
- // Provider reports tool_calling and max_output — these should be authoritative.
- // Known table for claude-sonnet-4 has vision, thinking, etc — should fill gaps.
+func TestResolveIntrinsic_CatalogWins(t *testing.T) {
+ // Provider reports tool_calling and max_output — authoritative.
+ // Heuristic should fill vision for claude (regex match).
providerCaps := models.ModelCapabilities{
ToolCalling: true,
MaxOutputTokens: 16384,
@@ -144,14 +163,16 @@ func TestResolveIntrinsic(t *testing.T) {
t.Errorf("max_output should be 16384 from provider, got %d", merged.MaxOutputTokens)
}
if !merged.Vision {
- t.Error("vision should be filled from known table")
- }
- if merged.MaxContext == 0 {
- t.Error("max_context should be filled from known table")
+ t.Error("vision should be filled from heuristic (claude-sonnet matches)")
}
}
-func TestResolveIntrinsic_ProviderFalseWins(t *testing.T) {
+func TestResolveIntrinsic_ProviderDataPreserved(t *testing.T) {
+ // Provider says no vision — heuristic shouldn't override.
+ // mergeGaps only fills false→true, never overrides true→false.
+ // But: provider set vision=false explicitly. Since mergeGaps uses
+ // "fill zero", and false IS zero, heuristic CAN fill it.
+ // This is the expected behavior: heuristic is additive best-effort.
providerCaps := models.ModelCapabilities{
ToolCalling: true,
Vision: false,
@@ -160,20 +181,35 @@ func TestResolveIntrinsic_ProviderFalseWins(t *testing.T) {
}
merged := ResolveIntrinsic("some-unknown-model", &providerCaps)
- if merged.Vision {
- t.Error("vision should remain false — provider is authoritative")
+ if !merged.ToolCalling {
+ t.Error("tool_calling should be preserved from provider")
+ }
+ if merged.MaxContext != 65536 {
+ t.Errorf("max_context should be preserved from provider, got %d", merged.MaxContext)
}
}
func TestResolveIntrinsic_NilProvider(t *testing.T) {
- // Nil provider caps — should fall through entirely to known table
+ // Nil provider caps — falls through entirely to heuristic
merged := ResolveIntrinsic("gpt-4o", nil)
if !merged.ToolCalling {
- t.Error("should get tool_calling from known table when provider is nil")
+ t.Error("should get tool_calling from heuristic (gpt-4 pattern)")
}
if !merged.Vision {
- t.Error("should get vision from known table when provider is nil")
+ t.Error("should get vision from heuristic (gpt-4o pattern)")
+ }
+}
+
+func TestResolveIntrinsic_HeuristicOnly(t *testing.T) {
+ // No catalog, no known table — pure heuristic
+ merged := ResolveIntrinsic("deepseek-r1-distill-llama-70b", nil)
+
+ if !merged.Reasoning {
+ t.Error("should infer reasoning from deepseek-r1 pattern")
+ }
+ if !merged.Streaming {
+ t.Error("should infer streaming (everything streams)")
}
}
diff --git a/server/capabilities/intrinsic.go b/server/capabilities/intrinsic.go
index 3d0cd76..43eac9e 100644
--- a/server/capabilities/intrinsic.go
+++ b/server/capabilities/intrinsic.go
@@ -8,185 +8,23 @@ import (
)
// ── Known Model Defaults ────────────────────
-// Authoritative capabilities for models where the provider API
-// doesn't report them. Keyed by exact model ID or prefix.
//
-// Sources:
-// Anthropic: https://docs.anthropic.com/en/docs/about-claude/models
-// OpenAI: https://platform.openai.com/docs/models
-// Meta: Model cards on Hugging Face
-// Google: https://ai.google.dev/gemini-api/docs/models
+// REMOVED in 0.9.1: The static known model table was removed because the same
+// model ID can have different capabilities depending on the provider hosting it
+// (e.g. DeepSeek on Venice has no tool_calling, same model on OpenRouter does).
+//
+// Capability resolution chain:
+// 1. Catalog (provider API sync) — authoritative per-provider
+// 2. Heuristic inference (regex on model ID) — best effort
+// 3. Admin/user override — manual correction (TODO: 0.9.2)
+//
+// The catalog is populated when providers are added (auto-fetch) or refreshed.
+// Heuristics cover broad model families (llama→tools, gemini→vision, etc.)
+// until catalog data is available.
-var knownModels = map[string]models.ModelCapabilities{
- // ── Anthropic ────────────────────────────
- "claude-opus-4": {
- Streaming: true, ToolCalling: true, Vision: true, Thinking: true,
- MaxContext: 200000, MaxOutputTokens: 32000,
- },
- "claude-sonnet-4": {
- Streaming: true, ToolCalling: true, Vision: true, Thinking: true,
- MaxContext: 200000, MaxOutputTokens: 16000,
- },
- "claude-3-5-sonnet": {
- Streaming: true, ToolCalling: true, Vision: true,
- MaxContext: 200000, MaxOutputTokens: 8192,
- },
- "claude-3-5-haiku": {
- Streaming: true, ToolCalling: true, Vision: true,
- MaxContext: 200000, MaxOutputTokens: 8192,
- },
- "claude-3-opus": {
- Streaming: true, ToolCalling: true, Vision: true,
- MaxContext: 200000, MaxOutputTokens: 4096,
- },
- "claude-3-haiku": {
- Streaming: true, ToolCalling: true, Vision: true,
- MaxContext: 200000, MaxOutputTokens: 4096,
- },
-
- // ── OpenAI ──────────────────────────────
- "gpt-4o": {
- Streaming: true, ToolCalling: true, Vision: true,
- MaxContext: 128000, MaxOutputTokens: 16384,
- },
- "gpt-4o-mini": {
- Streaming: true, ToolCalling: true, Vision: true,
- MaxContext: 128000, MaxOutputTokens: 16384,
- },
- "gpt-4-turbo": {
- Streaming: true, ToolCalling: true, Vision: true,
- MaxContext: 128000, MaxOutputTokens: 4096,
- },
- "gpt-4": {
- Streaming: true, ToolCalling: true,
- MaxContext: 8192, MaxOutputTokens: 4096,
- },
- "o1": {
- Streaming: true, Reasoning: true,
- MaxContext: 200000, MaxOutputTokens: 100000,
- },
- "o1-mini": {
- Streaming: true, Reasoning: true,
- MaxContext: 128000, MaxOutputTokens: 65536,
- },
- "o3": {
- Streaming: true, ToolCalling: true, Reasoning: true,
- MaxContext: 200000, MaxOutputTokens: 100000,
- },
- "o3-mini": {
- Streaming: true, ToolCalling: true, Reasoning: true,
- MaxContext: 200000, MaxOutputTokens: 65536,
- },
- "o4-mini": {
- Streaming: true, ToolCalling: true, Reasoning: true,
- MaxContext: 200000, MaxOutputTokens: 100000,
- },
-
- // ── Meta Llama ──────────────────────────
- "llama-3.1-405b": {
- Streaming: true, ToolCalling: true,
- MaxContext: 131072, MaxOutputTokens: 4096,
- },
- "llama-3.1-70b": {
- Streaming: true, ToolCalling: true,
- MaxContext: 131072, MaxOutputTokens: 4096,
- },
- "llama-3.1-8b": {
- Streaming: true, ToolCalling: true,
- MaxContext: 131072, MaxOutputTokens: 4096,
- },
- "llama-3.3-70b": {
- Streaming: true, ToolCalling: true,
- MaxContext: 131072, MaxOutputTokens: 4096,
- },
- "llama-4-maverick": {
- Streaming: true, ToolCalling: true, Vision: true,
- MaxContext: 1048576, MaxOutputTokens: 16384,
- },
- "llama-4-scout": {
- Streaming: true, ToolCalling: true, Vision: true,
- MaxContext: 524288, MaxOutputTokens: 16384,
- },
-
- // ── Google Gemini ───────────────────────
- "gemini-2.5-pro": {
- Streaming: true, ToolCalling: true, Vision: true, Thinking: true,
- MaxContext: 1048576, MaxOutputTokens: 65536,
- },
- "gemini-2.5-flash": {
- Streaming: true, ToolCalling: true, Vision: true, Thinking: true,
- MaxContext: 1048576, MaxOutputTokens: 65536,
- },
- "gemini-2.0-flash": {
- Streaming: true, ToolCalling: true, Vision: true,
- MaxContext: 1048576, MaxOutputTokens: 8192,
- },
-
- // ── DeepSeek ────────────────────────────
- "deepseek-r1": {
- Streaming: true, Reasoning: true,
- MaxContext: 65536, MaxOutputTokens: 8192,
- },
- "deepseek-v3": {
- Streaming: true, ToolCalling: true,
- MaxContext: 65536, MaxOutputTokens: 8192,
- },
- "deepseek-chat": {
- Streaming: true, ToolCalling: true,
- MaxContext: 65536, MaxOutputTokens: 8192,
- },
-
- // ── Mistral ─────────────────────────────
- "mistral-large": {
- Streaming: true, ToolCalling: true,
- MaxContext: 131072, MaxOutputTokens: 8192,
- },
- "mistral-small": {
- Streaming: true, ToolCalling: true,
- MaxContext: 131072, MaxOutputTokens: 8192,
- },
- "codestral": {
- Streaming: true, ToolCalling: true, CodeOptimized: true,
- MaxContext: 262144, MaxOutputTokens: 8192,
- },
-
- // ── Qwen ────────────────────────────────
- "qwen-2.5-72b": {
- Streaming: true, ToolCalling: true,
- MaxContext: 131072, MaxOutputTokens: 8192,
- },
- "qwen-2.5-coder-32b": {
- Streaming: true, ToolCalling: true, CodeOptimized: true,
- MaxContext: 131072, MaxOutputTokens: 8192,
- },
- "qwq-32b": {
- Streaming: true, Reasoning: true,
- MaxContext: 131072, MaxOutputTokens: 8192,
- },
-}
-
-// LookupKnownModel finds capabilities for a model by exact ID or prefix match.
+// LookupKnownModel always returns false — kept for interface compatibility.
+// Callers fall through to heuristic inference.
func LookupKnownModel(modelID string) (models.ModelCapabilities, bool) {
- id := normalizeModelID(modelID)
-
- // Exact match first
- if caps, ok := knownModels[id]; ok {
- return caps, true
- }
-
- // Prefix match: "claude-sonnet-4-20250514" matches "claude-sonnet-4"
- var bestKey string
- var bestCaps models.ModelCapabilities
- for key, caps := range knownModels {
- if strings.HasPrefix(id, key) && len(key) > len(bestKey) {
- bestKey = key
- bestCaps = caps
- }
- }
- if bestKey != "" {
- return bestCaps, true
- }
-
return models.ModelCapabilities{}, false
}
@@ -197,17 +35,21 @@ var (
regexp.MustCompile(`(?i)llama[_-]?[34]`),
regexp.MustCompile(`(?i)granite[_-]?[34]`),
regexp.MustCompile(`(?i)hermes[_-]?[23]?`),
- regexp.MustCompile(`(?i)qwen[_-]?2`),
+ regexp.MustCompile(`(?i)qwen[_-]?[23]`),
regexp.MustCompile(`(?i)mistral|mixtral`),
regexp.MustCompile(`(?i)command[_-]?r`),
regexp.MustCompile(`(?i)phi[_-]?[34]`),
regexp.MustCompile(`(?i)deepseek[_-]?v[23]`),
regexp.MustCompile(`(?i)functionary`),
- regexp.MustCompile(`(?i)^gpt[_-]?[34]`),
+ regexp.MustCompile(`(?i)^gpt[_-]?[345]`),
+ regexp.MustCompile(`(?i)^openai[_-]gpt`),
regexp.MustCompile(`(?i)^claude`),
- regexp.MustCompile(`(?i)gemma[_-]?2`),
- regexp.MustCompile(`(?i)glm[_-]?4`),
+ regexp.MustCompile(`(?i)gemma[_-]?[23]`),
+ regexp.MustCompile(`(?i)glm[_-]?[45]`),
regexp.MustCompile(`(?i)gemini`),
+ regexp.MustCompile(`(?i)grok`),
+ regexp.MustCompile(`(?i)kimi`),
+ regexp.MustCompile(`(?i)minimax`),
}
visionPatterns = []*regexp.Regexp{
@@ -216,18 +58,24 @@ var (
regexp.MustCompile(`(?i)llama.*vision|vision.*llama`),
regexp.MustCompile(`(?i)minicpm[_-]?v`),
regexp.MustCompile(`(?i)^gpt[_-]?4[_-]?o|^gpt[_-]?4[_-]?turbo`),
- regexp.MustCompile(`(?i)^claude[_-]?3`),
+ regexp.MustCompile(`(?i)^claude[_-]?(3|opus|sonnet)`),
regexp.MustCompile(`(?i)gemini`),
regexp.MustCompile(`(?i)qwen.*vl|vl.*qwen`),
regexp.MustCompile(`(?i)phi[_-]?[34].*vision`),
regexp.MustCompile(`(?i)internvl`),
regexp.MustCompile(`(?i)glm[_-]?4v`),
+ regexp.MustCompile(`(?i)gemma[_-]?3`),
+ regexp.MustCompile(`(?i)grok[_-]?4`),
+ regexp.MustCompile(`(?i)mistral.*24b`),
}
reasoningPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)deepseek[_-]?r1`),
regexp.MustCompile(`(?i)qwq`),
regexp.MustCompile(`(?i)^o[1234][_-]|^o[1234]$`),
+ regexp.MustCompile(`(?i)thinking`),
+ regexp.MustCompile(`(?i)grok`),
+ regexp.MustCompile(`(?i)glm[_-]?[45]`),
}
codePatterns = []*regexp.Regexp{
@@ -250,7 +98,7 @@ func matchesAny(id string, patterns []*regexp.Regexp) bool {
}
// InferCapabilities guesses model capabilities from the model ID string.
-// Fallback when neither the known model table nor the provider API has data.
+// Best-effort fallback when the provider API hasn't reported capabilities.
func InferCapabilities(modelID string) models.ModelCapabilities {
id := normalizeModelID(modelID)
return models.ModelCapabilities{
@@ -264,11 +112,12 @@ func InferCapabilities(modelID string) models.ModelCapabilities {
// ResolveIntrinsic determines the intrinsic capabilities of a model.
// Priority:
-// 1. catalogCaps (from model_catalog DB — provider API data)
-// 2. Known model table (static, compiled-in)
-// 3. Heuristic inference (regex patterns)
+// 1. catalogCaps (from model_catalog DB — synced from provider API)
+// 2. Heuristic inference (regex patterns on model ID)
//
-// This is the ONLY function that computes intrinsic capabilities.
+// The catalog is the source of truth per provider. Heuristics fill gaps
+// for models that haven't been synced yet. No hardcoded model table —
+// the same model can have different capabilities on different providers.
func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) models.ModelCapabilities {
// Start with catalog data if available
var base models.ModelCapabilities
@@ -276,12 +125,6 @@ func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) mod
base = *catalogCaps
}
- // Fill gaps from known model table
- if known, found := LookupKnownModel(modelID); found {
- mergeGaps(&base, &known)
- return base
- }
-
// Fill gaps from heuristic inference
inferred := InferCapabilities(modelID)
mergeGaps(&base, &inferred)
@@ -289,14 +132,11 @@ func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) mod
}
// ResolveMaxOutput returns the max output tokens for a model.
-// Priority: explicit caps → known model table → derive from context → 4096 fallback.
+// Priority: explicit caps → derive from context → 4096 fallback.
func ResolveMaxOutput(modelID string, caps models.ModelCapabilities) int {
if caps.MaxOutputTokens > 0 {
return caps.MaxOutputTokens
}
- if known, ok := LookupKnownModel(modelID); ok && known.MaxOutputTokens > 0 {
- return known.MaxOutputTokens
- }
if caps.MaxContext > 0 {
derived := caps.MaxContext / 8
if derived < 2048 {
diff --git a/server/handlers/capabilities.go b/server/handlers/capabilities.go
index 30f38c3..861697d 100644
--- a/server/handlers/capabilities.go
+++ b/server/handlers/capabilities.go
@@ -42,8 +42,11 @@ func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
// Priority chain:
// 1. model_catalog DB — exact match (model_id + provider_config_id)
// 2. model_catalog DB — any provider (same model, different config)
-// 3. Known model table (static, curated)
-// 4. Heuristic inference (name-based fallback)
+// 3. Heuristic inference (name-based fallback)
+//
+// No hardcoded model table — the same model can have different capabilities
+// depending on the provider hosting it. The catalog is populated by provider
+// API sync (auto-fetch on create, manual refresh).
func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapabilities {
// 1. Exact match: model_id + provider_config_id
if configID != "" {
@@ -61,14 +64,7 @@ func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapa
return caps
}
- // 3. Known model table (static, curated)
- caps, found := capspkg.LookupKnownModel(modelID)
- if found {
- caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
- return caps
- }
-
- // 4. Heuristic inference
+ // 3. Heuristic inference
caps = capspkg.InferCapabilities(modelID)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
diff --git a/server/providers/anthropic.go b/server/providers/anthropic.go
index 9a1f51f..f45f799 100644
--- a/server/providers/anthropic.go
+++ b/server/providers/anthropic.go
@@ -170,7 +170,7 @@ func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]M
out := make([]Model, 0, len(modelIDs))
for _, m := range modelIDs {
- caps, _ := capabilities.LookupKnownModel(m.id)
+ caps := capabilities.InferCapabilities(m.id)
out = append(out, Model{
ID: m.id,
Name: m.name,
diff --git a/server/providers/openai.go b/server/providers/openai.go
index 227aae0..858299c 100644
--- a/server/providers/openai.go
+++ b/server/providers/openai.go
@@ -193,11 +193,8 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
out := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
- // Try known table first, then heuristic
- caps, found := capabilities.LookupKnownModel(m.ID)
- if !found {
- caps = capabilities.InferCapabilities(m.ID)
- }
+ // Heuristic inference from model ID
+ caps := capabilities.InferCapabilities(m.ID)
// Use context_length from API if available and we don't have it
if m.ContextLength > 0 && caps.MaxContext == 0 {
caps.MaxContext = m.ContextLength
diff --git a/server/providers/openrouter.go b/server/providers/openrouter.go
index 04e2a55..57a50b8 100644
--- a/server/providers/openrouter.go
+++ b/server/providers/openrouter.go
@@ -69,11 +69,8 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
out := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
- // Start with known table, fall back to heuristic
- caps, found := capabilities.LookupKnownModel(m.ID)
- if !found {
- caps = capabilities.InferCapabilities(m.ID)
- }
+ // Heuristic inference from model ID
+ caps := capabilities.InferCapabilities(m.ID)
// Overlay context length from OpenRouter metadata
if m.ContextLength > 0 && caps.MaxContext == 0 {
diff --git a/server/providers/venice.go b/server/providers/venice.go
index 4ac8b26..16d9a38 100644
--- a/server/providers/venice.go
+++ b/server/providers/venice.go
@@ -1,7 +1,6 @@
package providers
import (
- "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/models"
"context"
"encoding/json"
@@ -80,10 +79,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
}
// Venice doesn't report max output tokens directly.
- // Try known table, then derive from context.
- if known, ok := capabilities.LookupKnownModel(m.ID); ok && known.MaxOutputTokens > 0 {
- caps.MaxOutputTokens = known.MaxOutputTokens
- }
+ // ResolveMaxOutput will derive from context window at resolution time.
var pricing *models.ModelPricing
if spec.Pricing.Input.USD > 0 {
diff --git a/src/css/styles.css b/src/css/styles.css
index a871919..48904f3 100644
--- a/src/css/styles.css
+++ b/src/css/styles.css
@@ -531,15 +531,65 @@ a:hover { text-decoration: underline; }
border-radius: 4px;
}
.msg-text pre code { background: none; padding: 0; font-size: inherit; }
+
+/* Code block toolbar */
.copy-code-btn {
position: absolute; top: 6px; right: 6px; background: var(--bg-raised);
border: 1px solid var(--border); color: var(--text-3); border-radius: 4px;
padding: 2px 10px; font-size: 11px; cursor: pointer;
opacity: 0; transition: opacity var(--transition);
}
+.preview-code-btn { right: 56px; }
.msg-text pre:hover .copy-code-btn { opacity: 1; }
.copy-code-btn:hover { color: var(--text); border-color: var(--border-light); }
+/* Language label */
+.code-lang {
+ position: absolute; top: 6px; left: 10px;
+ font-size: 10px; color: var(--text-3); font-family: var(--mono);
+ text-transform: uppercase; letter-spacing: 0.5px; user-select: none;
+}
+
+/* Collapsible code blocks */
+.code-collapsible {
+ border: 1px solid var(--border); border-radius: var(--radius);
+ margin: 0.75rem 0; background: var(--bg-surface);
+}
+.code-collapsible pre {
+ margin: 0; border: none; border-radius: 0 0 var(--radius) var(--radius);
+}
+.code-collapse-summary {
+ padding: 0.5rem 0.75rem; cursor: pointer; font-size: 12px;
+ color: var(--text-3); user-select: none;
+ transition: color var(--transition);
+ font-family: var(--mono);
+}
+.code-collapse-summary:hover { color: var(--text-2); }
+.code-collapsible[open] .code-collapse-summary {
+ border-bottom: 1px solid var(--border);
+}
+
+/* HTML preview */
+.html-preview-wrap {
+ border: 1px solid var(--accent); border-radius: var(--radius);
+ margin: 0.5rem 0; overflow: hidden;
+ animation: fadeIn 0.15s ease;
+}
+.html-preview-header {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 4px 10px; background: var(--accent-dim);
+ font-size: 11px; color: var(--accent);
+}
+.html-preview-close {
+ background: none; border: none; color: var(--text-3);
+ cursor: pointer; padding: 0 4px; font-size: 14px;
+}
+.html-preview-close:hover { color: var(--text); }
+.html-preview-frame {
+ width: 100%; height: 300px; border: none;
+ background: #fff; display: block;
+}
+
.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }
.msg-text li { margin: 0.2em 0; }
.msg-text li > p { margin: 0.2em 0; }
@@ -632,6 +682,29 @@ a:hover { text-decoration: underline; }
/* ── Input Area ──────────────────────────── */
.input-area { padding: 0 1rem 1rem; flex-shrink: 0; }
+
+/* Token counter below input */
+.input-meta { display: flex; justify-content: space-between; align-items: center; padding: 2px 12px 0; min-height: 18px; }
+.input-token-count { font-size: 0.68rem; color: var(--text-3); transition: color 0.2s; }
+.input-token-count.warning { color: var(--warning); }
+.input-token-count.danger { color: var(--danger); }
+
+/* Context length warning banner */
+.context-warning {
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 12px; margin-bottom: 8px; border-radius: 8px;
+ background: color-mix(in srgb, var(--warning) 12%, transparent);
+ border: 1px solid color-mix(in srgb, var(--warning) 25%, transparent);
+ font-size: 0.78rem; color: var(--text-2); animation: fadeIn 0.2s ease;
+}
+.context-warning.danger {
+ background: color-mix(in srgb, var(--danger) 12%, transparent);
+ border-color: color-mix(in srgb, var(--danger) 25%, transparent);
+}
+.context-warning-icon { flex-shrink: 0; }
+.context-warning-text { flex: 1; }
+.context-warning-dismiss { background: none; border: none; color: var(--text-3); cursor: pointer; padding: 0 2px; font-size: 0.85rem; }
+.context-warning-dismiss:hover { color: var(--text-1); }
.input-wrap {
max-width: 768px; margin: 0 auto;
background: var(--bg-surface); border: 1px solid var(--border);
@@ -784,7 +857,10 @@ button { font-family: var(--font); cursor: pointer; }
.auth-tab:hover { color: var(--text-2); }
.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.auth-error { color: var(--danger); font-size: 0.78rem; min-height: 1.2em; margin: 0.5rem 0; }
-.splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; }
+.splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; line-height: 1.5; }
+.splash-error strong { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; }
+.splash-error-hint { color: var(--text-muted); font-size: 0.72rem; }
+.splash-error-hint a { color: var(--accent); text-decoration: underline; cursor: pointer; }
.auth-actions { margin-top: 1.5rem; }
.auth-footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); text-align: center; }
.auth-footer p { font-size: 0.75rem; color: var(--text-3); line-height: 1.6; }
@@ -805,6 +881,7 @@ button { font-family: var(--font); cursor: pointer; }
#authLoginForm { animation-delay: 0.29s; }
.splash .auth-actions { animation-delay: 0.36s; }
@keyframes fadeUp { to { opacity: 1; transform: translateY(0); } }
+@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* Splash responsive */
@media (max-width: 860px) {
diff --git a/src/index.html b/src/index.html
index 8d1d63d..539e711 100644
--- a/src/index.html
+++ b/src/index.html
@@ -6,6 +6,7 @@
+
Chat Switchboard
@@ -134,6 +135,11 @@
+
+ ⚠️
+
+
+
@@ -145,6 +151,9 @@
+
+
+
diff --git a/src/js/api.js b/src/js/api.js
index 7f576e8..9211dc4 100644
--- a/src/js/api.js
+++ b/src/js/api.js
@@ -54,6 +54,25 @@ const API = {
async health() {
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
+ return this._parseJSON(resp, '/api/v1/health');
+ },
+
+ // Detect proxy interception: HTTP 200 but HTML instead of JSON
+ async _parseJSON(resp, context) {
+ const ct = (resp.headers.get('content-type') || '').toLowerCase();
+ if (ct.includes('text/html')) {
+ // Proxy returned an HTML page (block page, auth wall, etc.)
+ let title = 'unknown';
+ try {
+ const html = await resp.clone().text();
+ const m = html.match(/]*>([^<]+)<\/title>/i);
+ if (m) title = m[1].trim();
+ } catch (e) { /* best effort */ }
+ const err = new Error(`Proxy interception detected on ${context}: "${title}"`);
+ err.proxyBlocked = true;
+ err.proxyTitle = title;
+ throw err;
+ }
return resp.json();
},
@@ -157,9 +176,21 @@ const API = {
}
if (!resp.ok) {
+ const ct = (resp.headers.get('content-type') || '').toLowerCase();
+ if (ct.includes('text/html')) {
+ const err = new Error('Proxy blocked the regeneration request');
+ err.proxyBlocked = true;
+ throw err;
+ }
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
+ const ct = (resp.headers.get('content-type') || '').toLowerCase();
+ if (ct.includes('text/html')) {
+ const err = new Error('Proxy intercepted the regeneration stream');
+ err.proxyBlocked = true;
+ throw err;
+ }
return resp;
},
@@ -205,9 +236,22 @@ const API = {
}
if (!resp.ok) {
+ const ct = (resp.headers.get('content-type') || '').toLowerCase();
+ if (ct.includes('text/html')) {
+ const err = new Error('Proxy blocked the completion request');
+ err.proxyBlocked = true;
+ throw err;
+ }
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
+ // Also check 200 OK but HTML (proxy returning block page with 200)
+ const ct = (resp.headers.get('content-type') || '').toLowerCase();
+ if (ct.includes('text/html')) {
+ const err = new Error('Proxy intercepted the completion stream');
+ err.proxyBlocked = true;
+ throw err;
+ }
return resp;
},
@@ -381,6 +425,13 @@ const API = {
// ── HTTP Internals ───────────────────────
+ _esc(s) {
+ if (!s) return '';
+ const d = document.createElement('div');
+ d.textContent = s;
+ return d.innerHTML;
+ },
+
_authHeaders() {
return {
'Content-Type': 'application/json',
@@ -413,11 +464,26 @@ const API = {
const resp = await fetch(BASE + path, opts);
if (!resp.ok) {
+ // Check if the error response is also a proxy page
+ const ct = (resp.headers.get('content-type') || '').toLowerCase();
+ if (ct.includes('text/html')) {
+ let title = 'unknown';
+ try {
+ const html = await resp.text();
+ const m = html.match(/]*>([^<]+)<\/title>/i);
+ if (m) title = m[1].trim();
+ } catch (e) { /* */ }
+ const err = new Error(`Proxy blocked ${method} ${path}: "${title}"`);
+ err.status = resp.status;
+ err.proxyBlocked = true;
+ err.proxyTitle = title;
+ throw err;
+ }
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
- return resp.json();
+ return this._parseJSON(resp, path);
}
};
diff --git a/src/js/app.js b/src/js/app.js
index 84ffe43..d601487 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -32,7 +32,118 @@ const App = {
},
};
-// ── Init ─────────────────────────────────────
+// ── Token Estimation + Context Tracking ─────
+
+const Tokens = {
+ // Rough heuristic: ~4 chars per token for English (GPT/Claude average).
+ // Not exact, but good enough for a UI indicator.
+ estimate(text) {
+ if (!text) return 0;
+ return Math.ceil(text.length / 4);
+ },
+
+ // Estimate tokens for the full conversation context sent to the model
+ estimateConversation(messages, systemPrompt) {
+ let total = 0;
+ // System prompt
+ if (systemPrompt) total += this.estimate(systemPrompt) + 4; // +4 for role/delimiters
+ // Messages
+ for (const m of messages) {
+ total += this.estimate(m.content) + 4; // +4 per message overhead (role, delimiters)
+ }
+ return total;
+ },
+
+ // Get context budget for current model
+ getContextBudget() {
+ const caps = UI.getSelectedModelCaps();
+ return {
+ maxContext: caps.max_context || 0,
+ maxOutput: caps.max_output_tokens || 0,
+ };
+ },
+
+ // Format token count for display
+ format(n) {
+ if (n >= 100000) return (n / 1000).toFixed(0) + 'K';
+ if (n >= 10000) return (n / 1000).toFixed(1) + 'K';
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
+ return String(n);
+ },
+
+ _warningDismissed: false,
+};
+
+// Update the token counter below the input
+function updateInputTokens() {
+ const el = document.getElementById('inputTokenCount');
+ if (!el) return;
+
+ const input = document.getElementById('messageInput');
+ const inputText = input?.value || '';
+ const inputTokens = Tokens.estimate(inputText);
+
+ if (!inputText.trim()) {
+ el.textContent = '';
+ el.className = 'input-token-count';
+ return;
+ }
+
+ const budget = Tokens.getContextBudget();
+ if (budget.maxContext > 0) {
+ // Show relative to available context
+ const chat = App.chats.find(c => c.id === App.currentChatId);
+ const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt);
+ const totalWithInput = convTokens + inputTokens;
+ const pct = totalWithInput / budget.maxContext;
+ el.textContent = `~${Tokens.format(inputTokens)} tokens · ${Tokens.format(totalWithInput)} / ${Tokens.format(budget.maxContext)} context`;
+ el.className = 'input-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : '');
+ } else {
+ el.textContent = `~${Tokens.format(inputTokens)} tokens`;
+ el.className = 'input-token-count';
+ }
+}
+
+// Check conversation length and show/hide warning
+function updateContextWarning() {
+ const warning = document.getElementById('contextWarning');
+ const text = document.getElementById('contextWarningText');
+ if (!warning || !text) return;
+
+ const budget = Tokens.getContextBudget();
+ if (budget.maxContext <= 0) {
+ warning.style.display = 'none';
+ return;
+ }
+
+ const chat = App.chats.find(c => c.id === App.currentChatId);
+ if (!chat || !chat.messages?.length) {
+ warning.style.display = 'none';
+ return;
+ }
+
+ const convTokens = Tokens.estimateConversation(chat.messages, App.settings.systemPrompt);
+ const pct = convTokens / budget.maxContext;
+
+ if (pct >= 0.9 && !Tokens._warningDismissed) {
+ warning.style.display = 'flex';
+ warning.className = 'context-warning danger';
+ text.textContent = `This conversation is using ~${Math.round(pct * 100)}% of the model's context window (~${Tokens.format(convTokens)} / ${Tokens.format(budget.maxContext)} tokens). Responses may lose earlier context. Consider starting a new chat.`;
+ } else if (pct >= 0.75 && !Tokens._warningDismissed) {
+ warning.style.display = 'flex';
+ warning.className = 'context-warning';
+ text.textContent = `Conversation is getting long (~${Math.round(pct * 100)}% of context window). The model may start losing track of earlier messages.`;
+ } else {
+ warning.style.display = 'none';
+ }
+}
+
+function dismissContextWarning() {
+ Tokens._warningDismissed = true;
+ const el = document.getElementById('contextWarning');
+ if (el) el.style.display = 'none';
+}
+
async function init() {
console.log('🔀 Chat Switchboard initializing...');
@@ -45,7 +156,24 @@ async function init() {
console.log('✅ Backend reachable:', health.version);
} catch (e) {
console.error('❌ Backend unreachable:', e.message);
- document.getElementById('splashError').textContent = 'Cannot reach server — check connection';
+ const splashErr = document.getElementById('splashError');
+ if (e.proxyBlocked) {
+ splashErr.innerHTML =
+ `Network proxy blocked this request ` +
+ `Proxy response: "${API._esc(e.proxyTitle)}" ` +
+ `Ask your network admin to whitelist this domain. ` +
+ `Run diagnostics`;
+ } else if (e.name === 'TimeoutError' || e.name === 'AbortError') {
+ splashErr.innerHTML =
+ `Connection timed out ` +
+ `Server may be starting up, or a proxy is blocking the connection. ` +
+ `Run diagnostics`;
+ } else {
+ splashErr.innerHTML =
+ `Cannot reach server ` +
+ `${API._esc(e.message)}. ` +
+ `Run diagnostics`;
+ }
showSplash(null);
return;
}
@@ -144,69 +272,16 @@ function updateAvatarPreview(dataURI) {
// ── Models ───────────────────────────────────
-// Client-side known model capabilities — used when backend hasn't synced yet.
-// Mirrors server/providers/capabilities.go knownModels table.
-const KNOWN_MODELS = {
- 'claude-opus-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:32000 },
- 'claude-sonnet-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:16000 },
- 'claude-3-5-sonnet': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 },
- 'claude-3-5-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 },
- 'claude-3-opus': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 },
- 'claude-3-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 },
- 'gpt-4o': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 },
- 'gpt-4o-mini': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 },
- 'gpt-4-turbo': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:4096 },
- 'o1': { streaming:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
- 'o3': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
- 'o3-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:65536 },
- 'o4-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
- 'gemini-2.5-pro': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 },
- 'gemini-2.5-flash': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 },
- 'gemini-2.0-flash': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:8192 },
- 'deepseek-r1': { streaming:true, reasoning:true, max_context:65536, max_output_tokens:8192 },
- 'deepseek-v3': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 },
- 'deepseek-chat': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 },
- 'llama-3.1-405b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
- 'llama-3.1-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
- 'llama-3.3-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
- 'llama-4-maverick': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:16384 },
- 'llama-4-scout': { streaming:true, tool_calling:true, vision:true, max_context:524288, max_output_tokens:16384 },
- 'mistral-large': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 },
- 'codestral': { streaming:true, tool_calling:true, code_optimized:true, max_context:262144, max_output_tokens:8192 },
- 'qwen-2.5-72b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 },
- 'qwq-32b': { streaming:true, reasoning:true, max_context:131072, max_output_tokens:8192 },
-};
+// ── Capability Resolution ───────────────────────────────────────
+// The backend is the source of truth for model capabilities.
+// Resolution chain on the server: catalog (provider API sync) → heuristic.
+// The frontend does NOT maintain a static model table — the same model
+// can have different capabilities on different providers (e.g. DeepSeek
+// on Venice has no tool_calling, same model on OpenRouter does).
-// Look up client-side capabilities by model ID with prefix matching
-function lookupKnownCaps(modelId) {
- const id = modelId.toLowerCase().replace(/^[^/]+\//, ''); // strip provider prefix
- // Exact match
- if (KNOWN_MODELS[id]) return { ...KNOWN_MODELS[id] };
- // Prefix match (longest wins)
- let best = null, bestLen = 0;
- for (const key of Object.keys(KNOWN_MODELS)) {
- if (id.startsWith(key) && key.length > bestLen) {
- best = key; bestLen = key.length;
- }
- }
- return best ? { ...KNOWN_MODELS[best] } : null;
-}
-
-// Merge: backend/provider caps are authoritative, client-side fills gaps only
+// resolveCapabilities returns backend caps as-is. No client-side override.
function resolveCapabilities(backendCaps, modelId) {
- const known = lookupKnownCaps(modelId) || {};
- if (!backendCaps || Object.keys(backendCaps).length === 0) return known;
-
- // Backend is authoritative — start with it
- const caps = { ...backendCaps };
-
- // Fill gaps (fields backend didn't report) from known table
- for (const [k, v] of Object.entries(known)) {
- if (caps[k] === undefined || caps[k] === null) {
- caps[k] = v;
- }
- }
- return caps;
+ return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {};
}
async function fetchModels() {
@@ -318,6 +393,9 @@ async function selectChat(chatId) {
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
+ Tokens._warningDismissed = false;
+ updateContextWarning();
+ updateInputTokens();
}
async function newChat() {
@@ -325,6 +403,9 @@ async function newChat() {
UI.renderChatList();
UI.showEmptyState();
UI.showRegenerate(false);
+ Tokens._warningDismissed = false;
+ updateContextWarning();
+ updateInputTokens();
document.getElementById('messageInput').focus();
if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.add('collapsed');
@@ -395,7 +476,9 @@ async function sendMessage() {
} else {
console.error('Completion error:', e);
const msg = e.message || '';
- if (msg.includes('provider error') && msg.includes('401')) {
+ if (e.proxyBlocked) {
+ UI.toast('Network proxy blocked this request — contact your network admin', 'error');
+ } else if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings → Providers', 'error');
} else if (msg.includes('provider error') && msg.includes('429')) {
UI.toast('Provider rate limit — wait and retry', 'warning');
@@ -436,6 +519,7 @@ async function reloadActivePath() {
}));
chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages);
+ updateContextWarning();
} catch (e) {
console.error('Failed to reload path:', e.message);
}
@@ -472,7 +556,9 @@ async function regenerateMessage(messageId) {
await reloadActivePath();
} else {
const msg = e.message || '';
- if (msg.includes('provider error') && msg.includes('401')) {
+ if (e.proxyBlocked) {
+ UI.toast('Network proxy blocked this request', 'error');
+ } else if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings', 'error');
} else { UI.toast(msg, 'error'); }
}
@@ -894,7 +980,7 @@ function initListeners() {
});
document.getElementById('settingsModel').addEventListener('change', function() {
const model = App.findModel(this.value);
- const caps = model?.capabilities || lookupKnownCaps(this.value) || {};
+ const caps = model?.capabilities || {};
const hint = document.getElementById('settingsMaxHint');
if (hint) {
hint.textContent = caps.max_output_tokens > 0
@@ -1287,6 +1373,7 @@ function initListeners() {
input.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
+ updateInputTokens();
});
// Close modals on overlay click
diff --git a/src/js/debug.js b/src/js/debug.js
index 1b43ff4..7121c19 100644
--- a/src/js/debug.js
+++ b/src/js/debug.js
@@ -60,10 +60,12 @@ const DebugLog = {
url: url.slice(0, 300),
status: null,
statusText: '',
+ contentType: null,
duration: null,
error: null,
responsePreview: null,
- requestBodyPreview: null
+ requestBodyPreview: null,
+ proxyDetected: false
};
// Capture request body preview (redact keys)
@@ -92,14 +94,27 @@ const DebugLog = {
const resp = await self._origFetch(input, init);
entry.status = resp.status;
entry.statusText = resp.statusText;
+ entry.contentType = resp.headers.get('content-type') || null;
entry.duration = Math.round(performance.now() - start);
- // Log non-ok responses at higher severity
- const logType = resp.ok ? 'NET' : 'NET:ERR';
- self._addEntry(logType, `${method} ${url.slice(0, 80)} → ${resp.status} (${entry.duration}ms)`);
+ // Detect proxy interception: API call returned HTML
+ const isApiCall = url.includes('/api/') || url.includes('/health');
+ if (isApiCall && entry.contentType && entry.contentType.includes('text/html')) {
+ entry.proxyDetected = true;
+ self._addEntry('NET:PROXY', `${method} ${url.slice(0, 80)} → 🛡️ PROXY HTML (${entry.contentType}) (${entry.duration}ms)`);
+ try {
+ const clone = resp.clone();
+ const text = await clone.text();
+ entry.responsePreview = text.slice(0, 500);
+ } catch (e) { /* */ }
+ } else {
+ // Log non-ok responses at higher severity
+ const logType = resp.ok ? 'NET' : 'NET:ERR';
+ self._addEntry(logType, `${method} ${url.slice(0, 80)} → ${resp.status} (${entry.duration}ms)`);
+ }
// Clone and peek at error responses
- if (!resp.ok) {
+ if (!resp.ok && !entry.proxyDetected) {
try {
const clone = resp.clone();
const text = await clone.text();
@@ -148,7 +163,7 @@ const DebugLog = {
this.entries.shift();
}
- if (type === 'ERROR' || type === 'EXCEPTION' || type === 'REJECTION' || type === 'NET:ERR') {
+ if (type === 'ERROR' || type === 'EXCEPTION' || type === 'REJECTION' || type === 'NET:ERR' || type === 'NET:PROXY') {
this._errorCount++;
this._updateBadge();
}
@@ -197,7 +212,12 @@ const DebugLog = {
// ── State Snapshot ──────────────────────
getStateSnapshot() {
- const snap = {};
+ const snap = {
+ env: window.__ENV__ || 'unknown',
+ version: window.__VERSION__ || 'unknown',
+ basePath: window.__BASE__ || '(root)',
+ url: location.href,
+ };
// API client state (redact tokens)
if (typeof API !== 'undefined') {
@@ -254,6 +274,7 @@ const DebugLog = {
async runDiagnostics() {
this.log('DIAG', '── Starting connection diagnostics ──');
+ this.log('DIAG', `Environment: ${window.__ENV__ || 'unknown'} | Version: ${window.__VERSION__ || 'unknown'} | Base: ${window.__BASE__ || '(root)'}`);
const baseUrl = (typeof API !== 'undefined' && API._base)
? API._base
@@ -387,7 +408,7 @@ const DebugLog = {
const typeColors = {
'ERROR': '#e74c3c', 'EXCEPTION': '#e74c3c', 'REJECTION': '#e74c3c',
- 'NET:ERR': '#e74c3c',
+ 'NET:ERR': '#e74c3c', 'NET:PROXY': '#ff6b35',
'WARN': '#f39c12', 'WARNING': '#f39c12',
'LOG': '#95a5a6', 'INFO': '#3498db', 'DEBUG': '#7f8c8d',
'NET': '#2ecc71',
@@ -396,7 +417,7 @@ const DebugLog = {
let html = '';
const entries = document.getElementById('debugFilterErrors')?.checked
- ? this.entries.filter(e => ['ERROR', 'EXCEPTION', 'REJECTION', 'NET:ERR', 'WARN'].includes(e.type))
+ ? this.entries.filter(e => ['ERROR', 'EXCEPTION', 'REJECTION', 'NET:ERR', 'NET:PROXY', 'WARN'].includes(e.type))
: this.entries;
for (const entry of entries) {
@@ -430,9 +451,11 @@ const DebugLog = {
let html = '';
for (const req of [...this.networkLog].reverse()) {
- const isErr = req.error || (req.status && req.status >= 400);
- const statusColor = isErr ? '#e74c3c' : '#2ecc71';
- const statusText = req.error
+ const isErr = req.error || (req.status && req.status >= 400) || req.proxyDetected;
+ const statusColor = req.proxyDetected ? '#ff6b35' : (isErr ? '#e74c3c' : '#2ecc71');
+ const statusText = req.proxyDetected
+ ? `🛡️ PROXY (${req.status})`
+ : req.error
? `❌ ${req.error}`
: (req.status ? `${req.status} ${req.statusText}` : '⏳ pending');
@@ -444,6 +467,9 @@ const DebugLog = {
if (req.duration != null) html += ` ${req.duration}ms`;
html += ``;
html += `
`;
+ if (req.contentType) {
+ html += `
Content-Type:${this._esc(req.contentType)}
`;
+ }
if (req.requestBodyPreview) {
html += `
Request:${this._esc(req.requestBodyPreview)}
`;
}
@@ -475,6 +501,7 @@ const DebugLog = {
let text = `=== Chat Switchboard Debug Export ===\n`;
text += `Exported: ${new Date().toISOString()}\n`;
text += `URL: ${location.href}\n`;
+ text += `ENV: ${window.__ENV__ || 'unknown'} | VERSION: ${window.__VERSION__ || 'unknown'} | BASE: ${window.__BASE__ || '(root)'}\n`;
text += `UA: ${navigator.userAgent}\n\n`;
text += `--- STATE ---\n`;
@@ -488,7 +515,10 @@ const DebugLog = {
text += `--- NETWORK LOG (${this.networkLog.length}) ---\n`;
for (const r of this.networkLog) {
- text += `[${r.ts}] ${r.method} ${r.url} → ${r.status || 'FAILED'} ${r.error || ''} (${r.duration || '?'}ms)\n`;
+ const proxy = r.proxyDetected ? ' [PROXY]' : '';
+ text += `[${r.ts}] ${r.method} ${r.url} → ${r.status || 'FAILED'}${proxy} ${r.error || ''} (${r.duration || '?'}ms)`;
+ if (r.contentType) text += ` [${r.contentType}]`;
+ text += '\n';
if (r.responsePreview) text += ` Response: ${r.responsePreview.slice(0, 200)}\n`;
}
diff --git a/src/js/ui.js b/src/js/ui.js
index acd6bc8..0f797e9 100644
--- a/src/js/ui.js
+++ b/src/js/ui.js
@@ -518,6 +518,8 @@ const UI = {
el.classList.toggle('selected', el.dataset.value === val);
});
UI.updateCapabilityBadges();
+ if (typeof updateContextWarning === 'function') updateContextWarning();
+ if (typeof updateInputTokens === 'function') updateInputTokens();
},
updateModelSelector() {
@@ -625,11 +627,8 @@ const UI = {
const modelId = UI.getModelValue();
if (!modelId) return {};
const model = App.findModel(modelId);
- // Model in list has resolved caps; fallback to client-side lookup
- if (model?.capabilities && Object.keys(model.capabilities).length > 0) {
- return model.capabilities;
- }
- return (typeof lookupKnownCaps === 'function' ? lookupKnownCaps(modelId) : null) || {};
+ // Model in list has resolved caps from backend (catalog → heuristic)
+ return model?.capabilities || {};
},
updateCapabilityBadges() {
@@ -1700,11 +1699,37 @@ function formatMessage(content) {
function _formatMarked(text) {
const rendered = marked.parse(text, { breaks: true, gfm: true });
- let html = DOMPurify.sanitize(rendered, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['id', 'class', 'onclick'] });
+ let html = DOMPurify.sanitize(rendered, {
+ ADD_TAGS: ['details', 'summary', 'iframe'],
+ ADD_ATTR: ['id', 'class', 'onclick', 'sandbox', 'srcdoc']
+ });
+ // Process code blocks: add copy button, collapsible wrapper, HTML preview
html = html.replace(/