Merge remote-tracking branch 'origin/ouroboros' (55ad78213) into ouroboros-agent/presence-resilience

Semantic integration of #1299 (memory history visibility) and #1207 (reasoning-effort descriptor + Z.ai provider). One textual conflict: tests/test_persistence_inventory.py EXPECTED_SCAN_PATHS resolved 295 (+1 Presence quarantine members, -1 removed memory journal rewrite path); verified by test_persistence_inventory + reference-book/inventory/domain checks (48 passed).
This commit is contained in:
Ouroboros 2026-09-25 23:16:14 +03:00
commit 5507134f71
59 changed files with 1037 additions and 498 deletions

View file

@ -15,6 +15,7 @@ SECRET_KEYS = (
"ANTHROPIC_API_KEY",
"MINIMAX_API_KEY",
"DEEPSEEK_API_KEY",
"ZAI_API_KEY",
"GITHUB_TOKEN",
)

View file

@ -112,6 +112,7 @@ _PROVIDER_ENV_KEYS = frozenset({
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "OPENAI_COMPATIBLE_API_KEY",
"CLOUDRU_FOUNDATION_MODELS_API_KEY", "ANTHROPIC_API_KEY", "MINIMAX_API_KEY",
"DEEPSEEK_API_KEY",
"ZAI_API_KEY",
"GIGACHAT_CREDENTIALS", "GIGACHAT_PASSWORD",
})
@ -145,6 +146,7 @@ _AUTHORITATIVE_ENV_PREFIXES = (
"ANTHROPIC_",
"MINIMAX_",
"DEEPSEEK_",
"ZAI_",
"CLOUDRU_",
"GIGACHAT_",
"CLAUDE_",

View file

@ -115,6 +115,7 @@ def _active_direct_provider(settings: dict[str, Any]) -> str:
("minimax", "MINIMAX_API_KEY"),
("cloudru", "CLOUDRU_FOUNDATION_MODELS_API_KEY"),
("deepseek", "DEEPSEEK_API_KEY"),
("zai", "ZAI_API_KEY"),
)
if _setting_or_env(settings, key)
]

View file

