From 4ea932ef54d5185c40f2c234b8237296e01abfa2 Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 1 Sep 2026 21:07:13 +0100 Subject: [PATCH] feat(llm): Z.AI GLM-5.3 and GLM-5.3-Flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are added to the Z.AI static model list with their 1M-token context and 128K max output. GLM-5.3-Flash is natively multimodal, so it gets the vision and video capabilities — through an `override` rule, because the provider's `defaults: { vision: false }` already set the flag and a fill rule would have skipped it silently. Neither model can stop thinking (`thinking.type` only accepts "enabled"), so they get their own reasoning rule with low/high/max and no `disabled`, placed before the `glm-5*` family rule that would otherwise swallow them. --- CHANGELOG.md | 8 ++++++++ dev-docs/llm-stack.md | 6 ++++++ providers.yaml | 14 +++++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e3ed13..aa333be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ release PR may merge — and a section is closed at the commit that bumps it. ## [Unreleased] +### Added + +- Z.AI's new models are selectable on the **Models** page: **GLM-5.3** and **GLM-5.3-Flash**, + both with a 1M-token context. GLM-5.3-Flash is natively multimodal, so images and videos + attached to a message are sent to it directly instead of as a file path. Both always think — + Z.AI does not allow turning it off — and the reasoning control offers *low / high / max* + (default *max*) instead of an on/off switch. + ### Fixed - The **Providers** page said *API key missing* on every provider, including the ones diff --git a/dev-docs/llm-stack.md b/dev-docs/llm-stack.md index d9abae5..112c9da 100644 --- a/dev-docs/llm-stack.md +++ b/dev-docs/llm-stack.md @@ -10,6 +10,12 @@ LLM client abstraction (OpenAI-compat, Anthropic, Ollama…). OpenAI-compatible provider *types* are runtime data, not code: `providers/declared.rs` loads `providers.yaml` at boot (see Config in [../CLAUDE.md](../CLAUDE.md)); only non-OpenAI-compatible or bespoke providers (anthropic, ollama, openai, openrouter) stay native. **Retriability** (`Model::is_retriable`, `agent-loop`) keys on the real HTTP status carried by `ModelError { status }`, **not** a substring of the message — a model id/token count containing "404"/"401" cannot mis-classify; 401/403/404/422 don't retry, 400/429/5xx/network do. **Request logging** is the `logging.rs::LoggingModel` decorator, attached by the *caller's* `ModelSelector` (`loop_adapters/selector.rs::SkaldSelector::with_log`) — never by `LlmManager`, which builds one shared client per model and cannot know whose traffic it serves. The decorator's `RequestLogTarget` carries the owner: metadata → `llm_requests` in the registry (`user_id`, the column the UI filters on), payload bodies/headers → `llm_request_payloads` in that user's own encrypted DB, keyed by `request_id`; session + frame come from the request's own `conversation`/`frame`, so kernel rounds, sub-agent frames and compaction summaries are all attributed with no extra plumbing (`ModelRequest::log` is unused here) +## `providers.yaml` — two traps in the model metadata + +**`enrich` rules stop at the first glob that matches, and the default `mode: fill` skips a field that already has a value.** Both bite when adding a model to an existing family. Ordering: `glm-5.3-flash` matches `glm-5*` too, so a rule for it placed *after* the family rule never runs — the specific glob goes first, always. And `fill` means "the endpoint listing wins", which for a `static:` list is not the same as "nothing is set": `models.defaults` (today only `vision`) stamps every entry before `enrich` sees it, so a provider carrying `defaults: { vision: false }` — Z.AI does — needs **`mode: override`** to turn vision on for one model. A `fill` rule there parses, loads, logs nothing, and leaves the flag off; the only symptom is that images silently keep taking the textual `` path (see [Multimodal attachments](#multimodal-attachments)) on a model that can read them. `vision: true` also pushes the `vision` capability, but only when the rule actually applied. + +**A `reasoning.modes` `values` list is the whole contract with the provider — it is not a superset to trim in the UI.** Whatever it lists is what can be sent, so a model that cannot stop thinking (`thinking.type` accepting only `"enabled"`: GLM-5.3 and GLM-5.3-Flash) simply omits `disabled` from its rule, rather than inheriting the family's `[disabled, enabled]` toggle and sending a value the API rejects. With `request: { kind: thinking }`, any value other than `disabled`/`enabled` is emitted as `{"thinking":{"type":"enabled"},"reasoning_effort":v}`, which is exactly the shape those models want. + ## The provider API surface — the key is a boolean, never a value **No provider endpoint ever returns a stored `api_key`, and the trap is that omitting it silently reads as "no key".** `LlmProviderInfo` (list) and `ProviderDetail` (`src/frontend/api/llm.rs`, the detail DTO — deliberately *not* `LlmProviderRecord`, which does carry the secret) both expose **`has_api_key: bool`** instead. That is the whole contract: the UI needs to know *whether* a key is on file, never what it is, and the browser is where a leaked key would end up in a devtools tab or a screenshot. diff --git a/providers.yaml b/providers.yaml index f588d5f..b9f082a 100644 --- a/providers.yaml +++ b/providers.yaml @@ -145,11 +145,17 @@ providers: - { key: api_key, label: "API Key", required: true, secret: true } models: # Z.AI exposes no GET /models endpoint; this mirrors the console menu. - static: [glm-5.2, glm-5.1, glm-5, glm-5-turbo, glm-4.7, glm-4.6, glm-4.5, glm-4-32b-0414-128k] + static: [glm-5.3, glm-5.3-flash, glm-5.2, glm-5.1, glm-5, glm-5-turbo, glm-4.7, glm-4.6, glm-4.5, glm-4-32b-0414-128k] defaults: { vision: false } base_capabilities: [function_calling] enrich: - { match: "*128k*", context_length: 131072 } + # GLM-5.3-Flash is the first natively multimodal GLM (image + video in). + # `mode: override` is load-bearing: `defaults.vision` already stamped + # every static model with `Some(false)`, and a fill rule skips a field + # that is already set — the flag would silently stay off. + - { match: "glm-5.3-flash*", mode: override, context_length: 1048576, max_completion_tokens: 131072, vision: true, add_capabilities: [video] } + - { match: "glm-5.3*", context_length: 1048576, max_completion_tokens: 131072 } - { match: "glm-5*", context_length: 1048576 } - { match: "glm-4.7*", context_length: 200000 } - { match: "glm-4.6*", context_length: 200000 } @@ -157,6 +163,12 @@ providers: reasoning: request: { kind: thinking } modes: + # GLM-5.3 (and -Flash) cannot stop thinking: `thinking.type` only + # accepts "enabled", so the budget is picked with reasoning_effort + # alone and `disabled` is deliberately absent from the values. + - when: { models: ["glm-5.3*"] } + values: [low, high, max] + default: max # GLM-5.2+ adds a graded effort on top of the thinking toggle. - when: { models: ["glm-5.2*"] } values: [disabled, minimal, low, medium, high, xhigh, max]