Adding a transcribe model on OpenRouter logged "provider 'OpenRouter' does
not support transcription model listing" and dropped the user into typing a
model id by hand — `list_transcribe_models` was never implemented for it, so
the trait default answered None.
OpenRouter does serve the catalogue: it is the same `/models` envelope under
`output_modalities=transcription`. The filter is not an optimisation — those
models carry `architecture.modality = "audio->transcription"` and are absent
from the unfiltered listing, so nothing else surfaces them. `fetch_openai_models`
therefore takes an optional raw query string; plain OpenAI has no filters, but
a gateway hosting several service kinds needs to say which catalogue it wants.
Transcription itself already worked: OpenRouter accepts the OpenAI-style
multipart body that `OpenAiAudioTranscriber` sends, so only the listing was
missing. The feed says nothing about per-model languages, hence the empty
`languages` — the hint stays the user's to set.
The LLM-requests page has been empty since 24ee5b8: deleting
`session/handler/llm_call.rs` dropped both halves of the request log.
The kernel builds the `ModelRequest` itself and sets `log: None`, so the
`LoggingModel` decorator — wrapped once per model by `LlmManager` — wrote
every metadata row with a NULL `user_id`, while the page (and the detail
endpoint) filter on it. Nothing wrote `llm_request_payloads` at all any
more, so the payload viewer had nothing to show either.
Correlation cannot come from `LlmManager`: it builds one shared client
per model and does not know whose traffic it serves. It now comes from
the `ModelSelector`, the one component that knows both the model and the
owner: `SkaldSelector::with_log(RequestLogTarget)` wraps the model it
hands out, so metadata lands in `llm_requests` attributed to the user and
the payload lands in that user's own encrypted DB, keyed by `request_id`.
Session and frame are read off the request's `conversation`/`frame`,
which makes kernel rounds, sub-agent frames and compaction summaries all
attributed with no extra plumbing (`ModelRequest::log` stays unused).
The compactor's summariser call is attributed too, which it never was:
`try_compact`/`force_compact` now take the owner (its selector is built
per compaction, so it can carry the target).
Three tests in `llm::logging` lock this down — the owner/session/frame
columns, the error row with the provider's rejected body, and the
metadata-only path when no owner pool is available.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The LLM call path now runs on the agent-loop crate's clients and trait:
- core-api: BuiltLlmClient.client is Arc<dyn agent_loop::model::Model>;
chatbot.rs (ChatbotClient + wire types) deleted; APP_NAME re-exported
from agent-loop
- providers (openai/anthropic/ollama/openrouter/requesty/declared) build
OpenAiModel/AnthropicModel/OllamaModel with the model's wire id
- LoggingModel decorator (llm/logging.rs) replaces LoggingChatbotClient;
per-request correlation (session/stack/user) travels in the new
ModelRequest.log field, never sent to providers
- llm_call/llm_loop/compactor speak Model::complete + ModelResponse;
retriability via Model::is_retriable (structured status, B6 rule now
the crate's default); payload persistence reads RawMeta off
ModelResponse/ModelError
- crates/llm-client and skald-core/src/chatbot deleted
Full workspace test suite green (incl. 162 skald-core + 32 agent-loop).
Remove the scope system end-to-end (llm_models.scope column, agent meta
scope field, scope-based tier in model selection, UI checkboxes/pills):
it was only a soft ranking hint, had drifted (6 UI scopes vs 3 used by
agents, 'general' not even selectable) and duplicated what strength
already decides. Strength stays the single AUTO-selection axis.
Compaction: the summary model is now pickable from the Settings page
via a new PropertyType::LlmModel config property (registry key
compaction_model), instance-wide and live (no restart). Fallback chain:
explicit pick -> compaction.strength from config.yml -> priority order;
a deleted configured model degrades to AUTO. ContextCompactor reads the
key at compact time through GlobalConfigManager.
Replace the old session_mcp_grants/stack_mcp_grants table pair with
a single activated_tools table that anchors each activation at the
assistant message_id that triggered it. The durable write moves from
the activate_tools tool itself to the round loop (handle_tool_call),
which has the message_id the DTL serializer positions injected tool
blocks against.
Introduce DtlMode (None / AnthropicToolReference / KimiSystemTools),
resolved per model from capabilities (opt-in via tool_search)
combined with the provider's dtl_format(). The message builder inserts
Kimi system {tools} blocks at the activation position, or emits
Anthropic tool_reference markers on the tool result. The tool-def
surface (all_tool_defs) switches shape: Anthropic declares everything
deferred; Kimi omits activated tools from the top-level array (system
takes over); None keeps the old grant-set logic.
Anthropic client: accept structured system arrays (cache_control on
the static block when DTL is active), carry defer_loading through
conversion, emit tool_reference blocks on result messages. Prompt
caching enabled exactly when DTL is active (anthropic provider).
MCP server list in the prompt is now a static catalogue (not split
Available/Active) — the split invalidated the cache on every activation.
Groundwork for providers.yaml dtl: key; Moonshot/Kimi providers wired
with kimi_system_tools and the k3* enrich now adds tool_search.
Compactor re-anchors activations whose message was compacted away.
Adds media.rs for per-turn attachment routing, message_builder
partitioning by resolved model capabilities, controller endpoints
for uploads, and server routing for /data/* behind session auth.
See CLAUDE.md §Multimodal attachments for full design.
Replace the five copy-paste OpenAI-compatible provider structs (moonshot,
moonshot_code, deepseek, zai, lm_studio) with one DeclaredProvider engine
driven by a providers.yaml catalog loaded at boot from the cwd — edit and
restart, no rebuild. The YAML carries identity, endpoints, per-model JSON
field mapping, id-glob enrichment rules and the reasoning knob (effort /
thinking request kinds); capability_flags keep vision and future input
modalities declarative. Anthropic, Ollama, OpenAI and OpenRouter stay
native (different wire protocols or bespoke parsing) and register
alongside; colliding declared ids are skipped. The shipped catalog is
validated by a unit test.
- Add MoonshotProvider and MoonshotCodeProvider (OpenAI-compatible)
- Extract `fetch_openai_models()` and `build_openai_llm()` shared functions,
deduplicating model-listing and client construction across OpenAI,
OpenRouter, DeepSeek, LM Studio, and Z.AI providers
- Add `lists_models` field to `ProviderUiMeta` — drives frontend model
picker dynamically instead of hardcoded `type_id` list
- Port DeepSeek, LM Studio, OpenRouter model listing to `fetch_openai_models`
- Set `lists_models: true` on Z.AI provider (missed in prior pass)
feat(mcp): named key placeholders {SECRET:name}/{ENV:name} in connector URLs
- `apply_key_placeholder` now accepts an `env` map alongside `api_key`
- Named tokens resolve from the connector's described `env[]` fields,
with `{SECRET:name}` falling back to `api_key` for backward compat
- Replace `substitute_secret_tokens` with `substitute_named_tokens`
- Unresolved tokens are left in place (visible misconfig) rather than
silently producing a wrong URL
fix(ui): connector detail hides generic API key when schema has secret
fix(ui): model picker uses `lists_models` from provider types endpoint
Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.
## UserManager + per-user encryption (§9/§11)
New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.
New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).
- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
<0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
`create_owner_tables` (one owner's content, identical in every file). No FK in
the owner bucket may reach the registry — enforced by a standalone test.
Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
so a crash leaves an orphan file, never a user without a database. `open_db`
never creates: a missing file is an error, not a silent empty database.
Not consumed yet: no login, call sites still use the shared system.db pool.
## Extract crates/skald-core
The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:
- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
(sibling of `http_router`), so the core no longer downcasts to
`MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
teardown-and-respawn; the core defaults to the supervisor exit code. The core
loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
only emits tracing events.
All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.