@ -62,6 +62,7 @@ EXTRA_SECRET_FIELDS = (
"CLOUDRU_FOUNDATION_MODELS_API_KEY",
"MINIMAX_API_KEY",
"DEEPSEEK_API_KEY",
"ZAI_API_KEY",
"GIGACHAT_PASSWORD",
"OUROBOROS_NETWORK_PASSWORD",
"TELEGRAM_BOT_TOKEN",

View file

@ -603,7 +603,7 @@ Skills UI.
## Grants for protected keys and host permissions
Some settings keys are protected: `OPENROUTER_API_KEY`,
`OPENAI_API_KEY`, `OPENAI_COMPATIBLE_API_KEY`, `ANTHROPIC_API_KEY`, `MINIMAX_API_KEY`, `DEEPSEEK_API_KEY`,
`OPENAI_API_KEY`, `OPENAI_COMPATIBLE_API_KEY`, `ANTHROPIC_API_KEY`, `MINIMAX_API_KEY`, `DEEPSEEK_API_KEY`, `ZAI_API_KEY`,
`CLOUDRU_FOUNDATION_MODELS_API_KEY`, `GIGACHAT_CREDENTIALS`, `GIGACHAT_PASSWORD`, `TELEGRAM_BOT_TOKEN`,
`GITHUB_TOKEN`, `OUROBOROS_NETWORK_PASSWORD`. These keys are NEVER
forwarded to a skill by default, even when listed in

View file

@ -22,13 +22,13 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t
| D12 | Settings & configuration | 15 | 0 |
| D13 | Safety, guards & runtime mode | 9 | 0 |
| D14 | Skills & extensions | 56 | 0 |
| D15 | Memory, knowledge, consciousness & self-evolution | 22 | 0 |
| D15 | Memory, knowledge, consciousness & self-evolution | 23 | 0 |
| D16 | Observability, usage accounting & cost | 11 | 0 |
| D17 | Projects, workspaces & task results | 23 | 0 |
| D18 | Launcher, packaging, platform & shared substrate | 15 | 0 |
| D19 | Frozen contracts (ABI) | 10 | 0 |
| D20 | Presence | 10 | 0 |
| **total** | | **574** | **0** |
| **total** | | **575** | **0** |
## Dependency direction matrix (strict, pinned)
@ -720,6 +720,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne
- `ouroboros/knowledge.py`
- `ouroboros/memory.py`
- `ouroboros/memory_journal_compaction.py`
- `ouroboros/memory_nomination_receipts.py`
- `ouroboros/post_task_evolution.py`
- `ouroboros/project_facts.py`
- `ouroboros/reflection.py`

View file

@ -22,7 +22,7 @@ scanned data-relative path to be covered by a row here (count-anchored both ways
keys migrate). Governs subagent worktrees, headless/task drives, task trees,
service logs, consumed schedule receipts,
confirmed capability probes, delegate recovery/supervision sweeps, code_intel
and reconcile-failed prunes, memory-journal digesting and agent media.
reconcile-failed prunes, and agent media. Memory journals retain full new rows independently of this knob.
- **Rotation** — `supervisor/state.py::rotate_jsonl_log_if_needed`: >800 KB →
atomic rename to `archive/<prefix>_<ts>.jsonl` under the append lock.
Applied on the supervisor tick to `chat.jsonl`, `progress.jsonl`,
@ -143,10 +143,10 @@ scanned data-relative path to be covered by a row here (count-anchored both ways
| `memory/scratchpad.md` + `scratchpad_blocks.json` | `ouroboros/memory.py` (derived, regenerated from blocks under lock) | none | bounded: 10 blocks, eviction journaled first (fail-closed) | regenerated; evicted history in journal |
| `memory/WORLD.md` | `ouroboros/world_profiler.py` (write-once) | none | fixed | regenerates on restart — deletion IS the refresh mechanism |
| `memory/registry.md`, `memory/deep_review.md` | `ouroboros/tools/memory_tools.py` (section RMW), `ouroboros/agent.py` (overwrite) | none | unbounded / last-wins — accepted | recreated lazily |
| `memory/dialogue_blocks.json` + `dialogue_meta.json` | `ouroboros/consolidator.py` (locked atomic) | none | bounded by era compression (10 blocks, oldest 4 compressed) | blocks: compressed biography irreproducible; meta: full re-consolidation (cost, not loss) |
| `memory/dialogue_blocks.json` + `dialogue_meta.json` | `ouroboros/consolidator.py`, `memory_nomination_receipts.py` (locked atomic) | `pending_knowledge_nominations` source-entry IDs; legacy `last_unpublished_nominations` preserved | blocks bounded by era compression (10 blocks, oldest 4); unresolved nomination index unbounded; no tool-level resolver yet, later success never retires old debt | blocks: compressed biography irreproducible; meta: cursor and unpublished-obligation evidence lost |
| `memory/dialogue_summary.md` | none — legacy read-only (reader in context.py) | none | frozen | legacy artifact; nothing writes it |
| `memory/knowledge/**` (topic .md + `index-full.md` + `patterns.md`) | `ouroboros/tools/knowledge.py`, `consolidator.py` (index rebuild), `reflection.py` (patterns CAS rewrite) | none | topic files unbounded — accepted (curated by consolidation); backlog topic merge-only fail-closed | recreated lazily; knowledge lost |
| `memory/*_journal.jsonl`, `memory/knowledge_history.jsonl`, `memory/knowledge/patterns_history.jsonl` | `ouroboros/memory.py`, `tools/control_runtime.py`, `tools/knowledge.py`, `reflection.py` — every append through the `append_jsonl` sidecar-lock seam | scratchpad journal: `type` rows; others unversioned full-text snapshots; digested rows carry `content_digested: true` | full old+new text only inside GC retention: older identity/knowledge/patterns rows go digest-only (sha256+len) at startup (`memory_journal_compaction.py`, under the append lock, unreadable lines byte-preserved); scratchpad journal keeps its own eviction contract | undo/provenance record lost (live .md survives); eviction/rewrite paths fail closed when journal append fails; digested history is irreversible by design |
| `memory/*_journal.jsonl`, `memory/knowledge_history.jsonl`, `memory/knowledge/patterns_history.jsonl` | `ouroboros/memory.py`, `tools/control_runtime.py`, `tools/knowledge.py`, `reflection.py` — every append through the `append_jsonl` sidecar-lock seam | scratchpad journal: `type` rows; others unversioned full-text snapshots; historical digested rows retain `content_digested: true` | complete new old+new snapshots are retained indefinitely; `memory_journal_compaction.py` is a read-only compatibility entry point, not a source rewriter; existing digest-only rows cannot be restored; the `memory_journal_observation` startup event gives byte sizes (or missing/unreadable) for the three named journals; scratchpad keeps its eviction journal | deleting the journals loses undo/provenance; eviction/rewrite paths fail closed when journal append fails; historically digested content remains irrecoverable |
| `memory/owner_mailbox/<task>.jsonl` + `.acks.jsonl` | `ouroboros/owner_mailbox.py` (append-only; revocation appends, reader resolves) | `kind` discriminator | lifecycle-bounded: unlinked at task terminal; a startup sweep unlinks mailboxes whose task has a SETTLED durable result (no result / non-terminal keeps the mailbox fail-closed) | undelivered owner directives + restart-surviving hurry latch lost; acks lost ⇒ re-delivery |
## 7. Skills payloads, tasks, uploads, projects, services
@ -180,13 +180,15 @@ scanned data-relative path to be covered by a row here (count-anchored both ways
Always safe (pure caches, recreated): `state/pycache`, `state/code_intel`,
`state/evolution_metrics_cache.json`, `playwright-browsers/`, `state/cx`,
`state/betterleaks`, lock files, `state/server_port`.
Safe with bounded cost: `WORLD.md` (regenerates), `dialogue_meta.json`
(re-consolidation), `state/usage_import_watermark.json` (safe re-import),
Safe with bounded cost: `WORLD.md` (regenerates),
`state/usage_import_watermark.json` (safe re-import),
`ui_preferences.json`, `auth_secret.key` (one re-login).
Fail-closed losses (system stays correct, work/authority is forgone):
skill state dirs, `advisory_review.json`, `capability_evidence.json`,
`pending_restart_verify.json`.
Dangerous (authority/history destruction): `settings.json`,
`state/usage_attempts.jsonl`, `task_results/**`, `logs/events.jsonl`,
`memory/**`, `archive/**`, `observability/**`, `state/subagent_worktrees.json`
`memory/**` (including `dialogue_meta.json`: deletion erases cursor and pending
nomination obligations; re-consolidation cannot reconstruct the old IDs),
`archive/**`, `observability/**`, `state/subagent_worktrees.json`
(leak), `claudexor/**`, `state/python-userbase` (real deps).

View file

@ -161,7 +161,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
├── delegate_source_coverage.py ← Oversized-work-order source custody: interval union/completeness, durable receipt bounds, replay-safe start binding — incomplete source cannot authorize a terminal PASS/apply; no alternate store
├── delegate_evidence.py ← Read-side execution evidence over custody rows (`task_execution_evidence`; `delegate_start_attempted` counts blocked and uncustodied attempts), the stamp writers `record_nanny_nudge_stamp`/`record_start_blocked`, `applied_access_profiles`, `acceptance_patch_dispositions` (cap 20, `unreviewed_delegated_apply`); an unreadable log is `evidence_read_failed`, never clean (§6 Delegated subagents; §11.1)
├── synthesis_cost_text.py ← Synthesis-prompt renderers for the pre-synthesis cost/outcome snapshot over the SSOT `cost_display`
├── llm.py ← Multi-provider LLM routing (OpenRouter/OpenAI/compatible/Cloud.ru/MiniMax/DeepSeek/GigaChat/Anthropic); canonical conversations stay function-shaped while the physical-send seam delegates exact-route request adaptation to the request-wire leaves below
├── llm.py ← Multi-provider LLM routing (OpenRouter/OpenAI/compatible/Cloud.ru/MiniMax/DeepSeek/Z.ai/GigaChat/Anthropic); canonical conversations stay function-shaped while the physical-send seam delegates exact-route request adaptation to the request-wire leaves below
├── llm_routing.py, llm_attempt.py, llm_messages.py, llm_capability_policy.py, llm_fallback.py, llm_pricing.py, llm_openai_compatible.py, llm_anthropic.py, llm_gigachat.py, llm_local.py, llm_claudexor.py, llm_substitution.py ← The client's leaves behind that facade: target resolution, client construction and route affinity; physical-attempt candidates and send-time prompt-cache policy; wire transcript shaping and the reasoning-artifact contract; capability metadata and the learned parameter/effort policy; the recovery ladder; live price catalogs (OpenRouter, Cloud.ru); the wire lanes — OpenAI-compatible, native Anthropic, GigaChat, local llama.cpp, caller-owned Claudexor model operations; which account a route must not prefer next and the refusal of a round ANOTHER model answered (§6 Context fitting, retry, and compaction; Caller-owned subscription model calls; §7 Direct-provider routes)
├── llm_stream.py ← Complete Chat Completions and native Messages SSE assembly inside one physical attempt; private wire/partial evidence and terminal framing (§6 Streams and transport waits)
├── net_transport.py ← Shared httpx transport construction for remote LLM clients; TCP-keepalive socket options (§6 Context fitting, retry, and compaction)
@ -186,11 +186,12 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
├── consciousness_wake.py ← The wake-up MESSAGE (`prompts/CONSCIOUSNESS.md` rendered as the turn's USER message, cuts disclosed as `(+N more)`) and the origin/authority envelope `wake_task_metadata`
├── consciousness_authority.py ← The three autonomy levels of a wake (observe/act/full) and their consequences — `disabled_tools`, bound at dispatch only so the prompt prefix matches an owner turn's, `runtime_mode_cap=light` below Full, and Observe's argument-level narrowing of the mutating names it keeps (§6 Background consciousness and Evolution)
├── consciousness_allowance.py ← Rolling-24h consciousness spend read off the usage ledger; typed `allowance_unknown` on a read failure; read by the alarm and the single admission door in `supervisor/queue.py`
├── room_consolidation.py ← Per-room memory: one Light draft + one source-grounded correction per room, deterministic assembly of typed room sections into one block/era; no cross-room LLM recombine, legacy blocks keep unknown provenance (§6)
├── consolidator.py ← Dialogue consolidation with a generation-aware cursor; an unfindable generation appends a loud `[MEMORY GAP]` block, never a silent offset reset; `last_consolidation_error` / `last_unpublished_nominations` in `dialogue_meta.json` (§6 Durable memory and project focus)
├── room_consolidation.py ← Per-room Light draft/correction and deterministic assembly; no cross-room LLM recombine (§6)
├── consolidator.py ← Generation cursor, explicit `[MEMORY GAP]`, and knowledge nomination outcomes in `dialogue_meta.json` (§6)
├── memory_nomination_receipts.py ← Source-addressed pending nominations; no cross-batch retirement (§6)
├── memory.py ← Scratchpad, identity, chat history
├── knowledge.py ← `ouroboros/knowledge.py`: linked-Markdown note addressing, exact source reads, generated shelf indexes for global and project knowledge, and revision-checked writes, so concurrent cognition cannot silently overwrite a newer note (§6 Durable memory and project focus)
├── memory_journal_compaction.py ← Digest-only compaction of old memory-journal snapshots: the digest replaces the snapshots it summarizes, never a silent drop
├── memory_journal_compaction.py ← Startup read-only size facts (`memory_journal_observation`); new history stays complete, old digests unrecoverable
├── project_facts.py ← project_id resolution (explicit `--project-id` or workspace-path hash); per-project knowledge dir `projects/<id>/knowledge` isolated from `memory/knowledge`; journal/workpad helpers
├── task_tree_ledger.py ← Append-only `data/task_trees/<root>/blackboard.jsonl`: EPHEMERAL typed swarm coordination (`tree_note`/`tree_read`), mirrored into the durable project journal at root completion; pruned on root terminal
├── projects_registry.py ← Durable `data/state/projects.json`: 80-char names, `active|deleting|tombstoned`; deletion preserves bindings/history/folder/memory; a tombstone blocks resurrection; reconcile NEVER prunes (§6 Project registry and lease)

View file

@ -18,7 +18,7 @@ Completion is one HTTP conversation on every host — `POST /api/onboarding/comp
Validation (`settings_setup_contract.validate_setup_payload`, shared by the desktop and web wizard through `onboarding_wizard.py`) is structural: at least one exposed remote configuration, selected managed model source or local model source; local-only setup routes at least one active lane locally; Main is required, while Light, Vision, Consciousness and Fallback keep inheritance/empty semantics and Heavy is readable only for bounded migration into an explicit API actor; enforcement and runtime mode are closed enums, budgets finite and positive, the MiniMax region closed, a Hugging Face local source needs a filename. Credential length is checked only on fields changed in the payload: rejecting an unchanged short legacy value would discard the whole form, including its own repair.
Provider readiness and provider defaulting are separate. With no OpenRouter, legacy OpenAI base or OpenAI-compatible endpoint, exactly one registered direct provider (OpenAI, Anthropic, Cloud.ru, GigaChat, MiniMax, DeepSeek) receives its provider-prefixed defaults and migration of untouched shipped/legacy slot values. Multiple direct providers stay owner-editable, and OpenRouter keeps router-style routing. An arbitrary OpenAI-compatible endpoint gets no guessed model ids — compatible servers have no universal safe name; the owner selects explicit `openai-compatible::...` routes. A local-source install with no remote provider clears only untouched shipped remote Light/Fallback values that would be unreachable. This is migration of defaults, not a model allowlist, and never proof the local server is running.
Provider readiness and provider defaulting are separate. With no OpenRouter, legacy OpenAI base or OpenAI-compatible endpoint, exactly one registered direct provider (OpenAI, Anthropic, Cloud.ru, GigaChat, MiniMax, DeepSeek, Z.ai) receives its provider-prefixed defaults and migration of untouched shipped/legacy slot values. Multiple direct providers stay owner-editable, and OpenRouter keeps router-style routing. An arbitrary OpenAI-compatible endpoint gets no guessed model ids — compatible servers have no universal safe name; the owner selects explicit `openai-compatible::...` routes. A local-source install with no remote provider clears only untouched shipped remote Light/Fallback values that would be unreachable. This is migration of defaults, not a model allowlist, and never proof the local server is running.
`scripts/build_repo_bundle.py` creates the packaged seed only from a clean named checkout, writes a git bundle of that commit/tags, and records schema, version, source SHA, release tag, bundle hash and managed branch/remote metadata (the release-tag check itself: §8). The launcher validates the manifest fields and the bundle SHA-256 but no per-file member set: clone-time Git verification proves the manifest source object exists and checked-out HEAD equals it.

View file

@ -557,9 +557,9 @@ Every IMPLICIT claim — the UI conversion, that admission, the reaper's retry a
`context.py` assembles static governance, semi-stable memory, and dynamic task evidence without treating truncation as forgetting; the recent-activity sections are each task's OWN newest rows (progress 50 rendered; tools 20 selected, 10 rendered and 20 scanned for review markers; events 200 counted by type) through the bounded reader `jsonl_tail.py` (`Memory.read_task_recent`: a doubling live tail plus at most three newest archives), never a global tail filtered afterwards (issue #131), and their header's coverage line names the rows, the window and any unopened archives while `read_file` pages the rest; a subagent child gets the same three windows beside its `## Working sources` block, its tools and events read from its own execution drive (its worker rows; host-side rows such as waits stay in the canonical log, as the header says) and progress from the canonical log; the Development context matrix and `context_layout.py` own which reference form is resident. When the rendered scratchpad exceeds `SCRATCHPAD_SECTION_BUDGET_CHARS`, `context.py` keeps the newest whole blocks that fit and drops the oldest behind an in-band gap marker naming `memory/scratchpad.md` as the live source; no block is retired by a context build, and scratchpad replacement keeps its explicit summary and source-journal provenance.
`consolidator.py` publishes a dialogue block only after every part succeeds, retaining raw generations and their cursor; an unfindable generation appends `[MEMORY GAP]`, never silently resets the offset. `context_fit` measures full Light requests against fresh route/account capacity and calibrated density (`llm_local.local_context_limits` owns local output reservation; missing/stale evidence remains unknown). `room_consolidation.py` drafts and corrects each room separately, then deterministically assembles the sections. Episodic text is grounded in that room's source; cumulative knowledge replacements require the complete current note plus the episode in BOTH stages. Corrected entries bind to the corrector's complete delivered read, never the draft's revision credit. A narrow or older episode cannot negate prior facts or later receipts; supported corrections and removals remain model judgment. Range reads, authored views, CAS and old/new history remain the publication path; no new stage or store. Failed, empty or truncated correction withholds the chunk, cursor and nominations.
`consolidator.py` publishes a block and advances its generation-aware cursor only after complete room draft and correction; a missing generation appends `[MEMORY GAP]` instead of resetting. `context_fit` measures Light against fresh route/account capacity and calibrated density; `llm_local` owns the local output reserve, and absent evidence stays unknown. `room_consolidation.py` processes each room separately and assembles sections deterministically. Both knowledge stages receive the entire current note and source episode; the corrector's complete read, not the draft's, binds revised entries. Older episodes cannot negate newer facts; model judgment governs supported corrections. Source range reads and CAS preserve old/new history. Startup compaction is a no-op; earlier digests cannot be reversed. A failed correction withholds the chunk and cursor.
Oversized source splits without clipping, including inside an entry; continuation context stays outside source bytes. A real refusal records its source hash and strictly smaller same-route byte bound in `dialogue_meta.json` (`consolidation_retry`); changed source, route, capacity or output reserve invalidates it. Era compression regroups each recorded room across blocks and reassembles deterministically; legacy untyped blocks remain explicitly unknown provenance. Failed/overflowed eras preserve old blocks. Failures retain `last_consolidation_error`, cleared by an advance without a new failure; incomplete knowledge publication retains `last_unpublished_nominations`. Unknown spend remains nullable and control/resource/unknown model errors retain `propagate_model_error` semantics.
Oversized sources split without clipping, including within an entry. `consolidation_retry` records source hash and a smaller same-route bound, invalidated by source/route/capacity/reserve changes. Era compression regroups rooms deterministically; failed eras retain blocks and legacy provenance stays unknown. `last_consolidation_error` clears after a failure-free advance. `pending_knowledge_nominations` records each source entry BEFORE note publication; an unrelated successful batch cannot remove one. Legacy `last_unpublished_nominations` persists. Health shows three distinct abbreviated source+position IDs and omitted count; full proposals live in `knowledge_history.jsonl`. Unreadable meta preserves debts and warns in Health; Nano pressure records no-progress instead of aborting Main. Invalid legacy receipts warn separately. Old digests and debts need explicit resolution. Spend remains nullable; model-control errors follow `propagate_model_error`.
Consolidation labels every chronological source message through `dialogue_provenance.RoomLabelResolver`, using the actual `chat_id`, never lineage `project_id`; one read-only registry snapshot supplies the window. Main is named only for the actual Main id, a resolved project uses its current registry name and stable chat id, and missing, unknown or ambiguous rooms stay explicit. Ephemeral formatter offsets carry the original room/author/direction/transport header into split continuations without parsing message bodies or duplicating their bytes. Room draft and correction prompts require meaningful decisions, approvals, outcomes and unresolved commitments of that room, retaining source distinctions (who decided, what was authorized, what stays owed) and one first-person Ouroboros voice. Length adapts to content within the existing output-token ceiling; no per-room word quota, semantic gate or absent room is imposed. Labels establish provenance, not summary success. The mixed Main recent view opts into the same labels; focused Project rendering, membership and explicit `chat_history` retain their existing behavior and bytes.

View file

@ -67,6 +67,8 @@ A registry of `config.SETTINGS_DEFAULTS` (exact defaults canonical in `settings_
| MINIMAX_API_KEY | "" | MiniMax credential |
| MINIMAX_REGION | "" | MiniMax region (empty resolves `global_en`) |
| DEEPSEEK_API_KEY | "" | Optional DeepSeek direct key (`deepseek::...` values; route below) |
| ZAI_API_KEY | "" | Optional Z.ai (GLM) direct key (`zai::...` values; route below) |
| ZAI_PLAN | "" | Z.ai endpoint plan: empty/`payg` = pay-as-you-go, `coding` = Coding Plan |
| OUROBOROS_NETWORK_PASSWORD | "" | Non-localhost HTTP gate password (`server_auth.py`; unset only warns — §8) |
| OUROBOROS_SERVER_HOST | 127.0.0.1 | HTTP bind host (`0.0.0.0` for Docker/non-loopback) |
| OUROBOROS_UPDATE_CHANNEL | `stable` | Update channel: stable/qa/development (§8) |
@ -219,10 +221,12 @@ A registry of `config.SETTINGS_DEFAULTS` (exact defaults canonical in `settings_
#### Direct-provider routes
Direct-provider review fallback (legacy name: OpenAI-only review fallback): with exactly one official direct provider configured, `config.get_review_models()` compiles that provider's declarative reviewer-role sequence from provider-prefixed model IDs. Scope covers official OpenAI, Anthropic, MiniMax, DeepSeek, Cloud.ru, and GigaChat; OpenRouter, legacy-base, OpenAI-compatible and mixed configurations stay outside. Per-provider role coverage differs — three independent Main slots down to one role model for every slot (`provider_models.compute_direct_review_models_fallback`). `_exclusive_direct_remote_provider_env` returns empty when OpenRouter, legacy `OPENAI_BASE_URL`, OpenAI-compatible keys or several direct providers are present, and the fallback requires `provider_models.migrate_model_value` to make the main model already start with the exclusive provider prefix, so free text cannot silently enter a single-provider route (DEVELOPMENT "Provider Independence").
Direct-provider review fallback (legacy name: OpenAI-only review fallback): with exactly one official direct provider configured, `config.get_review_models()` compiles that provider's declarative reviewer-role sequence from provider-prefixed model IDs. Scope covers official OpenAI, Anthropic, MiniMax, DeepSeek, Z.ai, Cloud.ru, and GigaChat; OpenRouter, legacy-base, OpenAI-compatible and mixed configurations stay outside. Per-provider role coverage differs — three independent Main slots down to one role model for every slot (`provider_models.compute_direct_review_models_fallback`). `_exclusive_direct_remote_provider_env` returns empty when OpenRouter, legacy `OPENAI_BASE_URL`, OpenAI-compatible keys or several direct providers are present, and the fallback requires `provider_models.migrate_model_value` to make the main model already start with the exclusive provider prefix, so free text cannot silently enter a single-provider route (DEVELOPMENT "Provider Independence").
DeepSeek (`deepseek::`): the OpenAI-compatible endpoint is the fixed constant `provider_models.DEEPSEEK_BASE_URL`; a proxy or mirror belongs to `openai-compatible::`, and slash-form `deepseek/...` stays OpenRouter. The canonical effort scale is projected onto the provider's wire dialect at the send boundary, a forced tool choice is served with thinking disabled (thinking accepts only `auto`/`none`), and every tier change is disclosed as `reasoning_effort_clamped` (projection table: `provider_models.DEEPSEEK_REASONING_EFFORT_ALIASES`). `reasoning_content` stays on CANONICAL assistant turns for strict v4 replay (an explicit empty string marks a turn produced without provider reasoning); other lanes strip the field from their physical send copy and cross-family switches scrub it. On the send copy only, system/assistant/tool content arrays are flattened to strings — the API accepts arrays on user turns alone (`llm_openai_compatible.py`). Caching is automatic, cost stays nullable without an exact catalog, and the 1M context claim needs route-fingerprinted evidence or owner acknowledgement.
Z.ai (`zai::`): GLM through Z.ai's OpenAI-compatible API. `ZAI_PLAN` selects the endpoint (`provider_models.resolve_zai_base_url`: empty/`payg` = `api.z.ai/api/paas/v4`, `coding` = the Coding Plan endpoint); a proxy or the China host belongs to `openai-compatible::`, and slash-form `zai/...` stays OpenRouter. An absent `reasoning_effort` is served at the provider's MAX, so the canonical scale is always projected onto Z.ai's own `low/high/max` enum at the send boundary (`provider_models.ZAI_REASONING_EFFORT_ALIASES`: none/minimal→low, medium→high, xhigh/ultra→max — GLM-5.3 rejects every other value and cannot disable thinking, HTTP 400 code 1210) and every tier change is disclosed as `reasoning_effort_clamped`; a forced tool choice keeps its tier (no DeepSeek-style suppression). HTTP 429 code 1113 "Insufficient balance" is billing (also a Coding Plan key on the pay-as-you-go endpoint), so the provider Test reports it as `No credits`, not "Rate limited".
GigaChat (`gigachat::`): the native `gigachat` library, not OpenAI-compatible — OpenAI `tools` map to GigaChat `functions`, one `function_call` per turn (parallel `tool_calls` collapse to the first), `tool` results become role `function` and must be valid JSON (plain text is wrapped as `{"result": ...}`), and `system` must come first, so later system-reminders demote to `user` (`llm.py::_chat_gigachat`). `reasoning_effort` is deliberately omitted: hidden reasoning can consume the whole output budget and return empty content. No live cost source exists, so cost stays nullable/unknown, never a hand-maintained tariff. A GigaChat scope row runs native retrieval on its own window, with at most one function call per turn. Reading gaps are diagnostic and never remove its response from quorum; the agent decides whether more reading is needed. Missing inspection tools still produce `native_inspection_unavailable`, not a completed review, and the blocking triad continues to review the full staged diff (§6 Review stack).
---

View file

@ -385,6 +385,7 @@ rows — review-only maintenance.
| `ouroboros/reviewer_slot_config.py::_ACCEPTANCE_API_PANEL_MEASURED` | Historical API-panel comparison: approximately 12 s / $0.07 per model row per task (median of the 2026-09-01 OSWorld traces); 75 s / $0.82 for a three-row panel on ProgramBench | Workload and route dependent | The named measurement constant used by the one-time delivery disclosure | Repeat the same workload with recorded model, route and usage | An old comparison can be mistaken for a current tariff or a subscription-cost estimate | Keep the date and workload visible; current usage owns money, and session delivery spends subscription time |
| `ouroboros/llm_claudexor.py::cache_key_for_model` | The 2026-09-17 measurement found Codex prefix reuse across conversations requires one `prompt_cache_key` + `session_id`, while per-conversation turn states remain valid under that shared session | Provider dependent | Dated measurement beside the key derivation | Re-measure cache reads and turn state across two conversations | A stale positive pays cold prefixes or breaks turn state | Re-measure before changing the key scope |
| `ouroboros/llm_openai_compatible.py` DeepSeek send projection | The 2026-09-03 probe found thinking accepts only `auto`/`none` tool choice; required/named calls returned 400 on both probed v4 models | Provider dependent | Dated probe recorded beside the send projection and its transport tests | Re-probe the exact endpoint/model when that dialect changes | Removing the projection too early breaks forced calls; keeping it after a provider change may suppress supported thinking | Revalidate the wire contract before changing the projection; keep its effect disclosed |
| `ouroboros/provider_models.py::ZAI_REASONING_EFFORT_ALIASES` (Z.ai send projection) | The 2026-09-21 contributor probe (PR #1207, Coding Plan key, glm-5.3): only `low`/`high`/`max` are accepted, an absent tier is served at max, thinking cannot be disabled (400 code 1210), and forced tool_choice works with thinking on; GLM-5.2 accepts the wider scale | Provider dependent | Dated probe recorded beside the projection and its tests | Re-probe the exact endpoint/model when Z.ai changes the enum or a GLM release changes semantics | Dropping the projection bills every call at max; a stale one rejects tiers the provider would accept | Revalidate the wire contract before changing the projection; keep its effect disclosed |
### Provider Independence
@ -435,9 +436,9 @@ slug, not an official OpenAI model id, so a direct OpenAI Chat slot uses the pla
Sol id (the slug in Chat Completions is a guaranteed 404) — a compatibility
constraint, not a mutable capability table; direct OpenAI tool conversations stay
on Chat Completions and a model-name prefix is never admission authority;
DeepSeek is the second effort-carrying route, its `reasoning_effort` keyed on the
provider id rather than a name prefix or capability field, so a hand-built target
cannot silently drop it; direct Anthropic is the deliberate exception to a purely
DeepSeek and Z.ai carry `reasoning_effort` through provider-specific projections
keyed on the provider id rather than a name prefix or capability field, so a
hand-built target cannot silently drop it; direct Anthropic is the deliberate exception to a purely
reconstructed provider transcript, and no effort-to-`budget_tokens` policy is
synthesized (ARCHITECTURE §6 "Context fitting, retry, and compaction", ARCHITECTURE §7 "LLM output token
budgets"). A provider-specific optional feature may be unavailable elsewhere, but

View file

@ -2,7 +2,7 @@
Machine extraction of the `docs/ARCHITECTURE.md` "Data layout (`~/Ouroboros/`)" tree — the durable-file orientation carrier (this tree's counterpart of the reference PERSISTENCE_OWNERS derivation checklist) — regenerated by `python scripts/regenerate_inventories.py`. Do not edit. Every entry is probed against reality: repo entries must exist as tracked paths; data-plane entries must appear as a literal in the runtime sources that construct them. A durable file renamed or removed in code while its tree row survives = red (`tests/test_generated_inventories.py`).
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 602-691; UTF-8 SHA-256 `1839b923a939496f18c4d428807d8c876ca14422dd09c33001bf39a2b4a0e0ba`.
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 603-692; UTF-8 SHA-256 `7954873ca14387e7d615b148fc6a97444eb6ff922837f3a33a3bedc034362b3f`.
- entries: **79** (code-ref: 72, repo-dir: 6, repo-path: 1)

View file

@ -1340,7 +1340,7 @@ def probe(
# excluding it would starve the route of density witnesses entirely.
_CACHE_INCLUSIVE_PROMPT_TOKEN_PROVIDERS = frozenset({
"openrouter", "openai", "openai-compatible", "cloudru", "local", "anthropic",
"deepseek",
"deepseek", "zai",
})

View file

@ -24,7 +24,7 @@ DEFAULT_COLAB_APP_ROOT = "/content/drive/MyDrive/Ouroboros"
DEFAULT_COLAB_REPO_DIR = "/content/ouroboros_repo"
DEFAULT_OFFICIAL_REPO_URL = "https://github.com/razzant/ouroboros.git"
_SECRET_KEYS = ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "DEEPSEEK_API_KEY", "CLOUDRU_FOUNDATION_MODELS_API_KEY", "GITHUB_TOKEN", "TELEGRAM_BOT_TOKEN")
_SECRET_KEYS = ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "DEEPSEEK_API_KEY", "ZAI_API_KEY", "CLOUDRU_FOUNDATION_MODELS_API_KEY", "GITHUB_TOKEN", "TELEGRAM_BOT_TOKEN")
def _run_colab_git_network(args: list[str], *, cwd: pathlib.Path | None = None) -> str:
@ -87,7 +87,7 @@ def collect_colab_secrets() -> Dict[str, str]:
"""
# Providers the one-click Colab launch can auto-route models for via
# apply_runtime_provider_defaults (OpenRouter is the default aggregator;
# OpenAI/Anthropic/MiniMax/DeepSeek/Cloud.ru have direct model defaults). OpenAI-compatible
# OpenAI/Anthropic/MiniMax/DeepSeek/Z.ai/Cloud.ru have direct model defaults). OpenAI-compatible
# endpoints have no universal model default and need explicit OUROBOROS_MODEL_*
# config, so they are an advanced manual path, not part of the quick launch.
provider_keys = (
@ -96,6 +96,7 @@ def collect_colab_secrets() -> Dict[str, str]:
"ANTHROPIC_API_KEY",
"MINIMAX_API_KEY",
"DEEPSEEK_API_KEY",
"ZAI_API_KEY",
"CLOUDRU_FOUNDATION_MODELS_API_KEY",
)
out: Dict[str, str] = {}

View file

@ -10,7 +10,6 @@ from ouroboros import room_consolidation
from ouroboros.utils import (
append_jsonl,
atomic_write_json,
read_json_dict,
replace_atomic,
utc_now_iso,
read_text,
@ -388,6 +387,10 @@ def _run_block_consolidation(
return total_usage
for nominated_block, _entries in pending_knowledge:
nominated_block["knowledge_source_ref"] = ref
if knowledge_context is not None:
from ouroboros.memory_nomination_receipts import prepare
pending_ids = prepare(meta, source_id, pending_knowledge)
atomic_write_json(meta_path, meta) # Debt precedes block and note publication.
existing_blocks = _load_blocks(blocks_path)
all_blocks = existing_blocks + new_blocks
@ -435,21 +438,11 @@ def _run_block_consolidation(
"source_ref": block["knowledge_source_ref"], "outcomes": block["knowledge_writes"],
})
if pending_knowledge:
# Nominations were durable before mutation. Outcome facts belong to
# the same blocks, so a failed write is available to later learning.
_write_locked_json(blocks_path, all_blocks)
# Era compression later replaces these blocks with one object carrying no
# knowledge_writes, so this batch receipt lives in meta, not in a scan of
# dialogue_blocks.json. A fully published batch clears it; a run with no
# nominations at all leaves the older receipt standing.
# Count what was NOMINATED, not only what produced an outcome: an entry
# the writer skipped as malformed was not published either.
nominated = sum(len(entries) for _block, entries in pending_knowledge)
failed = nominated - sum(1 for outcome in published if outcome["ok"])
meta.pop("last_unpublished_nominations", None)
if failed > 0:
meta["last_unpublished_nominations"] = {"entry_id": ref["entry_id"],
"failed": failed, "total": nominated}
from ouroboros.memory_nomination_receipts import settle
settle(meta, pending_ids, published)
# Legacy batch-only receipts remain open: no positional evidence can
# prove which old entry a later successful nomination resolved.
_advance_cursor(meta, segments, segment_sigs, segment_entries, last_offset + processed)
if not run_failed: # An advance by a run that recorded no failure retires a stale error.
@ -1019,8 +1012,17 @@ def maintain_memory_pressure(memory: Any, llm_client: Any, context: Any, *,
identity_ref = retain_memory_source(context, "maintenance_identity", memory.identity_path().read_bytes())
identity += "\nExact identity source, available through read_file; no identity rewrite is authorized here:\n" + json.dumps(identity_ref)
if chat.exists() or blocks.exists():
usage = consolidate(chat, blocks, meta, llm_client, identity, knowledge_context=context,
force_tail=True, compact_chronicle=True, pressure_fits=fits)
from ouroboros.memory_nomination_receipts import DialogueMetaUnreadable
try:
usage = consolidate(chat, blocks, meta, llm_client, identity, knowledge_context=context,
force_tail=True, compact_chronicle=True, pressure_fits=fits)
except DialogueMetaUnreadable as exc:
# A damaged existing cursor is neither empty nor permission to rewrite
# memory. Keep the original context available to Main, with a typed
# maintenance gap instead of aborting its first round.
usage = {"_consolidation_errors": [{"kind": "dialogue_meta_unreadable",
"message": str(exc)}]}
if usage is not None:
usages.append(usage)
actions.append({"owner": "dialogue_consolidation", "usage": usage})
@ -1237,7 +1239,9 @@ def _advance_cursor(
def _load_meta(path: pathlib.Path) -> Dict[str, Any]:
return read_json_dict(path) or {}
from ouroboros.memory_nomination_receipts import load_meta
return load_meta(path)
from ouroboros.utils import jsonl_generation_signature as _chat_log_signature
@ -1452,9 +1456,11 @@ def _write_knowledge_entries(
outcomes = []
for entry in entries:
if not isinstance(entry, dict):
outcomes.append({"topic": "", "ok": False, "reason": "malformed_nomination"})
continue
topic, content = entry.get("topic"), entry.get("content")
if not isinstance(content, str) or not content.strip():
outcomes.append({"topic": topic, "ok": False, "reason": "empty_nomination"})
continue
try:
topic = sanitize_topic(topic)

View file

@ -232,26 +232,47 @@ def _memory_health_lines(env: Any) -> List[str]:
except Exception:
pass
from ouroboros.memory_nomination_receipts import DialogueMetaUnreadable, load_meta
try:
meta = read_json_dict(env.drive_path("memory/dialogue_meta.json")) or {}
receipt = meta.get("last_unpublished_nominations")
if isinstance(receipt, dict) and int(receipt.get("failed") or 0) > 0:
# The recovery route is named because the reader may hold no read_file:
# an external-channel turn has the cognitive memory tools and nothing else.
meta = load_meta(env.drive_path("memory/dialogue_meta.json"))
except DialogueMetaUnreadable:
# A broken existing meta file is not an empty nomination/cursor state.
lines.append("WARNING: DIALOGUE META UNREADABLE — memory/dialogue_meta.json; "
"consolidation withheld to preserve existing bytes")
else:
pending = meta.get("pending_knowledge_nominations")
if pending:
# load_meta already validates the whole list; malformed state raises.
sample = ", ".join(row["id"].split(":")[0][:12] + ":" +
":".join(row["id"].split(":")[-2:])
for row in pending[:3])
lines.append(
f"WARNING: LAST DIALOGUE KNOWLEDGE PUBLICATION INCOMPLETE — {receipt.get('failed')} of "
f"{receipt.get('total')} nominations from the latest consolidation batch were not published "
f"(entry_id {receipt.get('entry_id')}); from the main chat, read_file(root='runtime_data', "
"path='memory/knowledge_history.jsonl') and publish what still holds"
f"WARNING: DIALOGUE KNOWLEDGE PUBLICATION OPEN — {len(pending)} source-addressed "
f"nominations (first {min(3, len(pending))}: {sample}; omitted {max(0, len(pending)-3)}). "
"Read memory/dialogue_meta.json and memory/knowledge_history.jsonl for full source. "
"No automatic or tool-level discharge exists yet; later successes cannot retire older entries."
)
receipt = meta.get("last_unpublished_nominations")
if isinstance(receipt, dict):
failed = receipt.get("failed")
if type(failed) is int and failed > 0:
# This old batch receipt cannot be retired by a later new-source success.
lines.append(
f"WARNING: LAST DIALOGUE KNOWLEDGE PUBLICATION INCOMPLETE — {failed} of "
f"{receipt.get('total')} nominations in a legacy consolidation batch remain unresolved "
f"(entry_id {receipt.get('entry_id')}); from the main chat, read_file(root='runtime_data', "
"path='memory/knowledge_history.jsonl') and publish what still holds"
)
elif failed is not None and failed != 0:
lines.append("WARNING: DIALOGUE LEGACY NOMINATION RECEIPT INVALID — "
"memory/dialogue_meta.json; inspect the original receipt")
error = meta.get("last_consolidation_error")
if isinstance(error, dict):
lines.append(
f"WARNING: LAST DIALOGUE CONSOLIDATION FAILED — kind={error.get('kind') or 'unknown'} "
f"at cursor {error.get('cursor_offset')}"
)
except Exception:
pass
return lines

View file

@ -37,7 +37,7 @@ LEGACY_PLUGIN_API_GENERATION = "1.3"
FORBIDDEN_SKILL_SETTINGS: frozenset[str] = frozenset({
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "OPENAI_COMPATIBLE_API_KEY",
"CLOUDRU_FOUNDATION_MODELS_API_KEY", "GIGACHAT_CREDENTIALS", "GIGACHAT_PASSWORD",
"ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "DEEPSEEK_API_KEY", "GITHUB_TOKEN",
"ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "DEEPSEEK_API_KEY", "ZAI_API_KEY", "GITHUB_TOKEN",
"OUROBOROS_NETWORK_PASSWORD",
})

View file

@ -273,6 +273,7 @@ D20 = "Presence"
"ouroboros/mcp_client.py" = "D05"
"ouroboros/memory.py" = "D15"
"ouroboros/memory_journal_compaction.py" = "D15"
"ouroboros/memory_nomination_receipts.py" = "D15"
"ouroboros/model_concurrency.py" = "D02"
"ouroboros/model_send_seal.py" = "D16"
"ouroboros/mutation_attribution.py" = "D01"

View file

@ -18,8 +18,10 @@ from ouroboros.provider_models import (
ALL_PROVIDER_CREDENTIAL_KEYS,
ACTIVE_MODEL_SETTING_KEYS,
DEEPSEEK_BASE_URL,
resolve_zai_base_url,
DIRECT_PROVIDER_DEFAULTS,
MINIMAX_REGION_ENDPOINTS,
ZAI_PLAN_ENDPOINTS,
OPENROUTER_DEFAULTS,
provider_for_model,
resolve_minimax_base_url,
@ -41,6 +43,7 @@ def _provider_label_from_model_id(model_id: str) -> str:
"qwen": "Qwen",
"mistralai": "Mistral",
"deepseek": "DeepSeek",
"z-ai": "Z.ai (GLM)",
"perplexity": "Perplexity",
}.get(prefix, prefix.title() if prefix else "Other")
@ -269,6 +272,20 @@ def _provider_specs(
DEEPSEEK_BASE_URL,
),
))
zai_api_key = str(settings.get("ZAI_API_KEY", "") or "").strip()
if zai_api_key:
# Z.ai serves an OpenAI-compatible GET /models on its plan-selected
# official host, so the catalog is fetched live like the other providers.
specs.append((
"zai",
lambda client: _fetch_openai_compatible_model_catalog(
client,
"zai",
"Z.ai (GLM)",
zai_api_key,
resolve_zai_base_url(str(settings.get("ZAI_PLAN", "") or "")),
),
))
compatible_api_key = str(settings.get("OPENAI_COMPATIBLE_API_KEY", "") or "").strip()
compatible_base_url = str(settings.get("OPENAI_COMPATIBLE_BASE_URL", "") or "").strip()
@ -740,6 +757,9 @@ def _run_provider_test(provider_id: str, overrides: dict[str, str]) -> dict:
minimax_region = str(settings.get("MINIMAX_REGION", "") or "").strip().lower()
if provider_id == "minimax" and minimax_region and minimax_region not in MINIMAX_REGION_ENDPOINTS:
return {"error": "unknown MiniMax region", "_http_status": 400}
zai_plan = str(settings.get("ZAI_PLAN", "") or "").strip().lower()
if provider_id == "zai" and zai_plan and zai_plan not in ZAI_PLAN_ENDPOINTS:
return {"error": "unknown Z.ai plan", "_http_status": 400}
return _run_provider_test_with_settings(provider_id, settings)

View file

@ -38,7 +38,9 @@ from ouroboros.gateway.owner_settings import (
)
from ouroboros.onboarding_wizard import build_onboarding_html
from ouroboros.platform_layer import is_container_env
from ouroboros.provider_models import MINIMAX_REGION_ENDPOINTS, resolve_minimax_base_url
from ouroboros.provider_models import (
MINIMAX_REGION_ENDPOINTS, ZAI_PLAN_ENDPOINTS, resolve_minimax_base_url, resolve_zai_base_url,
)
from ouroboros.secret_masking import (
MCP_RESPONSE_ONLY_FIELDS,
is_custom_secret_setting_key,
@ -558,6 +560,8 @@ def _active_main_route(
"cloudru": "CLOUDRU_FOUNDATION_MODELS_BASE_URL", "gigachat": "GIGACHAT_BASE_URL"}.get(provider)
if provider == "minimax":
base_url = resolve_minimax_base_url(settings.get("MINIMAX_REGION") or "")
elif provider == "zai":
base_url = resolve_zai_base_url(settings.get("ZAI_PLAN") or "")
else:
base_url = str(settings.get(base_url_key) or "") if base_url_key else ""
# CW7 (v6.34.0): honour the USE_LOCAL_MAIN routing setting — a local-routed main
@ -1266,6 +1270,10 @@ def _api_settings_post_locked(request: Request, body: Any) -> JSONResponse:
if minimax_region and minimax_region not in MINIMAX_REGION_ENDPOINTS:
return unsaved_error("MINIMAX_REGION must be global_en or cn_zh.", 400)
current["MINIMAX_REGION"] = minimax_region
zai_plan = str(current.get("ZAI_PLAN") or "").strip().lower()
if zai_plan and zai_plan not in ZAI_PLAN_ENDPOINTS:
return unsaved_error("ZAI_PLAN must be payg or coding.", 400)
current["ZAI_PLAN"] = zai_plan
# Generic settings saves operate on the current boot baseline. A pending
# next-boot mode written by /api/owner/runtime-mode is preserved on disk
# below, but never hot-applied to this process/env.

View file

@ -27,7 +27,7 @@ from ouroboros.llm_capability_policy import (
)
from ouroboros.reasoning_artifacts import transcript_has_sealed_reasoning
from ouroboros.llm_routing import _resolve_or_provider
from ouroboros.provider_models import normalize_deepseek_reasoning_effort
from ouroboros.provider_models import normalize_deepseek_reasoning_effort, normalize_zai_reasoning_effort
from ouroboros.request_wire_recovery import (
finalize_wire_response,
note_provider_metadata_drop_fields,
@ -206,6 +206,26 @@ class _OpenAICompatibleLaneMixin:
"reason": "provider_forced_tool_choice" if forced_tool else "provider_wire_mapping",
"model": resolved_model,
})
elif provider == "zai":
# Same carriage family, Z.ai's OWN projection table (NOT
# DeepSeek's: medium does not exist at Z.ai and xhigh maps to
# max, not high). GLM reasoning cannot be disabled — the
# DeepSeek ``thinking={"type":"disabled"}`` arm answers
# 400 code 1210 ("please use low, high or max") on PAYG — and
# forced tool_choice WORKS with thinking enabled (measured
# 2026-09-21), so there is no forced-tool exception either.
# An absent parameter is served at MAX: dropping the tier
# silently billed every call at max. Any tier change is
# disclosed on usage as ``reasoning_effort_clamped``.
applied = normalize_zai_reasoning_effort(requested_effort)
kwargs["reasoning_effort"] = applied
_EFFORT_CLAMP_CVAR.set(None) # never inherit a stale note
if applied != requested_effort:
_EFFORT_CLAMP_CVAR.set({
"requested": requested_effort, "applied": applied,
"reason": "provider_wire_mapping",
"model": resolved_model,
})
if temperature is not None:
kwargs["temperature"] = temperature
if response_format:

View file

@ -208,6 +208,10 @@ def controlled_probe_error(exc: BaseException) -> dict[str, Any]:
"""Map typed transport facts to one bounded, provider-neutral reason."""
status, code, error_type = _error_facts(exc)
credit_codes = {
# Z.ai answers plan exhaustion as HTTP 429 code 1113 "Insufficient
# balance" (billing, not rate limiting; a Coding Plan key on the
# pay-as-you-go endpoint lands here too).
"1113",
"billing_hard_limit_reached",
"credit_balance_too_low",
"credits_exhausted",
@ -325,7 +329,7 @@ def probe_provider_readiness(
if provider in {
"openrouter", "openai", "openai-compatible", "minimax", "cloudru",
"deepseek",
"deepseek", "zai",
}:
remote_client = client._new_remote_client(target)

View file

@ -22,6 +22,7 @@ from ouroboros.model_wait import dispatch_deadline_remaining_sec
from ouroboros.openrouter_attribution import OPENROUTER_APP_HEADERS
from ouroboros.provider_models import (
DEEPSEEK_BASE_URL,
resolve_zai_base_url,
PROVIDER_PREFIXES,
normalize_anthropic_model_id,
normalize_model_identity,
@ -304,6 +305,8 @@ class _ProviderRoutingMixin:
return f"minimax/{resolved_model}"
if provider == "deepseek":
return f"deepseek/{resolved_model}"
if provider == "zai":
return f"zai/{resolved_model}"
if provider == "claudexor":
return f"claudexor::{resolved_model}"
return f"openai-compatible/{resolved_model}"
@ -391,6 +394,20 @@ class _ProviderRoutingMixin:
"supports_generation_cost": False,
}
if provider == "zai":
return {
"provider": provider,
"resolved_model": resolved_model,
"usage_model": usage_model,
"api_key": configured("ZAI_API_KEY", ""),
# Plan-selected official endpoint (PAYG default; the Coding
# Plan endpoint is intended for supported tools only).
"base_url": resolve_zai_base_url(configured("ZAI_PLAN", "")),
"default_headers": {},
"supports_openrouter_extensions": False,
"supports_generation_cost": False,
}
if provider == "cloudru":
return {
"provider": provider,

View file

@ -487,6 +487,7 @@ _NON_RETRYABLE_PROVIDER_MARKERS = {
"insufficient credits",
"insufficient_credit",
"insufficient_quota",
"1113", # Z.ai "Insufficient balance" (HTTP 429): billing, not a rate limit
"quota exceeded",
"billing",
"payment required",

View file

@ -1,182 +1,20 @@
"""Digest-only compaction of old memory-journal snapshots (CPL4-C16, owner 4A).
"""Compatibility entry point for the retired destructive memory-journal sweep.
``memory/identity_journal.jsonl``, ``memory/knowledge_history.jsonl`` and
``memory/knowledge/patterns_history.jsonl`` record the FULL old+new document
text on every write — O(doc×edits) growth, the worst byte offenders in the
memory plane. Owner decision 4A: entries younger than the unified GC
retention keep their full text; older entries become digest-only — the
content keys are replaced by their sha256 + length (existing hashes are
never overwritten) and the row is marked ``content_digested``.
Strictly fail-closed per line: an unparseable line, a row without a
readable ``ts``, a row with nothing to digest, or a row whose STORED digest
disagrees with the text it claims to describe is carried through
BYTE-IDENTICAL. The scratchpad journal (typed rows, its own eviction
contract) is deliberately NOT in scope.
This is the only sweep that DESTROYS content rather than whole dead files,
so its three guards are load-bearing (audit #15-11):
* **Digest truth before deletion.** The digest becomes the only surviving
record of the text, so a stored ``*_sha256``/``*_len`` that does not match
the text is never published over it: the row keeps its full content and the
mismatch is reported as a typed fact (``digest_mismatch``, surfaced on the
``memory_journal_compaction`` event).
* **A lock nobody can steal.** The append lock is taken ``owner_aware_stale``
so elapsed time alone can never hand a second writer the same journal.
* **Publish only an unchanged source.** ``append_jsonl`` falls back to an
UNLOCKED append after its own lock timeout, so a concurrent row can still
land while this rewrite streams. The file is re-identified (size + inode)
against the bytes actually consumed immediately before ``os.replace``; any
delta aborts the publish and the journal stays as the appender left it.
The rewrite streams line by line into the temp sibling — the journals are the
worst byte offenders in the memory plane and must never be loaded whole.
The startup/maintenance caller still invokes this function. Retaining that call
keeps old integrations working while new knowledge, identity and Pattern Register
history stays complete. A previously digested row cannot be reconstructed;
no new row loses its old/new text merely because of its age.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import pathlib
from typing import Any, Dict, Optional, Tuple
from ouroboros.deadline_utils import parse_deadline_ts
from ouroboros.platform_layer import acquire_exclusive_file_lock, release_exclusive_file_lock
from ouroboros.utils import jsonl_append_lock_path
log = logging.getLogger(__name__)
_JOURNAL_RELS = (
pathlib.Path("memory") / "identity_journal.jsonl",
pathlib.Path("memory") / "knowledge_history.jsonl",
pathlib.Path("memory") / "knowledge" / "patterns_history.jsonl",
)
_CONTENT_KEYS = ("old_content", "new_content")
from pathlib import Path
from typing import Any, Dict, Optional
import stat
def _digest_row(row: Dict[str, Any]) -> str:
"""Replace full-text keys with sha256+len.
Returns ``"digested"`` when text was dropped, ``"mismatch"`` when a STORED
digest or length contradicts the text it describes, ``""`` when there was
nothing to digest. On a mismatch the row is left EXACTLY as found: the
digest is about to become the only surviving record of that content, and
publishing a digest already known to be false while deleting the last
correct copy is unrecoverable.
"""
dropped = []
for key in _CONTENT_KEYS:
value = row.get(key)
if not isinstance(value, str):
continue
prefix = key[: -len("_content")]
digest = hashlib.sha256(value.encode("utf-8")).hexdigest() if value else ""
stored_digest = row.get(f"{prefix}_sha256")
stored_len = row.get(f"{prefix}_len")
if isinstance(stored_digest, str) and stored_digest != digest:
return "mismatch"
if isinstance(stored_len, int) and not isinstance(stored_len, bool) and stored_len != len(value):
return "mismatch"
dropped.append((key, prefix, digest, len(value)))
if not dropped:
return ""
for key, prefix, digest, length in dropped:
row[f"{prefix}_sha256"] = digest
row[f"{prefix}_len"] = length
del row[key]
row["content_digested"] = True
return "digested"
def _digest_line(raw: bytes, cutoff: float) -> Tuple[bytes, str]:
"""One journal line, transformed or carried through byte-identical."""
stripped = raw.strip()
if not stripped:
return raw, ""
try:
row = json.loads(stripped.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
return raw, "" # fail-closed: never rewrite what cannot be read
if not isinstance(row, dict):
return raw, ""
parsed_ts = parse_deadline_ts(str(row.get("ts") or ""))
if parsed_ts is None or parsed_ts.timestamp() >= cutoff:
return raw, "" # fresh, or age unknowable: keep full text
outcome = _digest_row(row)
if outcome != "digested":
return raw, outcome
return json.dumps(row, ensure_ascii=False).encode("utf-8") + b"\n", "digested"
def _publish_if_unchanged(
path: pathlib.Path, tmp: pathlib.Path, expected: Tuple[int, int, int],
) -> bool:
"""Swap the rewritten journal in ONLY if the source is still what we read.
``append_jsonl`` appends WITHOUT the sidecar lock once its own acquisition
times out, so a concurrent row can land while this rewrite streams. The
identity is (bytes consumed, device, inode): a grown file means an append
we did not carry over, a different inode means the journal was replaced
outright. Either way the rewrite is dropped and the appender's file stands.
"""
try:
stat = path.stat()
except OSError:
return False
if (int(stat.st_size), int(stat.st_dev), int(stat.st_ino)) != expected:
return False
os.replace(tmp, path)
return True
def _compact_one(path: pathlib.Path, cutoff: float) -> Tuple[int, int, str]:
"""Digest one journal in place.
Returns ``(digested, mismatched, error)``; a nonempty ``error``
(``lock_unavailable`` / ``source_changed``) means nothing was published and
the journal is byte-identical to what the appenders left.
"""
lock_path = jsonl_append_lock_path(path)
lock_fd = acquire_exclusive_file_lock(
lock_path, timeout_sec=2.0, stale_sec=10.0, owner_aware_stale=True,
)
if lock_fd is None:
return 0, 0, "lock_unavailable"
tmp = path.with_name(path.name + ".compact.tmp")
published = False
try:
digested = 0
mismatched = 0
consumed = 0
with path.open("rb") as source:
start = os.fstat(source.fileno())
with tmp.open("wb") as sink:
for raw in source: # streaming: one line in flight, never the file
consumed += len(raw)
out, outcome = _digest_line(raw, cutoff)
sink.write(out)
if outcome == "digested":
digested += 1
elif outcome == "mismatch":
mismatched += 1
if not digested:
return 0, mismatched, ""
published = _publish_if_unchanged(
path, tmp, (consumed, int(start.st_dev), int(start.st_ino)),
)
if not published:
return 0, 0, "source_changed"
return digested, mismatched, ""
finally:
if not published:
try:
tmp.unlink()
except OSError:
log.debug("Failed to drop the journal compaction temp file", exc_info=True)
release_exclusive_file_lock(lock_path, lock_fd)
_JOURNALS = ("memory/identity_journal.jsonl", "memory/knowledge_history.jsonl",
"memory/knowledge/patterns_history.jsonl")
def compact_memory_journal_snapshots(
@ -185,33 +23,26 @@ def compact_memory_journal_snapshots(
*,
now: Optional[float] = None,
) -> Dict[str, Any]:
"""Digest old full-text snapshots in the three memory journals."""
from ouroboros.retention import age_cutoff, get_gc_retention_days
"""Preserve every journal byte, including malformed and historical rows.
if retention_days is None:
retention_days = get_gc_retention_days()
cutoff = age_cutoff(retention_days, now)
report: Dict[str, Any] = {"digested": {}, "digest_mismatch": {}, "errors": []}
root = pathlib.Path(drive_root)
for rel in _JOURNAL_RELS:
path = root / rel
if not path.exists():
continue
The arguments keep the previous call contract. Size facts in the existing
startup report measure growth; a missing journal is not a measured zero.
"""
sizes: Dict[str, Optional[int]] = {}
errors: list[str] = []
for relative in _JOURNALS:
try:
digested, mismatched, error = _compact_one(path, cutoff)
except OSError:
report["errors"].append({"journal": rel.as_posix(), "error": "io_error"})
continue
if error:
report["errors"].append({"journal": rel.as_posix(), "error": error})
if digested:
report["digested"][rel.as_posix()] = digested
if mismatched:
# Typed fact, not a silent skip: a stored digest that contradicts
# its own text means one of the two is already corrupt, and the
# content stays in full until a human looks.
report["digest_mismatch"][rel.as_posix()] = mismatched
return report
info = (Path(drive_root) / relative).lstat()
sizes[relative] = info.st_size if stat.S_ISREG(info.st_mode) else None
if sizes[relative] is None:
errors.append(f"{relative}: not_regular")
except FileNotFoundError:
sizes[relative] = None
except OSError as exc:
sizes[relative] = None
errors.append(f"{relative}: {type(exc).__name__}")
return {"digested": {}, "digest_mismatch": {}, "errors": errors,
"journal_bytes": sizes}
__all__ = ["compact_memory_journal_snapshots"]

View file

@ -0,0 +1,104 @@
"""Source-addressed pending knowledge nominations in dialogue_meta.json.
The history log carries full nomination bytes. This compact index makes an
unpublished nomination visible even when its summary block becomes an era.
A later unrelated success cannot discharge an older source identity.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
KEY = "pending_knowledge_nominations"
class DialogueMetaUnreadable(ValueError):
"""Existing cursor or nomination obligations cannot be safely interpreted."""
def load_meta(path: Path) -> dict[str, Any]:
"""An absent cursor is new; an unreadable existing cursor is not empty.
This meta file now owns durable pending obligations. A permissive JSON read
would erase them on the next consolidation. Reject duplicate keys as well:
the second copy of an obligation field cannot silently replace the first.
"""
def unique_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise ValueError(f"Duplicate dialogue meta key: {key}")
result[key] = value
return result
try:
with path.open("r", encoding="utf-8") as source:
value = json.load(source, object_pairs_hook=unique_pairs)
except FileNotFoundError:
# A dangling link is an existing, unreadable source, not a new cursor.
try:
path.lstat()
except FileNotFoundError:
return {}
raise DialogueMetaUnreadable("Dialogue meta exists but cannot be read") from None
except (OSError, UnicodeError, ValueError) as exc:
raise DialogueMetaUnreadable(f"Dialogue meta unreadable: {type(exc).__name__}") from exc
if not isinstance(value, dict):
raise DialogueMetaUnreadable("Dialogue meta must be a JSON object")
_pending(value) # Refuse corrupt obligations before the first paid correction call.
return value
def _pending(meta: dict[str, Any]) -> dict[str, dict[str, Any]]:
rows = meta.get(KEY, [])
if not isinstance(rows, list) or any(not isinstance(row, dict) or not isinstance(row.get("id"), str)
for row in rows):
raise DialogueMetaUnreadable("Unreadable nomination obligations; refusing to replace their bytes")
if len({row["id"] for row in rows}) != len(rows):
raise DialogueMetaUnreadable("Duplicate nomination obligation IDs")
return {row["id"]: row for row in rows}
def prepare(meta: dict[str, Any], source_id: str, batches: list[tuple[Any, list[Any]]]) -> list[str]:
"""Record every proposed entry before publication; return positional IDs."""
pending = _pending(meta)
ids: list[str] = []
for block_index, (_block, entries) in enumerate(batches):
for entry_index, entry in enumerate(entries):
identifier = f"{source_id}:{block_index}:{entry_index}"
ids.append(identifier)
if identifier not in pending:
pending[identifier] = {
"id": identifier,
"scope": str(entry.get("scope") or "default") if isinstance(entry, dict) else "invalid",
"topic": str(entry.get("topic") or "") if isinstance(entry, dict) else "",
"reason": "publication_pending",
}
meta[KEY] = list(pending.values())
return ids
def settle(meta: dict[str, Any], ids: list[str], outcomes: list[dict[str, Any]]) -> None:
"""Only this exact source's successful entries retire; missing outcomes stay owed.
A failed or unobserved entry never expires. A future source-grounded
resolution must address its ID explicitly; same-topic later writes cannot.
"""
pending = _pending(meta)
for index, identifier in enumerate(ids):
outcome = outcomes[index] if index < len(outcomes) else {}
if outcome.get("ok") is True:
pending.pop(identifier, None)
elif identifier in pending:
row = pending[identifier]
row["reason"] = str(outcome.get("reason") or "outcome_missing")
if isinstance(outcome.get("scope"), str):
row["scope"] = outcome["scope"]
if isinstance(outcome.get("topic"), str):
row["topic"] = outcome["topic"]
if pending:
meta[KEY] = list(pending.values())
else:
meta.pop(KEY, None)

View file

@ -189,7 +189,7 @@ _GENERIC_KV_SECRET_KEY_HINTS = (
"key", "token", "secret", "auth", "bearer", "cred", "password", "passwd",
"passphrase", "apikey", "access_token", "openrouter", "openai", "anthropic",
"cloudru", "cloud_ru", "gigachat", "groq", "deepseek", "together", "fireworks",
"mistral", "cohere", "perplexity", "replicate", "huggingface", "azure", "xai",
"mistral", "cohere", "perplexity", "replicate", "huggingface", "azure", "xai", "zai",
)

View file

@ -223,7 +223,7 @@ def _cost_from_pricing(pricing: tuple, prompt_tokens: int, completion_tokens: in
def infer_api_key_type(model: str, provider: Optional[str] = None) -> str:
"""Infer which API key is used based on model name."""
provider_name = str(provider or "").strip().lower()
if provider_name in {"local", "openrouter", "openai", "anthropic", "openai-compatible", "cloudru", "gigachat", "minimax", "deepseek"}:
if provider_name in {"local", "openrouter", "openai", "anthropic", "openai-compatible", "cloudru", "gigachat", "minimax", "deepseek", "zai"}:
return provider_name
raw_model = str(model or "").strip()
direct_provider = provider_for_model(raw_model)

View file

@ -34,6 +34,24 @@ def resolve_minimax_base_url(region: str = "") -> str:
# the fingerprint is already unique per provider+model).
DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1"
# Z.ai (Zhipu / GLM) serves one OpenAI-compatible API surface on two plans that
# share the same key: pay-as-you-go and the subscription Coding Plan. The plan
# selects the endpoint (analogous to MiniMax regions); the PAYG endpoint is the
# default because the Coding Plan endpoint is officially intended for supported
# coding tools only.
ZAI_PLAN_ENDPOINTS: dict[str, str] = {
"payg": "https://api.z.ai/api/paas/v4",
"coding": "https://api.z.ai/api/coding/paas/v4",
}
ZAI_DEFAULT_PLAN = "payg"
def resolve_zai_base_url(plan: str = "") -> str:
"""Return the configured Z.ai OpenAI-compatible endpoint for the plan."""
selected = str(plan or "").strip().lower() or ZAI_DEFAULT_PLAN
return ZAI_PLAN_ENDPOINTS.get(selected, ZAI_PLAN_ENDPOINTS[ZAI_DEFAULT_PLAN])
# DeepSeek's Chat Completions ``reasoning_effort`` enum is low/high/max
# (medium/xhigh are documented aliases of high) and thinking is switched off by
# ``thinking.type=disabled``, not by an effort value. This is the wire dialect
@ -54,6 +72,39 @@ def normalize_deepseek_reasoning_effort(value: str) -> str:
return DEEPSEEK_REASONING_EFFORT_ALIASES.get(normalized, normalized)
# Z.ai (GLM) serves the same Chat Completions ``reasoning_effort`` shape but a
# DIFFERENT enum mapping than DeepSeek — do not reuse the DeepSeek table. The
# provider has exactly three tiers (low | high | max); ``medium`` does not
# exist, thinking cannot be disabled (``thinking={"type":"disabled"}`` answers
# 400 code 1210 "please use low, high or max" on PAYG), and an ABSENT
# parameter is served at MAX — so a silently dropped tier means every call
# runs (and bills) at max. Projection of the canonical scale, measured live
# 2026-09-21 on the Coding Plan endpoint: none/minimal/low -> low,
# medium/high -> high, xhigh/ultra -> max.
ZAI_REASONING_EFFORT_ALIASES = {
"none": "low",
"minimal": "low",
"low": "low",
"medium": "high",
"high": "high",
"xhigh": "max",
"max": "max",
"ultra": "max",
}
def normalize_zai_reasoning_effort(value: str) -> str:
"""Project one canonical effort tier onto Z.ai's Chat wire enum.
Every canonical tier maps to a concrete provider tier; unlike DeepSeek
there is no "off" arm — GLM reasoning cannot be disabled, so an unmapped
value still resolves to a tier rather than being dropped (a dropped tier
is served at max).
"""
normalized = str(value or "").strip().lower()
return ZAI_REASONING_EFFORT_ALIASES.get(normalized, "low")
# Direct-provider prefix → canonical provider name. Un-prefixed models route
# through OpenRouter. Order matters only for readability; prefixes are disjoint.
PROVIDER_PREFIXES: tuple[tuple[str, str], ...] = (
@ -64,6 +115,7 @@ PROVIDER_PREFIXES: tuple[tuple[str, str], ...] = (
("cloudru::", "cloudru"),
("gigachat::", "gigachat"),
("deepseek::", "deepseek"),
("zai::", "zai"),
("openai-compatible::", "openai-compatible"),
("openrouter::", "openrouter"),
)
@ -75,6 +127,7 @@ PROVIDER_ENV_KEYS: dict[str, str] = {
"minimax": "MINIMAX_API_KEY",
"cloudru": "CLOUDRU_FOUNDATION_MODELS_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
"zai": "ZAI_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
}
@ -101,6 +154,7 @@ PROVIDER_CREDENTIAL_GROUPS: dict[str, tuple[str, ...]] = {
"minimax": ("MINIMAX_API_KEY", "MINIMAX_REGION"),
"cloudru": ("CLOUDRU_FOUNDATION_MODELS_API_KEY", "CLOUDRU_FOUNDATION_MODELS_BASE_URL"),
"deepseek": ("DEEPSEEK_API_KEY",),
"zai": ("ZAI_API_KEY", "ZAI_PLAN"),
"gigachat": (
"GIGACHAT_CREDENTIALS", "GIGACHAT_PASSWORD", "GIGACHAT_USER",
"GIGACHAT_BASE_URL", "GIGACHAT_SCOPE", "GIGACHAT_VERIFY_SSL_CERTS",
@ -268,7 +322,7 @@ def local_only_review_route_env() -> bool:
provider_has_credentials(provider)
for provider in (
"openrouter", "openai", "anthropic", "minimax", "cloudru", "gigachat",
"deepseek", "openai-compatible",
"deepseek", "zai", "openai-compatible",
)
)
@ -440,6 +494,16 @@ MINIMAX_DIRECT_DEFAULTS = {
# the Cloud.ru/GigaChat clear-instead-of-fill path; owners can opt in manually.
}
ZAI_DIRECT_DEFAULTS = {
"main": "zai::glm-5.3",
"heavy": "",
"light": "zai::glm-5.3-flash",
"vision": "",
"fallback": "zai::glm-5.3-flash",
# No deep_review default: the route publishes no window metadata and no live
# measurement exists, so the slot follows the MiniMax clear-instead-of-fill path.
}
DEEPSEEK_DIRECT_DEFAULTS = {
"main": "deepseek::deepseek-v4-pro",
"heavy": "",
@ -475,6 +539,7 @@ DIRECT_PROVIDER_DEFAULTS = {
"gigachat": GIGACHAT_DIRECT_DEFAULTS,
"minimax": MINIMAX_DIRECT_DEFAULTS,
"deepseek": DEEPSEEK_DIRECT_DEFAULTS,
"zai": ZAI_DIRECT_DEFAULTS,
}
# Review panels are declared as provider ROLE sequences, then compiled against
@ -492,6 +557,7 @@ DIRECT_PROVIDER_REVIEW_ROLES = {
# Strongest-main ×3 policy (same as OpenAI/Anthropic): an exclusive
# DeepSeek install reviews with three independent thinking v4-pro calls.
"deepseek": ("main", "main", "main"),
"zai": ("main", "main", "main"),
}
DIRECT_PROVIDER_SCOPE_DEFAULTS = {
@ -542,6 +608,10 @@ def migrate_model_value(provider: str, value: str) -> str:
if text.startswith("deepseek/"):
return f"deepseek::{text[len('deepseek/'):]}"
return text
if provider == "zai":
if text.startswith("zai/"):
return f"zai::{text[len('zai/'):]}"
return text
return text
@ -665,6 +735,8 @@ def normalize_model_identity(model: str) -> str:
return f"minimax/{text[len('minimax::'):]}"
if text.startswith("deepseek::"):
return f"deepseek/{text[len('deepseek::'):]}"
if text.startswith("zai::"):
return f"zai/{text[len('zai::'):]}"
if text.startswith("anthropic::"):
return f"anthropic/{normalize_anthropic_model_id(text[len('anthropic::'):])}"
if text.startswith("anthropic/"):

View file

@ -51,13 +51,14 @@ def _exclusive_direct_remote_provider_env() -> str:
("openai", has_openai), ("anthropic", has_anthropic), ("minimax", has_minimax),
("cloudru", has_cloudru), ("gigachat", has_gigachat),
("deepseek", bool(str(runtime_setting("DEEPSEEK_API_KEY", "") or "").strip())),
("zai", bool(str(runtime_setting("ZAI_API_KEY", "") or "").strip())),
) if present]
return direct[0] if len(direct) == 1 else ""
def direct_provider_review_models_fallback(provider: str) -> list[str]:
"""Return the exact review-models list a direct-provider fallback emits."""
if provider not in ("openai", "anthropic", "minimax", "cloudru", "gigachat", "deepseek"):
if provider not in ("openai", "anthropic", "minimax", "cloudru", "gigachat", "deepseek", "zai"):
return []
main_model = str(
runtime_setting("OUROBOROS_MODEL", SETTINGS_DEFAULTS["OUROBOROS_MODEL"]) or ""

View file

@ -151,6 +151,11 @@ def reviewer_route(model_id: str, *, session: bool = False) -> tuple:
return provider, str(
resolve_minimax_base_url(runtime_settings().get("MINIMAX_REGION") or "") or "")
if provider == "zai":
# Z.ai's base url is selected by the plan, the same way MiniMax's is by region.
from ouroboros.provider_models import resolve_zai_base_url
return provider, resolve_zai_base_url(runtime_settings().get("ZAI_PLAN") or "")
settings_key = {
"openai": "OPENAI_BASE_URL",
"openai-compatible": "OPENAI_COMPATIBLE_BASE_URL",

View file

@ -625,6 +625,7 @@ _REMOTE_PROVIDER_KEYS = (
"ANTHROPIC_API_KEY",
"MINIMAX_API_KEY",
"DEEPSEEK_API_KEY",
"ZAI_API_KEY",
"OPENAI_COMPATIBLE_API_KEY",
"CLOUDRU_FOUNDATION_MODELS_API_KEY",
"GIGACHAT_CREDENTIALS",
@ -646,6 +647,7 @@ _PROVIDER_KEY_ENV = {
"anthropic": "ANTHROPIC_API_KEY",
"minimax": "MINIMAX_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
"zai": "ZAI_API_KEY",
"openai-compatible": "OPENAI_COMPATIBLE_API_KEY",
"cloudru": "CLOUDRU_FOUNDATION_MODELS_API_KEY",
"gigachat": "GIGACHAT_CREDENTIALS",

View file

@ -29,6 +29,7 @@ MASKED_SECRET_SETTING_KEYS = frozenset(
"ANTHROPIC_API_KEY",
"MINIMAX_API_KEY",
"DEEPSEEK_API_KEY",
"ZAI_API_KEY",
"GITHUB_TOKEN",
"OUROBOROS_NETWORK_PASSWORD",
}

View file

@ -452,9 +452,8 @@ def _startup_retired_settings_notice(settings: dict) -> None:
def _prune_event(event_type: str, keys: tuple, **reports: dict) -> None:
"""One ``events.jsonl`` row for a GC/sweep step that did or failed something:
``keys`` are its own evidence of material work, read across every report it
hands in, so a healthy no-op pass stays silent instead of rowing every boot."""
"""Emit when a report has evidence under ``keys``. GC no-ops stay silent;
an observation report with measured journal sizes intentionally rows at boot."""
from supervisor.state import append_jsonl
if any(report.get(key) for report in reports.values() for key in keys):
@ -565,11 +564,11 @@ def _startup_prune_sweeps(*, preserve_task_sources: bool = False) -> None:
except Exception:
log.debug("Stale cache prune failed", exc_info=True)
try:
# CPL4-C16 (owner 4A): memory-journal snapshots older than GC retention
# become digest-only (sha256 + length); fresh entries keep full text.
# TZ-3 11A/B5 supersedes old age-digestion: measure journal growth
# without touching historical or new full-text snapshots.
from ouroboros.memory_journal_compaction import compact_memory_journal_snapshots
_prune_event("memory_journal_compaction", ("digested", "digest_mismatch", "errors"),
_prune_event("memory_journal_observation", ("journal_bytes", "errors"),
report=compact_memory_journal_snapshots(DATA_DIR))
except Exception:
log.debug("Memory journal compaction failed", exc_info=True)

View file

@ -281,6 +281,7 @@ def _exclusive_direct_remote_provider(settings: dict) -> str:
has_anthropic = bool(_setting_text(settings, "ANTHROPIC_API_KEY"))
has_minimax = bool(_setting_text(settings, "MINIMAX_API_KEY"))
has_deepseek = bool(_setting_text(settings, "DEEPSEEK_API_KEY"))
has_zai = bool(_setting_text(settings, "ZAI_API_KEY"))
has_legacy_openai_base = bool(_setting_text(settings, "OPENAI_BASE_URL"))
has_compatible = bool(_setting_text(settings, "OPENAI_COMPATIBLE_BASE_URL"))
has_cloudru = bool(_setting_text(settings, "CLOUDRU_FOUNDATION_MODELS_API_KEY"))
@ -301,6 +302,7 @@ def _exclusive_direct_remote_provider(settings: dict) -> str:
("cloudru", has_cloudru),
("gigachat", has_gigachat),
("deepseek", has_deepseek),
("zai", has_zai),
) if present
]
return direct[0] if len(direct) == 1 else ""
@ -415,6 +417,7 @@ def has_remote_provider(settings: dict) -> bool:
"ANTHROPIC_API_KEY",
"MINIMAX_API_KEY",
"DEEPSEEK_API_KEY",
"ZAI_API_KEY",
"OPENAI_COMPATIBLE_BASE_URL",
"CLOUDRU_FOUNDATION_MODELS_API_KEY",
"GIGACHAT_CREDENTIALS",

View file

@ -75,6 +75,8 @@ SETTINGS_DEFAULTS = {**UPDATE_SETTINGS_DEFAULTS,
"MINIMAX_API_KEY": "",
"MINIMAX_REGION": "",
"DEEPSEEK_API_KEY": "",
"ZAI_API_KEY": "",
"ZAI_PLAN": "",
"OUROBOROS_NETWORK_PASSWORD": "",
"OUROBOROS_SERVER_HOST": "127.0.0.1",
"OUROBOROS_HOST_SERVICE_PORT": 8767,

View file

@ -11,6 +11,7 @@ from ouroboros.provider_models import (
ANTHROPIC_DIRECT_DEFAULTS,
CLOUDRU_DIRECT_DEFAULTS,
DEEPSEEK_DIRECT_DEFAULTS,
ZAI_DIRECT_DEFAULTS,
MINIMAX_DIRECT_DEFAULTS,
MINIMAX_REGION_ENDPOINTS,
OPENAI_DIRECT_DEFAULTS,
@ -122,6 +123,7 @@ _MODEL_DEFAULTS = {
"cloudru": {key: value for key, value in CLOUDRU_DIRECT_DEFAULTS.items() if key != "heavy"},
"minimax": {key: value for key, value in MINIMAX_DIRECT_DEFAULTS.items() if key != "heavy"},
"deepseek": {key: value for key, value in DEEPSEEK_DIRECT_DEFAULTS.items() if key != "heavy"},
"zai": {key: value for key, value in ZAI_DIRECT_DEFAULTS.items() if key != "heavy"},
"anthropic": {key: value for key, value in ANTHROPIC_DIRECT_DEFAULTS.items() if key != "heavy"},
# No defaults: model names are server-specific; user must fill all slots.
"openai-compatible": {"main": "", "light": "", "vision": "", "fallback": ""},
@ -150,6 +152,8 @@ _PROVIDER_FIELDS = _rows(("id", "stateKey", "settingKey", "settingsInputId", "la
("minimax-key", "minimaxKey", "MINIMAX_API_KEY", "s-minimax-key", "MiniMax API Key", "MiniMax API key", "Optional. If this is the only remote key, the next step prefills MiniMax's own model ids.", "password", "more"),
("minimax-region", "minimaxRegion", "MINIMAX_REGION", "s-minimax-region", "MiniMax Region", "global_en or cn_zh", "Choose global_en for the global endpoint or cn_zh for the China endpoint.", "text", "more"),
("deepseek-key", "deepseekKey", "DEEPSEEK_API_KEY", "s-deepseek-key", "DeepSeek API Key", "sk-...", "Optional. If this is the only remote key, the next step prefills DeepSeek's own model ids.", "password", "more"),
("zai-key", "zaiKey", "ZAI_API_KEY", "s-zai-key", "Z.ai API Key (GLM)", "...", "Optional. If this is the only remote key, the next step prefills Z.ai's own GLM model ids.", "password", "more"),
("zai-plan", "zaiPlan", "ZAI_PLAN", "s-zai-plan", "Z.ai Plan", "payg or coding", "Choose payg (pay-as-you-go, default) or coding (Coding Plan endpoint; officially intended for supported coding tools only).", "text", "more"),
("anthropic-key", "anthropicKey", "ANTHROPIC_API_KEY", "s-anthropic", "Anthropic API Key", "sk-ant-...", "Optional. Saved for models routed straight to Anthropic, and for Claude tooling.", "password", "primary"),
("openai-compatible-url", "compatibleBaseUrl", "OPENAI_COMPATIBLE_BASE_URL", "s-compatible-url", "OpenAI-compatible Base URL", "http://localhost:11434/v1", "Base URL for your OpenAI-compatible endpoint (e.g. Ollama, LM Studio, vLLM). Required whenever a slot uses the OpenAI-compatible endpoint as its source.", "url", "more"),
("openai-compatible-key", "compatibleApiKey", "OPENAI_COMPATIBLE_API_KEY", "s-compatible-key", "OpenAI-compatible API Key", "Leave empty for no auth", "API key for the endpoint. Leave empty if your server does not require authentication.", "password", "more"),
@ -164,6 +168,7 @@ _PROFILE_SPECS = {
"cloudru": ("Cloud.ru Foundation Models", "Cloud.ru is present, so the next step prefills Cloud.ru's own model ids.", "Cloud.ru-only setup detected. These defaults use Cloud.ru's own model ids."),
"minimax": ("MiniMax", "MiniMax is present, so the next step prefills MiniMax's own model ids.", "MiniMax-only setup detected. These defaults include MiniMax-M3 and MiniMax-M2.7."),
"deepseek": ("DeepSeek", "DeepSeek is present, so the next step prefills DeepSeek's own model ids.", "DeepSeek-only setup detected. These defaults use deepseek-v4-pro for main work and deepseek-v4-flash for the light lane."),
"zai": ("Z.ai (GLM)", "Z.ai is present, so the next step prefills Z.ai's own GLM model ids.", "Z.ai-only setup detected. These defaults use glm-5.3 for main work and glm-5.3-flash for the light lane. The plan setting selects the pay-as-you-go or Coding Plan endpoint."),
"anthropic": ("Anthropic", "Anthropic is present, so the next step prefills Anthropic's own model ids.", "Anthropic-only setup detected. These defaults are explicit and official."),
"openai-compatible": ("OpenAI-compatible endpoint", "An OpenAI-compatible base URL is configured. Enter the model names your server exposes in the next step.", "OpenAI-compatible endpoint detected. Choose the OpenAI-compatible endpoint as the source and enter the model names your server exposes. The model list is whatever your server supports."),
"direct-multi": ("Direct multi-provider", "Multiple direct providers are present, so the next step keeps your model values editable without forcing one provider family.", "Multiple direct providers are configured. Start here, then split model slots across them if you want."),
@ -266,7 +271,7 @@ _SUBSCRIPTION_FIELDS = _rows(("id", "payloadKey", "label", "note"), (
("skip-subscription-presets", SKIP_SUBSCRIPTION_PRESETS_FIELD, "Finish without agent defaults", "Completes onboarding without moving reviewers and subagents onto the connected subscriptions. Everything stays editable in Settings afterwards."),
))
_MODEL_SUGGESTIONS = list(dict.fromkeys(("google/gemini-3.8-flash", "x-ai/grok-4.6", "openai/gpt-5.6-terra", "openai/gpt-5.6-sol", "openai/gpt-5.6-luna", "openai::gpt-5.6-terra", "openai::gpt-5.6-sol", "openai::gpt-5.6-luna", "anthropic/claude-sonnet-5", "anthropic/claude-opus-5", "anthropic::claude-sonnet-5", "anthropic::claude-opus-5", "anthropic::claude-opus-4-6", "deepseek/deepseek-v4-pro", "deepseek::deepseek-v4-pro", "deepseek::deepseek-v4-flash", "openai-compatible::meta-llama/compatible", "cloudru::zai-org/GLM-4.7", "minimax::MiniMax-M3", "minimax::MiniMax-M2.7")))
_MODEL_SUGGESTIONS = list(dict.fromkeys(("google/gemini-3.8-flash", "x-ai/grok-4.6", "openai/gpt-5.6-terra", "openai/gpt-5.6-sol", "openai/gpt-5.6-luna", "openai::gpt-5.6-terra", "openai::gpt-5.6-sol", "openai::gpt-5.6-luna", "anthropic/claude-sonnet-5", "anthropic/claude-opus-5", "anthropic::claude-sonnet-5", "anthropic::claude-opus-5", "anthropic::claude-opus-4-6", "deepseek/deepseek-v4-pro", "deepseek::deepseek-v4-pro", "deepseek::deepseek-v4-flash", "zai::glm-5.3", "zai::glm-5.3-flash", "openai-compatible::meta-llama/compatible", "cloudru::zai-org/GLM-4.7", "minimax::MiniMax-M3", "minimax::MiniMax-M2.7")))
def _string(value: Any) -> str:
@ -342,6 +347,7 @@ def derive_provider_profile(settings: dict) -> str:
("CLOUDRU_FOUNDATION_MODELS_API_KEY", "cloudru"),
("MINIMAX_API_KEY", "minimax"),
("DEEPSEEK_API_KEY", "deepseek"),
("ZAI_API_KEY", "zai"),
("ANTHROPIC_API_KEY", "anthropic"),
]
configured = [name for key, name in direct if flags[key]]
@ -556,7 +562,7 @@ def validate_setup_payload(data: dict, current_settings: dict) -> Tuple[dict, st
has_remote = any(
value
for setting_key, value in keys.items()
if setting_key not in {"OPENAI_COMPATIBLE_API_KEY", "MINIMAX_REGION"}
if setting_key not in {"OPENAI_COMPATIBLE_API_KEY", "MINIMAX_REGION", "ZAI_PLAN"}
)
has_local = bool(local_source)
if not has_remote and not has_local and not (pending_subscription or selected_subscription):

View file

@ -318,5 +318,6 @@ def test_settings_extraction_size_bounds_have_meaningful_headroom():
}
assert counts["ouroboros.config"] <= 1000
assert all(count <= 1000 for count in counts.values())
assert counts["ouroboros.settings_defaults"] <= 500
# 500 -> 520: the Z.ai direct provider adds its key and plan rows to the leaf (PR #1207).
assert counts["ouroboros.settings_defaults"] <= 520
assert (PACKAGE / "config.py").is_file()

View file

@ -198,18 +198,39 @@ def test_partial_publication_records_the_batch_receipt_in_meta(tmp_path, fit, mo
lambda *_a, **_k: [{"topic": "people/alex", "ok": False, "reason": "revision_conflict"}])
ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="partial")
c.consolidate(chat, blocks, meta, _Nominating(), knowledge_context=ctx)
receipt = json.loads(meta.read_text())["last_unpublished_nominations"]
assert receipt["failed"] == 1 and receipt["total"] == 1 and receipt["entry_id"]
pending = json.loads(meta.read_text())["pending_knowledge_nominations"]
assert len(pending) == 1
assert pending[0]["topic"] == "people/alex" and pending[0]["reason"] == "revision_conflict"
assert pending[0]["id"].endswith(":0:0")
def test_a_fully_published_batch_clears_the_receipt(tmp_path, fit):
def test_new_success_does_not_erase_an_old_failed_entry_or_legacy_receipt(tmp_path, fit, monkeypatch):
chat, blocks, meta = _paths(tmp_path)
_write_chat(chat, count=100, text_size=0)
meta.parent.mkdir(parents=True, exist_ok=True)
c.atomic_write_json(meta, {"last_unpublished_nominations": {"entry_id": "old", "failed": 3, "total": 4}})
legacy = {"entry_id": "old", "failed": 3, "total": 4}
c.atomic_write_json(meta, {"last_unpublished_nominations": legacy})
ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="clean")
original = c._write_knowledge_entries
calls = 0
def fail_once(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
return [{"topic": "people/alex", "scope": "global", "ok": False,
"reason": "revision_conflict"}]
return original(*args, **kwargs)
monkeypatch.setattr(c, "_write_knowledge_entries", fail_once)
c.consolidate(chat, blocks, meta, _Nominating(), knowledge_context=ctx)
assert "last_unpublished_nominations" not in json.loads(meta.read_text())
older = json.loads(meta.read_text())["pending_knowledge_nominations"][0]
_write_chat(chat, count=200, text_size=0)
c.consolidate(chat, blocks, meta, _Nominating(), knowledge_context=ctx)
saved = json.loads(meta.read_text())
assert saved["last_unpublished_nominations"] == legacy
assert saved["pending_knowledge_nominations"] == [older]
assert calls == 2
def test_a_run_without_nominations_leaves_the_receipt_alone(tmp_path, fit):
@ -235,8 +256,9 @@ def test_the_receipt_survives_era_compression(tmp_path, fit, monkeypatch):
saved_blocks = json.loads(blocks.read_text())
assert saved_blocks[0]["type"] == "era"
assert "knowledge_writes" not in saved_blocks[0]
receipt = json.loads(meta.read_text())["last_unpublished_nominations"]
assert receipt["failed"] == 11 and receipt["total"] == 11
pending = json.loads(meta.read_text())["pending_knowledge_nominations"]
assert len(pending) == 11
assert len({row["id"] for row in pending}) == 11
# --- the Health block is where stale memory becomes visible -----------------------
@ -297,3 +319,99 @@ def test_unreadable_receipts_do_not_raise_or_shout(tmp_path, payload):
env = _health_env(tmp_path)
c.atomic_write_json(tmp_path / "memory" / "dialogue_meta.json", payload)
assert not any("DIALOGUE" in line for line in context_health._memory_health_lines(env))
def test_invalid_legacy_receipt_does_not_impersonate_unreadable_meta(tmp_path):
env = _health_env(tmp_path)
c.atomic_write_json(tmp_path / "memory" / "dialogue_meta.json",
{"last_unpublished_nominations": {"failed": "many", "total": 2}})
lines = context_health._memory_health_lines(env)
assert any("LEGACY NOMINATION RECEIPT INVALID" in line for line in lines)
assert not any("DIALOGUE META UNREADABLE" in line for line in lines)
def test_pending_receipt_precedes_the_note_writer_and_cannot_be_replaced_by_corrupt_meta(tmp_path, fit, monkeypatch):
chat, blocks, meta = _paths(tmp_path)
_write_chat(chat, count=100, text_size=0)
ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="interrupted")
def interrupted(*_args, **_kwargs):
saved = json.loads(meta.read_text())
assert len(saved["pending_knowledge_nominations"]) == 1
assert saved["pending_knowledge_nominations"][0]["reason"] == "publication_pending"
raise RuntimeError("simulated stop after pending publication")
monkeypatch.setattr(c, "_write_knowledge_entries", interrupted)
with pytest.raises(RuntimeError, match="simulated stop"):
c.consolidate(chat, blocks, meta, _Nominating(), knowledge_context=ctx)
saved = json.loads(meta.read_text())
assert saved["pending_knowledge_nominations"][0]["reason"] == "publication_pending"
assert saved.get("last_consolidated_offset", 0) == 0
def test_health_projects_three_owed_addresses_and_omission_count(tmp_path):
env = _health_env(tmp_path)
rows = [{"id": f"source{i}:0:0", "scope": "global", "topic": f"people/{i}",
"reason": "revision_conflict"} for i in range(5)]
c.atomic_write_json(tmp_path / "memory" / "dialogue_meta.json",
{"pending_knowledge_nominations": rows})
lines = context_health._memory_health_lines(env)
row = next(line for line in lines if "KNOWLEDGE PUBLICATION OPEN" in line)
assert "5 source-addressed" in row and "first 3" in row and "omitted 2" in row
assert "source0" in row and "source2" in row and "source3" not in row
assert "memory/knowledge_history.jsonl" in row
def test_malformed_nomination_keeps_its_position_and_cannot_retire_another_entry(tmp_path):
from ouroboros.memory_nomination_receipts import prepare, settle
meta = {}
ids = prepare(meta, "source", [({}, [None, {"topic": "people/alex", "content": "Valid"}])])
outcomes = c._write_knowledge_entries(tmp_path / "memory" / "knowledge", [None,
{"topic": "people/alex", "content": "Valid"}])
assert len(outcomes) == 2 and outcomes[0]["reason"] == "malformed_nomination"
assert outcomes[1]["ok"]
settle(meta, ids, outcomes)
assert [row["id"] for row in meta["pending_knowledge_nominations"]] == ["source:0:0"]
def test_corrupt_obligation_index_refuses_replacement(tmp_path, fit):
chat, blocks, meta = _paths(tmp_path)
_write_chat(chat, count=100, text_size=0)
meta.parent.mkdir(parents=True, exist_ok=True)
c.atomic_write_json(meta, {"pending_knowledge_nominations": {"not": "a list"}})
ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="corrupt")
with pytest.raises(ValueError, match="refusing to replace"):
c.consolidate(chat, blocks, meta, _Nominating(), knowledge_context=ctx)
assert not blocks.exists()
assert json.loads(meta.read_text())["pending_knowledge_nominations"] == {"not": "a list"}
@pytest.mark.parametrize("bad_bytes", [b'{"pending_knowledge_nominations":[{"id":"old"}]',
b'["wrong top-level type"]',
b'{"pending_knowledge_nominations":[],"pending_knowledge_nominations":[]}'])
def test_unreadable_existing_meta_cannot_erase_obligations(tmp_path, fit, bad_bytes):
chat, blocks, meta = _paths(tmp_path)
_write_chat(chat, count=100, text_size=0)
meta.parent.mkdir(parents=True, exist_ok=True)
meta.write_bytes(bad_bytes)
ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="corrupt")
with pytest.raises(ValueError):
c.should_consolidate(meta, chat)
with pytest.raises(ValueError):
c.consolidate(chat, blocks, meta, _Nominating(), knowledge_context=ctx)
assert meta.read_bytes() == bad_bytes
assert not blocks.exists()
assert any("DIALOGUE META UNREADABLE" in line for line in
context_health._memory_health_lines(_health_env(tmp_path)))
def test_pending_health_disambiguates_two_entries_from_one_source(tmp_path):
env = _health_env(tmp_path)
c.atomic_write_json(tmp_path / "memory" / "dialogue_meta.json", {
"pending_knowledge_nominations": [
{"id": "a" * 64 + f":{index}:0", "reason": "revision_conflict"}
for index in (0, 1)]})
row = next(line for line in context_health._memory_health_lines(env)
if "KNOWLEDGE PUBLICATION OPEN" in line)
assert "aaaaaaaaaaaa:0:0" in row and "aaaaaaaaaaaa:1:0" in row

View file

@ -2,6 +2,8 @@
import json
import pytest
from ouroboros import context
from ouroboros.context_fit import estimate_context_prompt_tokens
from ouroboros.tools.registry import ToolContext
@ -50,6 +52,30 @@ def test_actual_nano_preparation_consolidates_complete_source_before_returning(t
assert len(actor.calls) == calls
@pytest.mark.parametrize("broken", [b"{bad", b"[]", b'{"pending_knowledge_nominations":[],"pending_knowledge_nominations":[]}'])
def test_nano_unreadable_dialogue_meta_withholds_maintenance_not_main(tmp_path, fit, monkeypatch, broken):
env, memory, task, ctx, chat_before = _setup(tmp_path)
meta = env.drive_root / "memory/dialogue_meta.json"
meta.write_bytes(broken)
monkeypatch.setattr(context, "get_context_mode", lambda: "nano")
actor = SourceReader(env.drive_root, fit.window)
messages, info = context.build_llm_messages(
env, memory, task, ctx=ctx, llm=actor, tool_schemas=[],
fit_candidate=lambda _messages, _tools: {"accepted": False},
)
receipt = info["context_memory_maintenance"]
assert receipt["status"] == "no_progress"
assert receipt["usage"]["_consolidation_errors"][0]["kind"] == "dialogue_meta_unreadable"
assert messages[-1]["content"] == task["text"]
assert meta.read_bytes() == broken
assert (env.drive_root / "logs/chat.jsonl").read_text() == chat_before
assert not actor.calls
events = [json.loads(line) for line in (env.drive_root / "logs/events.jsonl").read_text().splitlines()]
assert any(row.get("type") == "context_memory_maintenance" and
row["usage"]["_consolidation_errors"][0]["kind"] == "dialogue_meta_unreadable"
for row in events)
def test_max_and_pure_preview_never_start_a_maintenance_model(tmp_path, fit, monkeypatch):
env, memory, task, ctx, _raw = _setup(tmp_path)
actor = SourceReader(env.drive_root, 50000)

View file

@ -224,7 +224,7 @@ def test_architecture_mentions_shared_log_grouping_and_direct_provider_review_fa
# silently re-expand to claim symmetric coverage it does not have yet.
assert "Direct-provider review fallback" in arch
assert "OpenAI-only review fallback" in arch # legacy name still referenced for discoverability
assert "official OpenAI, Anthropic, MiniMax, DeepSeek, Cloud.ru, and GigaChat" in arch
assert "official OpenAI, Anthropic, MiniMax, DeepSeek, Z.ai, Cloud.ru, and GigaChat" in arch
assert "_exclusive_direct_remote_provider_env" in arch
# v4.34.0: direct-provider fallback now documents the
# `main_model.startswith(provider_prefix)` guard in get_review_models —

View file

@ -229,5 +229,7 @@ def test_era_compression_cannot_erase_unpublished_knowledge_proposals(tmp_path,
# The era object carries no knowledge_writes, so the batch receipt lives in meta:
# without it the incomplete publication would vanish from every resident surface.
assert "knowledge_writes" not in saved[0]
receipt = json.loads(meta.read_text())["last_unpublished_nominations"]
assert receipt == {"entry_id": nominations["entry_id"], "failed": 11, "total": 11}
pending = json.loads(meta.read_text())["pending_knowledge_nominations"]
assert len(pending) == 11
assert all(row["id"].startswith(nominations["entry_id"] + ":") for row in pending)
assert all(row["reason"] == "revision_required" for row in pending)

View file

@ -1,223 +1,92 @@
"""CPL4-C16 pins (owner batch №8, 4A): old journal snapshots go digest-only.
"""Old memory-journal snapshots remain readable after every maintenance pass.
Fresh entries keep their full old/new text; entries older than GC retention
keep only sha256 + length and gain ``content_digested``. Unparseable lines and
rows without a readable ``ts`` survive byte-identical; the consciousness
observation inbox is out of scope.
Audit #15-11 corrective lane: this compactor is the one sweep that destroys
CONTENT, so it also pins that a stored digest is verified before the text it
describes is deleted, that the rewrite publishes only a source nothing else
touched, and that the journal is never loaded whole.
Previously this startup sweep digested old knowledge, identity and Pattern
Register old/new contents. A digest cannot restore the complete source after
retention; the compatibility entry point is intentionally non-destructive.
"""
from __future__ import annotations
import hashlib
import inspect
import json
import os
import pathlib
import pytest
from ouroboros import memory_journal_compaction as mjc
from ouroboros.memory_journal_compaction import compact_memory_journal_snapshots
from ouroboros.utils import utc_now_iso
_OLD_TS = "2020-01-01T00:00:00+00:00"
_JOURNALS = (
"memory/identity_journal.jsonl",
"memory/knowledge_history.jsonl",
"memory/knowledge/patterns_history.jsonl",
"projects/example/knowledge_history.jsonl",
"memory/scratchpad_journal.jsonl",
)
def _journal(tmp_path, rel):
path = tmp_path / rel
@pytest.mark.parametrize("journal", _JOURNALS)
def test_old_journal_bytes_remain_complete_through_repeated_maintenance(tmp_path, journal):
path = tmp_path / journal
path.parent.mkdir(parents=True, exist_ok=True)
return path
row = {"ts": "2020-01-01T00:00:00+00:00", "old_content": "old\nwith Unicode Я",
"new_content": "new\nwith Unicode Ё", "old_sha256": "legacy-mismatch"}
original = (json.dumps(row, ensure_ascii=False) + "\n{legacy broken row\n").encode("utf-8")
path.write_bytes(original)
for _ in range(2):
report = compact_memory_journal_snapshots(tmp_path, retention_days=0,
now=2_000_000_000.0)
assert report["digested"] == report["digest_mismatch"] == {}
assert report["errors"] == []
assert report["journal_bytes"].get(journal) == (len(original) if journal in
("memory/identity_journal.jsonl", "memory/knowledge_history.jsonl",
"memory/knowledge/patterns_history.jsonl") else None)
assert path.read_bytes() == original
# Content digested in an older release cannot be restored, but is not deleted either.
assert b"old_content" in path.read_bytes() and b"new_content" in path.read_bytes()
def test_old_rows_digested_fresh_rows_kept_full(tmp_path):
path = _journal(tmp_path, "memory/identity_journal.jsonl")
old_row = {
"ts": _OLD_TS, "old_content": "I was v1", "new_content": "I am v2",
"old_sha256": hashlib.sha256(b"I was v1").hexdigest(), "old_len": 8,
}
fresh_row = {"ts": utc_now_iso(), "old_content": "I am v2", "new_content": "I am v3"}
broken_line = "{not json at all\n"
path.write_text(
json.dumps(old_row) + "\n" + broken_line + json.dumps(fresh_row) + "\n",
encoding="utf-8",
)
report = compact_memory_journal_snapshots(tmp_path)
lines = path.read_text(encoding="utf-8").splitlines()
digested = json.loads(lines[0])
assert "old_content" not in digested and "new_content" not in digested
assert digested["content_digested"] is True
assert digested["old_sha256"] == hashlib.sha256(b"I was v1").hexdigest()
assert digested["new_sha256"] == hashlib.sha256(b"I am v2").hexdigest()
assert digested["new_len"] == len("I am v2")
assert lines[1] == broken_line.rstrip("\n") # unreadable: byte-identical
kept = json.loads(lines[2])
assert kept["old_content"] == "I am v2" and kept["new_content"] == "I am v3"
assert report["digested"] == {"memory/identity_journal.jsonl": 1}
assert not report["digest_mismatch"] and not report["errors"]
def test_maintenance_does_not_create_missing_journals_or_directories(tmp_path):
root = tmp_path / "absent"
compact_memory_journal_snapshots(root)
assert not root.exists()
@pytest.mark.parametrize("false_fact", [
{"old_sha256": "pinned-old-hash"},
{"old_len": 999},
])
def test_a_false_stored_digest_never_costs_the_text(tmp_path, false_fact):
"""Audit #15-11: the compactor used ``setdefault``, so a stored digest that
contradicted its own text was KEPT while the only correct copy of the
content was deleted — the lie became the whole record. The pre-fix pin in
this file asserted exactly that behavior (``old_sha256 == "pinned-old-hash"``
survives the deletion of ``old_content``); it was cementing the defect and
is reshaped above to a truthful stored digest.
def test_startup_prune_still_reaches_compatibility_entry_point():
import ouroboros.server_maintenance as maintenance
A row whose stored fact does not match its text now keeps its FULL content
and is reported as a typed fact."""
path = _journal(tmp_path, "memory/identity_journal.jsonl")
row = {"ts": _OLD_TS, "old_content": "I was v1", "new_content": "I am v2", **false_fact}
original = json.dumps(row) + "\n"
path.write_text(original, encoding="utf-8")
report = compact_memory_journal_snapshots(tmp_path)
assert path.read_text(encoding="utf-8") == original # byte-identical
assert not report["digested"]
assert report["digest_mismatch"] == {"memory/identity_journal.jsonl": 1}
assert "compact_memory_journal_snapshots" in inspect.getsource(maintenance._startup_prune_sweeps)
def test_a_concurrent_append_is_never_dropped_by_the_rewrite(tmp_path):
"""``append_jsonl`` appends WITHOUT the sidecar lock once its own
acquisition times out, so a row can land mid-rewrite. Whether this pass
carries it over or abandons the rewrite, the row must survive."""
path = _journal(tmp_path, "memory/knowledge_history.jsonl")
old_row = {"ts": _OLD_TS, "old_content": "a", "new_content": "b"}
path.write_text(json.dumps(old_row) + "\n", encoding="utf-8")
racing = {"ts": utc_now_iso(), "old_content": "b", "new_content": "c"}
real_digest_line = mjc._digest_line
fired = {"done": False}
def racing_digest_line(raw, cutoff):
result = real_digest_line(raw, cutoff)
if not fired["done"]:
fired["done"] = True
with path.open("ab") as unlocked_appender:
unlocked_appender.write(json.dumps(racing).encode("utf-8") + b"\n")
return result
mjc._digest_line = racing_digest_line
def test_size_observation_does_not_follow_a_journal_symlink(tmp_path):
target = tmp_path / "elsewhere"
target.write_bytes(b"secret data")
link = tmp_path / "memory" / "knowledge_history.jsonl"
link.parent.mkdir()
try:
compact_memory_journal_snapshots(tmp_path)
finally:
mjc._digest_line = real_digest_line
rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
assert len(rows) == 2
assert rows[1] == racing # the concurrent row is intact whichever branch ran
@pytest.mark.skipif(os.name == "nt", reason="the reader holds the journal open; Windows refuses to unlink it (no FILE_SHARE_DELETE)")
def test_a_replaced_source_aborts_the_publish(tmp_path):
"""Identity, not just size: if the journal is swapped for a different file
under the rewrite, the finished temp must be dropped, not published over
whatever now lives there."""
path = _journal(tmp_path, "memory/knowledge/patterns_history.jsonl")
path.write_text(json.dumps({"ts": _OLD_TS, "old_content": "a", "new_content": "b"}) + "\n",
encoding="utf-8")
replacement = json.dumps({"ts": _OLD_TS, "topic": "someone else's file"}) + "\n"
real_digest_line = mjc._digest_line
fired = {"done": False}
def swapping_digest_line(raw, cutoff):
result = real_digest_line(raw, cutoff)
if not fired["done"]:
fired["done"] = True
path.unlink()
path.write_text(replacement, encoding="utf-8")
return result
mjc._digest_line = swapping_digest_line
try:
report = compact_memory_journal_snapshots(tmp_path)
finally:
mjc._digest_line = real_digest_line
assert path.read_text(encoding="utf-8") == replacement
assert not report["digested"]
assert report["errors"] == [
{"journal": "memory/knowledge/patterns_history.jsonl", "error": "source_changed"},
]
assert not list(path.parent.glob("*.compact.tmp"))
def test_the_journal_is_never_loaded_whole(tmp_path, monkeypatch):
"""Bounded/streaming (audit #15-11c): these journals are the worst byte
offenders in the memory plane; a whole-file read is the thing being fixed.
Poison the whole-file readers and the compaction must still work."""
path = _journal(tmp_path, "memory/identity_journal.jsonl")
rows = [{"ts": _OLD_TS, "old_content": f"o{i}", "new_content": f"n{i}"} for i in range(50)]
path.write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8")
def _boom(self, *args, **kwargs):
raise AssertionError(f"whole-file read of {self}")
monkeypatch.setattr(pathlib.Path, "read_bytes", _boom)
link.symlink_to(target)
except (OSError, NotImplementedError):
pytest.skip("symlink creation unavailable")
report = compact_memory_journal_snapshots(tmp_path)
assert report["digested"] == {"memory/identity_journal.jsonl": 50}
assert report["journal_bytes"]["memory/knowledge_history.jsonl"] is None
assert "memory/knowledge_history.jsonl: not_regular" in report["errors"]
assert target.read_bytes() == b"secret data"
def test_the_rewrite_takes_an_unstealable_lock():
"""Owner-aware stale: elapsed time alone must never hand a second writer
the journal this destructive rewrite is holding."""
import inspect
src = inspect.getsource(mjc._compact_one)
assert "owner_aware_stale=True" in src
def test_patterns_history_gains_derived_digests(tmp_path):
path = _journal(tmp_path, "memory/knowledge/patterns_history.jsonl")
path.write_text(json.dumps({
"ts": _OLD_TS, "task_id": "t", "markers": ["m"],
"old_content": "old body", "new_content": "new body\n",
}) + "\n", encoding="utf-8")
compact_memory_journal_snapshots(tmp_path)
row = json.loads(path.read_text(encoding="utf-8"))
assert row["old_sha256"] == hashlib.sha256(b"old body").hexdigest()
assert row["new_len"] == len("new body\n")
assert "old_content" not in row and row["content_digested"] is True
def test_row_without_readable_ts_keeps_full_text(tmp_path):
path = _journal(tmp_path, "memory/knowledge_history.jsonl")
original = json.dumps({"topic": "x", "old_content": "a", "new_content": "b"}) + "\n"
path.write_text(original, encoding="utf-8")
def test_startup_event_publishes_normal_journal_sizes(tmp_path, monkeypatch):
import ouroboros.server_maintenance as maintenance
import supervisor.state as state
journal = tmp_path / "memory" / "knowledge_history.jsonl"
journal.parent.mkdir()
journal.write_bytes(b"full historical text\n")
rows = []
monkeypatch.setattr(maintenance, "DATA_DIR", tmp_path)
monkeypatch.setattr(state, "append_jsonl", lambda _path, row: rows.append(row))
report = compact_memory_journal_snapshots(tmp_path)
assert path.read_text(encoding="utf-8") == original
assert not report["digested"] and not report["errors"]
def test_observation_inbox_is_out_of_scope(tmp_path):
inbox = tmp_path / "state" / "consciousness_observations.jsonl"
inbox.parent.mkdir(parents=True)
original = json.dumps({"ts": _OLD_TS, "op": "enqueue", "payload": "keep me"}) + "\n"
inbox.write_text(original, encoding="utf-8")
compact_memory_journal_snapshots(tmp_path)
assert inbox.read_text(encoding="utf-8") == original
def test_startup_prune_sweeps_run_the_compaction():
import inspect
import ouroboros.server_maintenance as sm
assert "compact_memory_journal_snapshots" in inspect.getsource(sm._startup_prune_sweeps)
maintenance._prune_event("memory_journal_observation", ("journal_bytes", "errors"), report=report)
assert len(rows) == 1
assert rows[0]["report"]["journal_bytes"]["memory/knowledge_history.jsonl"] == len(b"full historical text\n")
assert journal.read_bytes() == b"full historical text\n"
# Pin the real startup caller, not only this unit invocation.
source = inspect.getsource(maintenance._startup_prune_sweeps)
assert '_prune_event("memory_journal_observation", ("journal_bytes", "errors")' in source

View file

@ -176,6 +176,8 @@ PROVIDER_DRIVERS: Dict[str, ProviderDriver] = {
"minimax", "minimax::model-x", {"MINIMAX_API_KEY": "minimax-conformance-key"}),
"deepseek": _openai_family(
"deepseek", "deepseek::model-x", {"DEEPSEEK_API_KEY": "deepseek-conformance-key"}),
"zai": _openai_family(
"zai", "zai::model-x", {"ZAI_API_KEY": "zai-conformance-key"}),
"anthropic": ProviderDriver(
"anthropic", model="anthropic::claude-x",
env={"ANTHROPIC_API_KEY": "anthropic-conformance-key"},

View file

@ -338,7 +338,7 @@ def test_prepare_onboarding_settings_rejects_openai_compatible_key_without_base_
def test_onboarding_frontend_uses_base_url_first_compatible_validation():
source = (REPO / "web/modules/onboarding_wizard.js").read_text(encoding="utf-8")
assert "!['OPENAI_COMPATIBLE_API_KEY', 'MINIMAX_REGION'].includes(field.settingKey)" in source
assert "!['OPENAI_COMPATIBLE_API_KEY', 'MINIMAX_REGION', 'ZAI_PLAN'].includes(field.settingKey)" in source
assert "const hasRemote = keyValues.some(([, value]) => value);" not in source
@ -617,6 +617,8 @@ def test_setup_contract_groups_rarely_used_providers():
"MINIMAX_API_KEY": "more",
"MINIMAX_REGION": "more",
"DEEPSEEK_API_KEY": "more",
"ZAI_API_KEY": "more",
"ZAI_PLAN": "more",
"ANTHROPIC_API_KEY": "primary",
"OPENAI_COMPATIBLE_BASE_URL": "more",
"OPENAI_COMPATIBLE_API_KEY": "more",
@ -691,6 +693,7 @@ _SECRET_CANARIES = {
"CLOUDRU_FOUNDATION_MODELS_API_KEY": "cloudru-SECRETCANARY126",
"MINIMAX_API_KEY": "minimax-SECRETCANARY127",
"DEEPSEEK_API_KEY": "sk-ds-SECRETCANARY133",
"ZAI_API_KEY": "sk-zai-SECRETCANARY134",
"ANTHROPIC_API_KEY": "sk-ant-SECRETCANARY128",
"GIGACHAT_CREDENTIALS": "giga-SECRETCANARY129",
"GIGACHAT_PASSWORD": "gigapw-SECRETCANARY130",

View file

@ -132,7 +132,7 @@ def test_onboarding_compact_access_step_keeps_default_width_two_column():
def test_settings_more_providers_collapse_keeps_inputs_mounted():
"""Rarely used provider cards (Cloud.ru, MiniMax, DeepSeek, GigaChat) collapse under a
"""Rarely used provider cards (Cloud.ru, MiniMax, DeepSeek, Z.ai, GigaChat) collapse under a
"More providers" details wrapper, but their inputs must stay mounted:
settings.js applyInputValue has no null guard, so a missing input id
breaks settings load. The wrapper auto-opens when configured."""
@ -141,7 +141,7 @@ def test_settings_more_providers_collapse_keeps_inputs_mounted():
css = _read("web/settings.css")
assert 'id="settings-more-providers"' in ui
assert ui.count("advanced: true") == 4
assert ui.count("advanced: true") == 5
assert "PROVIDER_CARDS.filter((card) => !card.advanced)" in ui
assert "PROVIDER_CARDS.filter((card) => card.advanced)" in ui
assert "syncMoreProvidersDisclosure" in settings

View file

@ -571,10 +571,11 @@ def scan_data_paths(root: pathlib.Path = REPO) -> frozenset[str]:
# one rebuildable projection per conversation written by presence_runner at the end of an executed
# turn; it has its own row in section 2.
# 293 -> 295: the disposable test-environment caches (``cache/pip``, ``cache/uv``; test root only).
# 295 -> 296: Presence recovery inspects the retained quarantine members before
# deciding whether an event ever started; a quarantined task id cannot become
# a fresh model generation on a transport retry.
EXPECTED_SCAN_PATHS = 296
# 295 -> 296 -> 295: Presence recovery inspects the retained quarantine members
# before deciding whether an event ever started (+1); TZ-3 removed the destructive
# memory journal rewrite and its ``.compact.tmp`` sibling path (-1). PERSISTENCE.md
# keeps the journals read-only observed and never age-digested.
EXPECTED_SCAN_PATHS = 295
# Scanned paths that must always be present — guards the scanner itself
# against a silent regression that would shrink coverage while keeping counts

View file

@ -699,7 +699,7 @@ def test_response_log_and_accounting_expose_only_controlled_error(
def test_provider_test_registry_is_derived_from_provider_defaults():
assert provider_api._PROVIDER_TEST_KNOWN_IDS == {
"openrouter", "openai", "anthropic", "cloudru", "gigachat", "minimax",
"deepseek", "openai-compatible",
"deepseek", "zai", "openai-compatible",
}
assert provider_api._PROVIDER_TEST_OVERRIDE_KEYS == provider_api.ALL_PROVIDER_CREDENTIAL_KEYS

View file

@ -162,7 +162,10 @@ CHAPTER_BYTE_BUDGETS: dict[str, int] = {
"docs/architecture/06-agent-core.md": 310100,
# 36991 -> 37300: the facade paragraph names the three loop constants runtime_limits.py
# gained (events batch bound, budget-projection retry interval); no older text to displace.
"docs/architecture/07-configuration.md": 37300,
# 37300 -> 38400 (PR #1207): the Z.ai (`zai::`) direct provider gets its own route
# paragraph (plan-selected endpoint, low/high/max projection, 1113 billing) plus two
# settings rows; the base sat 95 bytes under the previous budget, no older text to displace.
"docs/architecture/07-configuration.md": 38400,
# 18947 -> 19287: CI failure collection now documents diagnostic desktop builds while release remains gated.
# 19287 -> 20560 (#1215): three contracts the chapter had no older text for — the
# ONE reusable browser lane and the two triggers that share it (the unfiltered

View file

@ -162,5 +162,6 @@ def test_unread_correction_failure_remains_visible_after_dialogue_publication(tm
assert stored["knowledge_writes"][0]["reason"] == "revision_required"
state = json.loads(meta.read_text(encoding="utf-8"))
assert state["last_consolidated_offset"] == 100
assert state["last_unpublished_nominations"]["failed"] == 1
assert len(state["pending_knowledge_nominations"]) == 1
assert state["pending_knowledge_nominations"][0]["reason"] == "revision_required"
assert k.read_knowledge_note(address).raw == original.raw

321
tests/test_zai_provider.py Normal file
View file

@ -0,0 +1,321 @@
"""Z.ai (GLM) direct provider: registry, plan-selected endpoint, the effort
projection at the send boundary, and the 429/1113 billing classification.
Facts pinned here come from the contributor's live probe (PR #1207, 2026-09-21,
Coding Plan key, glm-5.3) and docs.z.ai: the provider accepts exactly
``low``/``high``/``max``, an ABSENT ``reasoning_effort`` is served at max,
thinking cannot be disabled (HTTP 400 code 1210), forced tool_choice works with
thinking on, and plan exhaustion arrives as HTTP 429 code 1113.
"""
import os
import pytest
from ouroboros import provider_models
from ouroboros.llm import LLMClient
from ouroboros.provider_models import (
DIRECT_PROVIDER_DEFAULTS,
DIRECT_PROVIDER_REVIEW_ROLES,
DIRECT_PROVIDER_SCOPE_DEFAULTS,
ZAI_DIRECT_DEFAULTS,
ZAI_PLAN_ENDPOINTS,
ZAI_REASONING_EFFORT_ALIASES,
migrate_model_value,
normalize_model_identity,
normalize_zai_reasoning_effort,
provider_for_model,
provider_has_credentials,
resolve_zai_base_url,
)
_PROVIDER_ENV_KEYS = (
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL",
"OPENAI_COMPATIBLE_API_KEY", "OPENAI_COMPATIBLE_BASE_URL",
"ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "DEEPSEEK_API_KEY",
"ZAI_API_KEY", "ZAI_PLAN",
"CLOUDRU_FOUNDATION_MODELS_API_KEY", "GIGACHAT_CREDENTIALS",
"GIGACHAT_USER", "GIGACHAT_PASSWORD", "USE_LOCAL_MAIN",
)
def _clear_provider_env(monkeypatch):
for key in _PROVIDER_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
def _zai_target(model="glm-5.3"):
return {
"provider": "zai",
"resolved_model": model,
"usage_model": f"zai/{model}",
"api_key": "sk-x",
"base_url": ZAI_PLAN_ENDPOINTS["payg"],
"supports_openrouter_extensions": False,
}
def _build(target, effort, tool_choice="auto", tools=None):
client = LLMClient()
kwargs = client._build_remote_kwargs(
target, [{"role": "user", "content": "hi"}], effort, 256, tool_choice, None, tools,
)
return kwargs, client._pop_effort_clamp_disclosure()
class TestRegistry:
def test_prefix_routes_direct(self):
assert provider_for_model("zai::glm-5.3") == "zai"
def test_slash_form_stays_openrouter(self):
from ouroboros.pricing import infer_api_key_type
assert provider_for_model("zai/glm-5.3") == "openrouter"
assert infer_api_key_type("zai/glm-5.3") == "openrouter"
assert infer_api_key_type("zai::glm-5.3") == "zai"
def test_credentials_mapping(self, monkeypatch):
_clear_provider_env(monkeypatch)
assert provider_has_credentials("zai") is False
monkeypatch.setenv("ZAI_API_KEY", "sk-x")
assert provider_has_credentials("zai") is True
def test_direct_defaults_registered(self):
assert DIRECT_PROVIDER_DEFAULTS["zai"] is ZAI_DIRECT_DEFAULTS
assert ZAI_DIRECT_DEFAULTS["main"] == "zai::glm-5.3"
assert ZAI_DIRECT_DEFAULTS["light"] == "zai::glm-5.3-flash"
assert DIRECT_PROVIDER_REVIEW_ROLES["zai"] == ("main", "main", "main")
assert DIRECT_PROVIDER_SCOPE_DEFAULTS["zai"] == "zai::glm-5.3"
def test_migrate_and_normalize_round_trip(self):
assert migrate_model_value("zai", "zai/glm-5.3") == "zai::glm-5.3"
assert migrate_model_value("zai", "zai::glm-5.3") == "zai::glm-5.3"
assert normalize_model_identity("zai::glm-5.3") == "zai/glm-5.3"
class TestPlanSwitch:
@pytest.mark.parametrize("plan", [None, "", "payg", " PAYG ", "unknown-plan"])
def test_payg_is_the_default_and_the_fallback(self, plan):
assert resolve_zai_base_url(plan) == ZAI_PLAN_ENDPOINTS["payg"]
def test_coding_plan_endpoint(self):
assert resolve_zai_base_url("coding") == ZAI_PLAN_ENDPOINTS["coding"]
assert ZAI_PLAN_ENDPOINTS["payg"].startswith("https://api.z.ai/")
assert ZAI_PLAN_ENDPOINTS["coding"].startswith("https://api.z.ai/")
def test_resolve_target_uses_plan(self, monkeypatch):
_clear_provider_env(monkeypatch)
monkeypatch.setenv("ZAI_API_KEY", "sk-x")
monkeypatch.setenv("ZAI_PLAN", "coding")
monkeypatch.setattr(
"ouroboros.llm_routing.runtime_setting",
lambda key, default="": os.environ.get(key, default),
)
target = LLMClient()._resolve_remote_target("zai::glm-5.3")
assert target["provider"] == "zai"
assert target["base_url"] == ZAI_PLAN_ENDPOINTS["coding"]
assert target["api_key"] == "sk-x"
assert target["usage_model"] == "zai/glm-5.3"
def test_route_readers_follow_the_plan(self, monkeypatch):
# The Capability Evidence route identity (main route + reviewer route)
# must name the plan's endpoint, exactly as MiniMax's follows its region.
from ouroboros.gateway.settings import _active_main_route
from ouroboros.reviewer_window import reviewer_route
monkeypatch.setattr("ouroboros.config.runtime_settings", lambda: {"ZAI_PLAN": "coding"})
assert reviewer_route("zai::glm-5.3") == ("zai", ZAI_PLAN_ENDPOINTS["coding"])
route = _active_main_route({"OUROBOROS_MODEL": "zai::glm-5.3", "ZAI_PLAN": "coding"})
assert (route["provider"], route["base_url"]) == ("zai", ZAI_PLAN_ENDPOINTS["coding"])
assert _active_main_route({"OUROBOROS_MODEL": "zai::glm-5.3"})["base_url"] == ZAI_PLAN_ENDPOINTS["payg"]
def test_provider_test_rejects_an_unknown_plan(self, monkeypatch):
from ouroboros.gateway import models as provider_api
monkeypatch.setattr(provider_api, "load_settings", lambda: {})
monkeypatch.setattr(
provider_api, "_run_provider_test_with_settings",
lambda *_args: (_ for _ in ()).throw(AssertionError("must not probe")),
)
body = provider_api._run_provider_test("zai", {"ZAI_API_KEY": "x", "ZAI_PLAN": "codign"})
assert body == {"error": "unknown Z.ai plan", "_http_status": 400}
def test_plan_alone_is_not_a_provider(self):
# A plan is a transport choice, not a credential: a draft carrying only
# ZAI_PLAN must be refused exactly like a MiniMax region without a key,
# while the same draft with the key is accepted.
from ouroboros.settings_setup_contract import validate_setup_payload
plan_only = {"ZAI_PLAN": "coding", "OUROBOROS_MODEL": "zai::glm-5.3",
"OUROBOROS_MODEL_LIGHT": "zai::glm-5.3-flash", "OUROBOROS_MODEL_FALLBACKS": "zai::glm-5.3-flash"}
_prepared, error = validate_setup_payload(plan_only, {})
assert error
_prepared, error = validate_setup_payload({**plan_only, "ZAI_API_KEY": "sk-zai-key-1234567890"}, {})
assert not error
class TestEffortCarriage:
"""The canonical scale is always projected onto Z.ai's low/high/max enum:
an absent tier would be served (and billed) at max."""
@pytest.mark.parametrize(
("requested", "wire"),
[
("none", "low"),
("minimal", "low"),
("low", "low"),
("medium", "high"),
("high", "high"),
("xhigh", "max"),
("max", "max"),
("ultra", "max"),
],
)
def test_projection_reaches_the_wire(self, requested, wire):
kwargs, note = _build(_zai_target(), requested)
assert kwargs["reasoning_effort"] == wire
assert "thinking" not in (kwargs.get("extra_body") or {})
if requested == wire:
assert note is None
else:
assert note == {
"requested": requested, "applied": wire,
"reason": "provider_wire_mapping", "model": "glm-5.3",
}
def test_table_stays_inside_the_provider_enum(self):
assert set(ZAI_REASONING_EFFORT_ALIASES.values()) == {"low", "high", "max"}
assert normalize_zai_reasoning_effort("not-a-tier") == "low"
def test_forced_tool_choice_keeps_thinking_on(self):
tools = [{"type": "function", "function": {"name": "f", "parameters": {"type": "object"}}}]
kwargs, _ = _build(_zai_target(), "high", tool_choice="required", tools=tools)
assert kwargs["reasoning_effort"] == "high"
assert "thinking" not in (kwargs.get("extra_body") or {})
def test_generic_compatible_lane_is_untouched(self):
# The same GLM model id on an owner's OpenAI-compatible endpoint keeps
# today's behavior: the projection is keyed on the zai provider id,
# never on the model name.
target = {
"provider": "openai-compatible", "resolved_model": "glm-5.3",
"usage_model": "openai-compatible::glm-5.3", "api_key": "",
"base_url": "http://127.0.0.1:11434/v1", "supports_openrouter_extensions": False,
}
kwargs, note = _build(target, "medium")
assert "reasoning_effort" not in kwargs
assert note is None
class TestSingleProviderIndependence:
def test_exclusive_direct_env_detection(self, monkeypatch):
_clear_provider_env(monkeypatch)
monkeypatch.setenv("ZAI_API_KEY", "sk-x")
from ouroboros.config import _exclusive_direct_remote_provider_env
assert _exclusive_direct_remote_provider_env() == "zai"
def test_startup_gate_accepts_zai_only(self):
from ouroboros.server_runtime import (
_exclusive_direct_remote_provider,
has_remote_provider,
has_startup_ready_provider,
)
settings = {"ZAI_API_KEY": "sk-x"}
assert has_remote_provider(settings) is True
assert has_startup_ready_provider(settings) is True
assert _exclusive_direct_remote_provider(settings) == "zai"
def test_review_fallback_compiles_for_zai(self, monkeypatch):
_clear_provider_env(monkeypatch)
monkeypatch.setenv("ZAI_API_KEY", "sk-x")
monkeypatch.setenv("OUROBOROS_MODEL", "zai::glm-5.3")
monkeypatch.setenv("OUROBOROS_MODEL_LIGHT", "zai::glm-5.3-flash")
monkeypatch.setattr(
"ouroboros.review_model_routes.runtime_setting",
lambda key, default="": os.environ.get(key, default),
)
from ouroboros.config import get_review_models
assert get_review_models() == ["zai::glm-5.3"] * 3
def test_local_only_review_route_sees_zai(self, monkeypatch):
_clear_provider_env(monkeypatch)
monkeypatch.setenv("USE_LOCAL_MAIN", "1")
monkeypatch.setenv("ZAI_API_KEY", "sk-x")
assert provider_models.local_only_review_route_env() is False
class TestSecretSurfaces:
def test_forbidden_for_skills_and_masked(self):
from ouroboros.contracts.plugin_api import FORBIDDEN_SKILL_SETTINGS
from ouroboros.secret_masking import MASKED_SECRET_SETTING_KEYS
assert "ZAI_API_KEY" in FORBIDDEN_SKILL_SETTINGS
assert "ZAI_API_KEY" in MASKED_SECRET_SETTING_KEYS
def test_settings_defaults(self):
from ouroboros.config import SETTINGS_DEFAULTS
assert SETTINGS_DEFAULTS["ZAI_API_KEY"] == ""
assert SETTINGS_DEFAULTS["ZAI_PLAN"] == ""
class TestSafetyRouting:
def test_zai_only_install_reaches_the_real_safety_check(self, monkeypatch):
"""A zai-only install must reach the remote safety check, not fail open."""
from ouroboros import safety
_clear_provider_env(monkeypatch)
assert safety._any_remote_provider_configured() is False
monkeypatch.setenv("ZAI_API_KEY", "sk-x")
assert safety._any_remote_provider_configured() is True
assert safety._PROVIDER_KEY_ENV["zai"] == "ZAI_API_KEY"
def test_light_model_reaches_its_provider_key(self, monkeypatch):
from ouroboros import safety
from ouroboros.pricing import infer_api_key_type
_clear_provider_env(monkeypatch)
monkeypatch.setenv("ZAI_API_KEY", "sk-x")
assert safety._PROVIDER_KEY_ENV.get(infer_api_key_type("zai::glm-5.3-flash")) == "ZAI_API_KEY"
class TestProbeBilling:
"""HTTP 429 code 1113 "Insufficient balance" is billing, not rate limiting."""
def test_1113_maps_to_no_credits(self):
from ouroboros.llm_probe import controlled_probe_error
class Exhausted(Exception):
status_code = 429
code = "1113"
type = ""
result = controlled_probe_error(Exhausted("Insufficient balance"))
assert result["error"] == "No credits"
assert result["status_code"] == 429
def test_task_loop_does_not_retry_an_exhausted_plan(self):
from ouroboros.loop_llm_call import classify_llm_exception
class Exhausted(Exception):
status_code = 429
code = "1113"
body = {"error": {"code": "1113", "message": "Insufficient balance"}}
exhausted = classify_llm_exception(Exhausted("Insufficient balance"))
assert exhausted.kind == "quota_exhausted"
assert exhausted.retry_same_request is False
# An ordinary 429 keeps its transient, retryable classification.
assert classify_llm_exception(RuntimeError("Error code: 429 - too many requests")).retry_same_request is True
def test_plain_429_stays_rate_limited(self):
from ouroboros.llm_probe import controlled_probe_error
class Plain(Exception):
status_code = 429
code = ""
type = ""
assert controlled_probe_error(Plain("too many requests"))["error"] == "Rate limited"

View file

@ -75,10 +75,8 @@ import { accountRowFacts } from './harness_accounts.js';
subagentsOpen: false,
reviewersOpen: false,
localSourceOpen: Boolean(INITIAL_STATE.localSource),
moreProvidersOpen: Boolean(
INITIAL_STATE.cloudruKey || INITIAL_STATE.minimaxKey || INITIAL_STATE.deepseekKey
|| INITIAL_STATE.compatibleBaseUrl || INITIAL_STATE.compatibleApiKey,
),
moreProvidersOpen: Boolean(INITIAL_STATE.cloudruKey || INITIAL_STATE.minimaxKey || INITIAL_STATE.deepseekKey
|| INITIAL_STATE.zaiKey || INITIAL_STATE.compatibleBaseUrl || INITIAL_STATE.compatibleApiKey),
localStatusText: 'Status: Offline',
localStatusTone: 'muted',
localTestResult: '',
@ -135,7 +133,7 @@ import { accountRowFacts } from './harness_accounts.js';
}
function hasApiAccess() {
return PROVIDER_FIELDS.some((field) => !['MINIMAX_REGION', 'OPENAI_COMPATIBLE_API_KEY'].includes(field.settingKey)
return PROVIDER_FIELDS.some((field) => !['MINIMAX_REGION', 'ZAI_PLAN', 'OPENAI_COMPATIBLE_API_KEY'].includes(field.settingKey)
&& trim(state[field.stateKey]));
}
@ -266,7 +264,7 @@ import { accountRowFacts } from './harness_accounts.js';
['OPENAI_API_KEY', 'openai'],
['CLOUDRU_FOUNDATION_MODELS_API_KEY', 'cloudru'],
['MINIMAX_API_KEY', 'minimax'],
['DEEPSEEK_API_KEY', 'deepseek'],
['DEEPSEEK_API_KEY', 'deepseek'], ['ZAI_API_KEY', 'zai'],
['ANTHROPIC_API_KEY', 'anthropic'],
].filter(([settingKey]) => configured[settingKey]);
if (hasOpenrouter) return 'openrouter';
@ -413,7 +411,7 @@ import { accountRowFacts } from './harness_accounts.js';
// one value the wizard can never replace.
const shortKey = keyValues.find(([field, value]) => value && (field.inputType || 'password') === 'password' && value.length < 10 && value !== trim(INITIAL_STATE[field.stateKey]));
if (shortKey) return `${shortKey[0].label.replace(' API Key', '')} API key looks too short.`;
const hasRemote = keyValues.some(([field, value]) => value && !['OPENAI_COMPATIBLE_API_KEY', 'MINIMAX_REGION'].includes(field.settingKey));
const hasRemote = keyValues.some(([field, value]) => value && !['OPENAI_COMPATIBLE_API_KEY', 'MINIMAX_REGION', 'ZAI_PLAN'].includes(field.settingKey));
if (!hasRemote && !localSource && !hasModelSubscription()) {
return state.agentsConnected.length
? 'A Main model source has not been confirmed. Retry discovery, add an API key, or choose a local model.'
@ -613,6 +611,7 @@ import { accountRowFacts } from './harness_accounts.js';
if (trim(state.cloudruKey)) rows.splice(1, 0, ['Cloud.ru', 'configured']);
if (trim(state.minimaxKey)) rows.splice(1, 0, ['MiniMax', 'configured']);
if (trim(state.deepseekKey)) rows.splice(1, 0, ['DeepSeek', 'configured']);
if (trim(state.zaiKey)) rows.splice(1, 0, ['Z.ai (GLM)', trim(state.zaiPlan) === 'coding' ? 'configured · coding plan' : 'configured']);
if (trim(state.anthropicKey)) rows.splice(1, 0, ['Anthropic', 'configured']);
if (hasLocalModel()) {
rows.splice(

View file

@ -84,13 +84,13 @@ export function composeModelSource(source, model) {
// Owner-facing order of the direct API providers. OpenRouter first because an
// unprefixed model id routes through it; the rest follow the settings order.
export const API_PROVIDER_ORDER = ['openrouter', 'openai', 'anthropic', 'deepseek',
'minimax', 'cloudru', 'gigachat', 'openai-compatible'];
'zai', 'minimax', 'cloudru', 'gigachat', 'openai-compatible'];
// Fallback names for providers the setup contract does not describe (GigaChat
// has no profile spec). The contract's label wins whenever it exists.
const API_PROVIDER_LABELS = {
openrouter: 'OpenRouter', openai: 'OpenAI', anthropic: 'Anthropic', deepseek: 'DeepSeek',
minimax: 'MiniMax', cloudru: 'Cloud.ru Foundation Models', gigachat: 'GigaChat',
zai: 'Z.ai (GLM)', minimax: 'MiniMax', cloudru: 'Cloud.ru Foundation Models', gigachat: 'GigaChat',
'openai-compatible': 'OpenAI-compatible endpoint',
};
@ -103,6 +103,7 @@ const API_PROVIDER_CREDENTIALS = {
openai: [['OPENAI_API_KEY']],
anthropic: [['ANTHROPIC_API_KEY']],
deepseek: [['DEEPSEEK_API_KEY']],
zai: [['ZAI_API_KEY']],
minimax: [['MINIMAX_API_KEY']],
cloudru: [['CLOUDRU_FOUNDATION_MODELS_API_KEY']],
gigachat: [['GIGACHAT_CREDENTIALS'], ['GIGACHAT_USER', 'GIGACHAT_PASSWORD']],

View file

@ -38,6 +38,7 @@ const INPUT_FIELDS = [
['s-openai-base-url', 'OPENAI_BASE_URL'], ['s-openai-compatible-base-url', 'OPENAI_COMPATIBLE_BASE_URL'], ['s-cloudru-base-url', 'CLOUDRU_FOUNDATION_MODELS_BASE_URL'],
['s-gigachat-scope', 'GIGACHAT_SCOPE'], ['s-gigachat-user', 'GIGACHAT_USER'], ['s-gigachat-base-url', 'GIGACHAT_BASE_URL'], ['s-gigachat-verify-ssl', 'GIGACHAT_VERIFY_SSL_CERTS'],
['s-minimax-region', 'MINIMAX_REGION'],
['s-zai-plan', 'ZAI_PLAN'],
['s-server-host', 'OUROBOROS_SERVER_HOST', '127.0.0.1'],
// 6.1: OUROBOROS_REVIEW_MODELS / OUROBOROS_SCOPE_REVIEW_MODELS are no
// longer authored here — the Review lanes section composes the ONE
@ -401,12 +402,13 @@ function collectSecretValue(id, body) {
* Exported for dependency-free node tests.
*/
export function moreProvidersCredentialConfigured({
cloudruKey = '', minimaxKey = '', deepseekKey = '', gigachatCredentials = '', gigachatUser = '', gigachatPassword = '',
cloudruKey = '', minimaxKey = '', deepseekKey = '', zaiKey = '', gigachatCredentials = '', gigachatUser = '', gigachatPassword = '',
} = {}) {
const has = (v) => Boolean(String(v ?? '').trim());
return has(cloudruKey)
|| has(minimaxKey)
|| has(deepseekKey)
|| has(zaiKey)
|| has(gigachatCredentials)
|| (has(gigachatUser) && has(gigachatPassword));
}
@ -757,6 +759,7 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
cloudruKey: value('s-cloudru-key'),
minimaxKey: value('s-minimax-key'),
deepseekKey: value('s-deepseek-key'),
zaiKey: value('s-zai-key'),
gigachatCredentials: value('s-gigachat-credentials'),
gigachatUser: value('s-gigachat-user'),
gigachatPassword: value('s-gigachat-password'),

View file

@ -135,6 +135,16 @@ const PROVIDER_CARDS = [
testInputs: { 's-deepseek-key': 'DEEPSEEK_API_KEY' },
note: 'Pick DeepSeek as the source in Models or Agents, then choose deepseek-v4-pro or deepseek-v4-flash.',
},
{
id: 'zai', title: 'Z.ai (GLM)', icon: '', hint: 'Direct OpenAI-compatible runtime', advanced: true,
fields: [
{ id: 's-zai-key', settingKey: 'ZAI_API_KEY', label: 'API Key', placeholder: 'sk-...' },
{ id: 's-zai-plan', label: 'Plan', placeholder: 'payg or coding' },
],
testProvider: 'zai',
testInputs: { 's-zai-key': 'ZAI_API_KEY', 's-zai-plan': 'ZAI_PLAN' },
note: 'Pick Z.ai as the source in Models or Agents, then choose glm-5.3 or glm-5.3-flash. Coding Plan subscribers: set Plan to <code>coding</code>; the default <code>payg</code> is pay-as-you-go, and a Coding Plan key tested there reports No credits.',
},
{
id: 'gigachat', title: 'GigaChat', icon: '/static/providers/gigachat.svg', hint: 'Sber GigaChat via the gigachat library', advanced: true,
fields: [
@ -230,6 +240,7 @@ export const SECRET_KEYS = [
['ANTHROPIC_API_KEY', 'Anthropic API Key', 'sk-ant-...'],
['MINIMAX_API_KEY', 'MiniMax API Key', 'MiniMax key'],
['DEEPSEEK_API_KEY', 'DeepSeek API Key', 'sk-...'],
['ZAI_API_KEY', 'Z.ai API Key (GLM)', 'Z.ai key'],
['GITHUB_TOKEN', 'GitHub Token', 'ghp_...'],
['OUROBOROS_NETWORK_PASSWORD', 'Network Password', 'Required for LAN/Docker binds'],
];

View file

@ -27,7 +27,8 @@ test('a provider is offered only while its credential is stored, in one owner-fa
OPENAI_COMPATIBLE_BASE_URL: 'http://localhost:11434/v1',
GIGACHAT_USER: 'owner', GIGACHAT_PASSWORD: '***set***',
CLOUDRU_FOUNDATION_MODELS_API_KEY: '***set***', MINIMAX_API_KEY: 'mm',
DEEPSEEK_API_KEY: 'ds', ANTHROPIC_API_KEY: 'sk-ant', OPENAI_API_KEY: 'sk',
DEEPSEEK_API_KEY: 'ds', ZAI_API_KEY: 'zai',
ANTHROPIC_API_KEY: 'sk-ant', OPENAI_API_KEY: 'sk',
OPENROUTER_API_KEY: 'sk-or',
});
assert.deepEqual(every.map((provider) => provider.id), API_PROVIDER_ORDER);

View file

@ -181,6 +181,28 @@
"settingsInputId": "s-deepseek-key",
"stateKey": "deepseekKey"
},
{
"group": "more",
"id": "zai-key",
"inputType": "password",
"label": "Z.ai API Key (GLM)",
"note": "Optional. If this is the only remote key, the next step prefills Z.ai's own GLM model ids.",
"placeholder": "...",
"settingKey": "ZAI_API_KEY",
"settingsInputId": "s-zai-key",
"stateKey": "zaiKey"
},
{
"group": "more",
"id": "zai-plan",
"inputType": "text",
"label": "Z.ai Plan",
"note": "Choose payg (pay-as-you-go, default) or coding (Coding Plan endpoint; officially intended for supported coding tools only).",
"placeholder": "payg or coding",
"settingKey": "ZAI_PLAN",
"settingsInputId": "s-zai-plan",
"stateKey": "zaiPlan"
},
{
"group": "primary",
"id": "anthropic-key",
@ -260,6 +282,11 @@
"label": "OpenRouter",
"modelCopy": "OpenRouter-style routing remains active. OpenRouter stays the source for ids such as openai/gpt-5.6-terra or anthropic/claude-sonnet-5.",
"providerCopy": "OpenRouter is present, so the next step keeps router-style defaults while still saving any extra direct keys you paste here."
},
"zai": {
"label": "Z.ai (GLM)",
"modelCopy": "Z.ai-only setup detected. These defaults use glm-5.3 for main work and glm-5.3-flash for the light lane. The plan setting selects the pay-as-you-go or Coding Plan endpoint.",
"providerCopy": "Z.ai is present, so the next step prefills Z.ai's own GLM model ids."
}
},
"reviewModes": [
@ -394,7 +421,9 @@
"runtimeMode": "advanced",
"skillsRepoPath": "",
"totalBudget": 200.0,
"visionModel": ""
"visionModel": "",
"zaiKey": "",
"zaiPlan": ""
},
"localPresets": {
"qwen25-7b": {
@ -478,6 +507,13 @@
"light": "openai/gpt-5.6-luna",
"main": "google/gemini-3.8-flash",
"vision": ""
},
"zai": {
"consciousness": "",
"fallback": "zai::glm-5.3-flash",
"light": "zai::glm-5.3-flash",
"main": "zai::glm-5.3",
"vision": ""
}
},
"modelSuggestions": [
@ -497,6 +533,8 @@
"deepseek/deepseek-v4-pro",
"deepseek::deepseek-v4-pro",
"deepseek::deepseek-v4-flash",
"zai::glm-5.3",
"zai::glm-5.3-flash",
"openai-compatible::meta-llama/compatible",
"cloudru::zai-org/GLM-4.7",
"minimax::MiniMax-M3",

View file

@ -45,7 +45,7 @@ test('provider test responses apply only to the exact draft generation', () => {
test('every provider test button warns that one charged request is sent', () => {
const html = renderSettingsPage();
const buttons = [...html.matchAll(/data-provider-test="[^"]+"[^>]*>/g)];
assert.equal(buttons.length, 8);
assert.equal(buttons.length, 9);
for (const [button] of buttons) {
assert.match(
button,
@ -58,10 +58,10 @@ test('provider actions use the shared status-first action row contract', () => {
const html = renderSettingsPage();
const rows = [...html.matchAll(/<div class="settings-action-row(?:"|\s)[\s\S]*?<\/div>/g)]
.map(([row]) => row);
// Eight provider probes plus the catalog action. The Claude-runtime
// Nine provider probes plus the catalog action. The Claude-runtime
// status/Repair panel is retired with its product surface (the advisory
// pre-review runs on a configured routed model or agent session now).
assert.equal(rows.length, 9, 'eight provider probes plus the catalog action');
assert.equal(rows.length, 10, 'nine provider probes plus the catalog action');
assert.doesNotMatch(html, /settings-claude-code/);
assert.doesNotMatch(html, /settings-ghost-btn/);
for (const row of rows) {