diff --git a/docs/spec/configuration.md b/docs/spec/configuration.md index 94dd1a787..388e974af 100644 --- a/docs/spec/configuration.md +++ b/docs/spec/configuration.md @@ -103,24 +103,28 @@ keys used by model references. ### Models -Named model roles. Each role points to a provider and model ID. +Named model definitions own provider/model identity and metadata. Roles reference definitions, +so changing Main or Fallback does not destroy overrides belonging to the previous model. ```json { "Models": { - "Main": { + "Definitions": { + "qwen-main": { "Provider": "remote-gpu", "ModelId": "qwen3:30b", "ContextWindow": 32768 + }, + "qwen-small": { + "Provider": "remote-gpu", + "ModelId": "qwen3:8b", + "ContextWindow": 32768 + } }, - "Fallback": { - "Provider": "remote-gpu", - "ModelId": "qwen3:8b", - "ContextWindow": 32768 - }, - "Compaction": { - "Provider": "remote-gpu", - "ModelId": "qwen3:8b" + "Roles": { + "Main": "qwen-main", + "Fallback": "qwen-small", + "Compaction": "qwen-small" } } } @@ -481,8 +485,9 @@ following the standard .NET convention. ```bash # Override the main model -export NETCLAW_Models__Main__Provider="openrouter" -export NETCLAW_Models__Main__ModelId="anthropic/claude-sonnet-4" +export NETCLAW_Models__Definitions__claude__Provider="openrouter" +export NETCLAW_Models__Definitions__claude__ModelId="anthropic/claude-sonnet-4" +export NETCLAW_Models__Roles__Main="claude" # Set a provider API key export NETCLAW_Providers__openrouter__ApiKey="sk-or-v1-..." @@ -520,14 +525,20 @@ export NETCLAW_Session__MaxToolIterationsPerTurn="60" } }, "Models": { - "Main": { - "Provider": "local", - "ModelId": "qwen3:30b", - "ContextWindow": 32768 + "Definitions": { + "qwen-main": { + "Provider": "local", + "ModelId": "qwen3:30b", + "ContextWindow": 32768 + }, + "qwen-small": { + "Provider": "local", + "ModelId": "qwen3:8b" + } }, - "Compaction": { - "Provider": "local", - "ModelId": "qwen3:8b" + "Roles": { + "Main": "qwen-main", + "Compaction": "qwen-small" } }, "Session": { diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 4980ff49e..46df1398d 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.24.1" + version: "2.26.0" --- # Netclaw Operations diff --git a/feeds/skills/.system/files/netclaw-operations/references/providers.md b/feeds/skills/.system/files/netclaw-operations/references/providers.md index 7e2349a83..8930557b3 100644 --- a/feeds/skills/.system/files/netclaw-operations/references/providers.md +++ b/feeds/skills/.system/files/netclaw-operations/references/providers.md @@ -36,7 +36,7 @@ options instead of adding provider-specific properties to `ProviderEntry`. ### Degraded mode: No-Op chat client When Netclaw starts without an explicitly configured main model/provider -(no `Models:Main`, incomplete `Models:Main`, no `Providers`, or `Models:Main` +(no `Models.Roles.Main`, an unresolved definition, no `Providers`, or the selected definition points to a provider that is not configured), the daemon launches in **degraded mode** with a No-Op chat client. Bound defaults such as `local-ollama/qwen3:30b` do not count as operator configuration unless those @@ -78,6 +78,35 @@ When adding an OpenAI provider from the CLI, `netclaw provider add openai` defaults to the ChatGPT OAuth device flow. Use `--auth api-key --api-key ` to force platform API-key auth instead. +### Assigning models to roles and overriding metadata + +`netclaw model set ` creates or reuses a named model +definition and assigns it to a role (`main`, `fallback`, `compaction`). Definitions +own provider/model identity and metadata, while roles only reference definitions. +Switching away from a model and back therefore preserves its overrides. Two attributes can be overridden by the +operator and are **operator-owned**: the context window and the input/output +modalities. Provider discovery seeds a new definition but never changes an existing +definition, including adding a property the definition deliberately omits. + +- `--context-window ` clamps the session budget and takes precedence + over provider-reported detection. Supplying it configures the model manually + and skips the metadata probe. +- `--input-modalities ` / `--output-modalities ` override detected + modalities with a comma-separated list of named flags (`Text`, `Image`, + `Audio`, `Video`). These do **not** skip the probe — the model is still + validated and its context window discovered; the override just wins over the + discovered modalities. +- `--clear-context-window` and `--clear-modalities` remove the respective + override so runtime capability detection resolves it again (use these after a + provider enlarges a model's window or fixes mis-reported modalities). + +To change a preserved value you must pass the corresponding flag (a plain +re-set will not touch it). A legacy or hand-edited entry with an unreadable +value does not block a re-set — `model set` migrates legacy inline roles to named +definitions and repairs the selected entry while keeping the fields +it can still read; `model list` reports an unparseable config instead of +crashing, and `netclaw doctor --fix` repairs it. + ### Adding GitHub Copilot GitHub Copilot uses the OAuth device flow only — no API key. The operator diff --git a/openspec/changes/preserve-model-definitions-across-role-switches/.openspec.yaml b/openspec/changes/preserve-model-definitions-across-role-switches/.openspec.yaml new file mode 100644 index 000000000..eb5fa80e6 --- /dev/null +++ b/openspec/changes/preserve-model-definitions-across-role-switches/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-10 diff --git a/openspec/changes/preserve-model-definitions-across-role-switches/design.md b/openspec/changes/preserve-model-definitions-across-role-switches/design.md new file mode 100644 index 000000000..2fb5b0906 --- /dev/null +++ b/openspec/changes/preserve-model-definitions-across-role-switches/design.md @@ -0,0 +1,59 @@ +## Context + +Model metadata is currently embedded in three runtime role entries. Those entries are both the operator's durable configuration and the runtime consumer shape, so assigning a different model destroys metadata belonging to the previous model. Existing deployments and the current stable Docker image use this legacy shape. + +## Goals / Non-Goals + +**Goals:** + +- Store model-owned metadata once in named definitions and make roles reference definitions. +- Keep manual JSON editing obvious: property absence means runtime detection, with no tombstones. +- Run legacy configuration without an eager write, and migrate deterministically on explicit mutation/fix. +- Resolve and validate references before persistence and runtime client construction. + +**Non-Goals:** + +- Downgrade compatibility after the configuration has been migrated. +- Automatic conflict resolution or model-definition garbage collection. +- Changes to provider discovery or actor/persistence protocols. + +## Decisions + +### New canonical shape + +`Models.Definitions` is a dictionary of operator-chosen names to complete `ModelReference` values. `Models.Roles` contains `Main`, `Fallback`, and `Compaction` definition-name references. Runtime code receives the existing resolved `ModelSelection`, keeping actor and chat-client boundaries unchanged. + +This is preferred over a hidden metadata cache or role-entry tombstones because it gives manual editors one visible source of truth and preserves property absence as runtime detection. + +### Dual-shape reader, single-shape writer + +A shared configuration resolver accepts either the complete legacy inline shape or the complete named shape. Mixed shapes, missing definitions, duplicate/invalid names, and conflicting migration candidates fail loudly. Daemon startup reads legacy configuration without rewriting it. CLI/TUI writes and `doctor --fix` migrate legacy input atomically before applying the requested mutation. + +The schema accepts both complete shapes during the compatibility window. New writers emit only the named shape. + +### Deterministic legacy migration + +Each distinct case-insensitive `(Provider, ModelId)` becomes one definition. A deterministic slug is derived from provider and model ID, with a stable numeric suffix for name collisions. When multiple legacy roles identify the same model, their optional metadata must agree; otherwise migration fails with the conflicting role names and fields. + +### Upgrade smoke + +The smoke harness creates a disposable directory/volume, runs the latest stable image to produce or consume a legacy configuration, stops it, builds a uniquely tagged local image, and starts that image against the same isolated volume. Assertions verify startup, legacy resolution, explicit migration, and preservation after role switching. Cleanup removes only resources carrying the test's unique label/name. + +## Risks / Trade-offs + +- **Older binaries cannot read the named shape after migration** → document that rollback requires restoring the pre-migration backup; migration writes atomically and retains a backup. +- **Dual-shape support can become permanent complexity** → centralize it in one resolver and have every writer emit only the canonical shape. +- **Conflicting legacy roles could be silently merged** → reject conflicts and report exact fields/roles. +- **Docker smoke could touch operator state** → require an absolute temporary path created by the harness and unique container/image names; never use default Netclaw volumes. +- **Stable image availability/network failures** → make the Docker upgrade scenario explicit and fail with actionable diagnostics; unit migration fixtures remain mandatory offline proof. + +## Migration Plan + +1. Ship a reader that supports both shapes and schema validation for both. +2. Verify an untouched legacy stable configuration starts with the new daemon. +3. On the first explicit model/config write or `doctor --fix`, validate, back up, migrate, re-resolve, then atomically persist. +4. Document rollback as restoring the generated legacy backup before running an older image. + +## Open Questions + +- The exact stable tag is resolved from the release manifest at smoke execution time rather than hard-coded. diff --git a/openspec/changes/preserve-model-definitions-across-role-switches/proposal.md b/openspec/changes/preserve-model-definitions-across-role-switches/proposal.md new file mode 100644 index 000000000..ac0dd85ca --- /dev/null +++ b/openspec/changes/preserve-model-definitions-across-role-switches/proposal.md @@ -0,0 +1,34 @@ +## Why + +PRD-004 and PRD-005 allow operators to select models and override provider-reported capabilities, but the current `Models.Main` / `Fallback` / `Compaction` entries combine role assignment with model-owned metadata. Switching a role therefore destroys manually maintained context-window and modality overrides, especially for vLLM deployments that cannot report modalities. + +## What Changes + +- Add human-readable named model definitions whose metadata is independent of role assignment. +- Make model roles reference named definitions, so switching roles does not rewrite a definition. +- Continue accepting the existing inline role shape on upgrade and provide deterministic migration to the named shape. +- Reject ambiguous mixed or conflicting configuration instead of silently choosing a representation. +- Add isolated stable-container to locally-built-container upgrade smoke coverage using a disposable volume. +- Preserve absence of optional metadata as runtime detection; no hidden tombstone values are introduced. + +In scope: configuration binding, CLI/TUI model assignment, schema, doctor/migration behavior, operational guidance, automated compatibility proof, and Docker upgrade smoke coverage. + +Out of scope: automatic model discovery beyond existing probes, changing provider APIs, and supporting downgrade from the new shape to an older Netclaw binary. + +## Capabilities + +### New Capabilities + +- `named-model-definitions`: Model-owned definitions, role references, legacy resolution, migration, and conflict behavior. + +### Modified Capabilities + +- `netclaw-model-providers`: Primary, fallback, and compaction assignments reference persistent model definitions. +- `netclaw-cli`: Model commands and TUI preserve model metadata across role switches and expose migration failures. +- `netclaw-testing`: Upgrade compatibility is proven with legacy configuration and an isolated container-volume smoke. + +## Impact + +Affected areas include `Netclaw.Configuration` model types and schema, daemon/CLI configuration binding, model CLI and TUI persistence, provider rename behavior, doctor repair, system operational guidance, and smoke tooling. Startup remains fail-closed for invalid references. Existing inline deployments remain readable and runnable without an eager startup rewrite. + +Security impact is limited to configuration integrity: unresolved or conflicting role references fail before persistence or runtime client construction. Operationally, configuration is migrated only by an explicit writing/fix operation, and the upgrade smoke never mounts the operator's real Netclaw home. diff --git a/openspec/changes/preserve-model-definitions-across-role-switches/specs/named-model-definitions/spec.md b/openspec/changes/preserve-model-definitions-across-role-switches/specs/named-model-definitions/spec.md new file mode 100644 index 000000000..fc4a2fdbf --- /dev/null +++ b/openspec/changes/preserve-model-definitions-across-role-switches/specs/named-model-definitions/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Model-owned named definitions +The system SHALL store provider identity, model ID, context-window override, modality overrides, and provenance in named model definitions independent of runtime role assignment. Roles SHALL reference definitions by name. + +#### Scenario: Switching away and back preserves overrides +- **GIVEN** definition `vision` has a manual `InputModalities` override +- **WHEN** Main switches from `vision` to another definition and back +- **THEN** the `vision` definition SHALL remain unchanged +- **AND** Main SHALL resolve to its original override + +#### Scenario: Manual absence remains runtime detection +- **GIVEN** an existing definition omits an optional capability property +- **WHEN** the definition is assigned to another role +- **THEN** the property SHALL remain absent +- **AND** no tombstone or discovered replacement SHALL be persisted + +### Requirement: Legacy model configuration compatibility +The system SHALL accept the legacy inline Main/Fallback/Compaction shape without rewriting it during startup and SHALL resolve it to the same runtime model selection. + +#### Scenario: Existing deployment starts after upgrade +- **GIVEN** a valid configuration written by the latest stable Netclaw image +- **WHEN** the upgraded daemon starts +- **THEN** startup SHALL succeed with equivalent model role values and capabilities +- **AND** the configuration file SHALL not be rewritten merely by startup + +#### Scenario: Explicit mutation migrates legacy shape +- **GIVEN** a valid legacy configuration +- **WHEN** an operator performs a model-writing command or runs doctor fix +- **THEN** the system SHALL atomically persist the named shape before completing the mutation +- **AND** the persisted named shape SHALL resolve to the same runtime values + +#### Scenario: Ambiguous shape fails loudly +- **GIVEN** configuration contains both legacy role objects and named role references +- **WHEN** configuration is validated or loaded +- **THEN** the operation SHALL fail with remediation identifying the mixed shape + +### Requirement: Reference integrity +Every persisted role reference SHALL resolve to an existing definition before persistence and startup. + +#### Scenario: Missing definition is rejected +- **WHEN** a role references an unknown definition +- **THEN** validation SHALL fail before runtime client construction +- **AND** no partial configuration write SHALL occur + +#### Scenario: Conflicting legacy duplicates are rejected +- **GIVEN** two legacy roles identify the same provider/model but contain conflicting overrides +- **WHEN** migration is requested +- **THEN** migration SHALL fail with the conflicting roles and fields +- **AND** the legacy file SHALL remain unchanged diff --git a/openspec/changes/preserve-model-definitions-across-role-switches/specs/netclaw-cli/spec.md b/openspec/changes/preserve-model-definitions-across-role-switches/specs/netclaw-cli/spec.md new file mode 100644 index 000000000..d8c2383e3 --- /dev/null +++ b/openspec/changes/preserve-model-definitions-across-role-switches/specs/netclaw-cli/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Named model role management +Model CLI and TUI operations SHALL assign roles by changing references and SHALL edit model metadata only through the selected definition. + +#### Scenario: Assign existing definition +- **WHEN** an operator assigns an existing named definition to Main +- **THEN** only the Main role reference SHALL change +- **AND** no definition metadata SHALL change + +#### Scenario: Mutating legacy configuration +- **GIVEN** the CLI loads a valid legacy model configuration +- **WHEN** a model mutation is requested +- **THEN** the CLI SHALL migrate and validate the canonical shape before persistence +- **AND** failure SHALL leave the original file unchanged diff --git a/openspec/changes/preserve-model-definitions-across-role-switches/specs/netclaw-model-providers/spec.md b/openspec/changes/preserve-model-definitions-across-role-switches/specs/netclaw-model-providers/spec.md new file mode 100644 index 000000000..5743dded3 --- /dev/null +++ b/openspec/changes/preserve-model-definitions-across-role-switches/specs/netclaw-model-providers/spec.md @@ -0,0 +1,21 @@ +## MODIFIED Requirements + +### Requirement: Primary and fallback model +The system SHALL support configuring primary, fallback, and compaction roles as references to persistent named model definitions. Changing a role SHALL NOT change the referenced definition. When the primary model is unavailable due to rate limiting, timeout, or error, the system SHALL automatically switch to the fallback model. Fallback activation SHALL be logged for operator visibility. + +#### Scenario: Primary model succeeds +- **GIVEN** both primary and fallback roles reference valid definitions +- **WHEN** the primary model responds successfully +- **THEN** the primary model response SHALL be used +- **AND** no fallback activation SHALL occur + +#### Scenario: Automatic fallback on primary failure +- **GIVEN** both primary and fallback roles reference valid definitions +- **WHEN** the primary model returns a rate limit, timeout, or error response +- **THEN** the system SHALL retry using the fallback definition +- **AND** a log entry SHALL record the fallback activation with the failure reason + +#### Scenario: Role switch preserves model definition +- **GIVEN** a named model definition contains operator capability overrides +- **WHEN** Main or Fallback is assigned to another definition +- **THEN** the previous definition SHALL remain unchanged and available for reassignment diff --git a/openspec/changes/preserve-model-definitions-across-role-switches/specs/netclaw-testing/spec.md b/openspec/changes/preserve-model-definitions-across-role-switches/specs/netclaw-testing/spec.md new file mode 100644 index 000000000..833a552fd --- /dev/null +++ b/openspec/changes/preserve-model-definitions-across-role-switches/specs/netclaw-testing/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Container upgrade compatibility proof +The smoke suite SHALL verify upgrade from the latest stable Netclaw container to a locally built image using only an isolated temporary configuration volume. + +#### Scenario: Stable-to-local upgrade +- **GIVEN** the latest stable image has written or consumed a legacy config in a disposable volume +- **WHEN** a uniquely tagged local image starts against the same volume +- **THEN** the new image SHALL become healthy without modifying the file on startup +- **AND** an explicit migration SHALL preserve effective role and capability values +- **AND** switching away from and back to a definition SHALL preserve its overrides + +#### Scenario: Production state isolation +- **WHEN** the upgrade smoke runs +- **THEN** it SHALL use a newly created absolute temporary directory or uniquely named test volume +- **AND** it SHALL NOT mount or inspect the default or operator-provided Netclaw home diff --git a/openspec/changes/preserve-model-definitions-across-role-switches/tasks.md b/openspec/changes/preserve-model-definitions-across-role-switches/tasks.md new file mode 100644 index 000000000..1d03f3282 --- /dev/null +++ b/openspec/changes/preserve-model-definitions-across-role-switches/tasks.md @@ -0,0 +1,20 @@ +## 1. Canonical configuration and compatibility + +- [x] 1.1 Add named definition and role-reference configuration types plus one resolver for legacy and canonical shapes +- [x] 1.2 Add deterministic, conflict-detecting legacy migration with atomic persistence and backup behavior +- [x] 1.3 Update the JSON schema to accept legacy or canonical models while rejecting mixed/invalid shapes +- [x] 1.4 Route daemon, CLI, doctor, provider rename, wizard, and TUI consumers through resolved canonical configuration + +## 2. Operator workflows + +- [x] 2.1 Update model CLI commands to create/edit definitions and switch roles without mutating definitions +- [x] 2.2 Update the model-manager TUI and initialization writer to emit canonical configuration +- [x] 2.3 Update `netclaw-operations` guidance and CLI help, including migration and rollback behavior + +## 3. Automated proof + +- [x] 3.1 Add legacy load, conflict rejection, canonical round-trip, and role A→B→A preservation tests +- [x] 3.2 Add CLI/TUI tests proving invalid references are rejected before persistence +- [x] 3.3 Add isolated latest-stable-container → local-image upgrade smoke and semantic assertions +- [ ] 3.4 Run targeted/full tests, native TUI smoke, evals, Slopwatch, and copyright verification +- [x] 3.5 Validate OpenSpec implementation alignment and prepare spec synchronization diff --git a/scripts/docker/test-model-config-upgrade.sh b/scripts/docker/test-model-config-upgrade.sh new file mode 100755 index 000000000..7623cf3c4 --- /dev/null +++ b/scripts/docker/test-model-config-upgrade.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Verify that the latest stable image's inline model configuration survives an upgrade to a +# locally built image and migrates without losing operator-owned model metadata. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/docker/lib/smoke-lib.sh +. "$SCRIPT_DIR/lib/smoke-lib.sh" + +LOCAL_IMAGE="${1:?usage: test-model-config-upgrade.sh [stable-image]}" +STABLE_IMAGE="${2:-ghcr.io/netclaw-dev/netclaw:latest}" +RUN_ID="model-upgrade-$PPID-$$" +CONTAINER="netclaw-$RUN_ID" +ROOT="$(mktemp -d -t netclaw-model-upgrade.XXXXXX)" +HOME_DIR="$ROOT/home" +CONFIG_DIR="$HOME_DIR/config" +CONFIG="$CONFIG_DIR/netclaw.json" + +cleanup() { + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + docker run --rm --entrypoint chmod -v "$ROOT:/cleanup" "$LOCAL_IMAGE" \ + -R a+rwX /cleanup >/dev/null 2>&1 || true + rm -rf "$ROOT" +} +trap cleanup EXIT + +mkdir -p "$CONFIG_DIR" +chmod 0777 "$HOME_DIR" "$CONFIG_DIR" + +cat >"$CONFIG" <<'JSON' +{ + "configVersion": 1, + "Providers": { + "vllm": { + "Type": "openai-compatible", + "Endpoint": "http://127.0.0.1:8000" + } + }, + "Models": { + "Main": { + "Provider": "vllm", + "ModelId": "qwen-vl", + "ContextWindow": 32768, + "InputModalities": "Text, Image", + "OutputModalities": "Text" + }, + "Fallback": { + "Provider": "vllm", + "ModelId": "llama-text" + } + } +} +JSON +chmod 0666 "$CONFIG" + +echo "==> Latest stable consumes the isolated legacy configuration: $STABLE_IMAGE" +docker run --rm --entrypoint /usr/local/bin/netclaw \ + -v "$HOME_DIR:/home/netclaw/.netclaw" "$STABLE_IMAGE" model list >/dev/null + +legacy_hash="$(sha256sum "$CONFIG" | awk '{print $1}')" + +echo "==> Locally built daemon starts against the same legacy configuration without rewriting it" +docker run -d --name "$CONTAINER" -v "$HOME_DIR:/home/netclaw/.netclaw" "$LOCAL_IMAGE" >/dev/null +netclaw_wait_healthy "$CONTAINER" 5199 60 +[[ "$(sha256sum "$CONFIG" | awk '{print $1}')" == "$legacy_hash" ]] \ + || { echo "ERROR: startup rewrote the legacy configuration" >&2; exit 1; } +docker rm -f "$CONTAINER" >/dev/null + +run_local_cli() { + docker run --rm --entrypoint /usr/local/bin/netclaw \ + -v "$HOME_DIR:/home/netclaw/.netclaw" "$LOCAL_IMAGE" "$@" +} + +echo "==> Explicit model mutation migrates, then A -> B -> A preserves A's overrides" +run_local_cli model set main vllm llama-text --context-window 65536 >/dev/null +run_local_cli model set main vllm qwen-vl >/dev/null + +jq -e ' + .Models.Main == null and + .Models.Roles.Main as $active | + .Models.Definitions[$active] | + .Provider == "vllm" and + .ModelId == "qwen-vl" and + .ContextWindow == 32768 and + .InputModalities == "Text, Image" and + .OutputModalities == "Text" +' "$CONFIG" >/dev/null + +test -f "$CONFIG.legacy-models.bak" +jq -e '.Models.Main.ModelId == "qwen-vl"' "$CONFIG.legacy-models.bak" >/dev/null + +echo "✓ stable legacy config starts unchanged, migrates explicitly, and preserves model metadata" diff --git a/src/Netclaw.Cli.Tests/Config/ModelEntryWriterTests.cs b/src/Netclaw.Cli.Tests/Config/ModelEntryWriterTests.cs index 01e2ae467..28101310c 100644 --- a/src/Netclaw.Cli.Tests/Config/ModelEntryWriterTests.cs +++ b/src/Netclaw.Cli.Tests/Config/ModelEntryWriterTests.cs @@ -3,9 +3,12 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Text.Json; using Netclaw.Cli.Config; using Netclaw.Configuration; using Xunit; +using ModalityOverride = Netclaw.Cli.Config.ValueOverride; +using ContextWindowOverride = Netclaw.Cli.Config.ValueOverride; namespace Netclaw.Cli.Tests.Config; @@ -13,7 +16,8 @@ namespace Netclaw.Cli.Tests.Config; /// Guards the single persist path shared by `model set`, the init wizard, and the TUI /// model manager. The rule under test: modalities are written only when the discovery /// source genuinely reported them, so an unknown is never frozen into config as a -/// permanent "Text" override (#1290). +/// permanent "Text" override (#1290), and operator-owned overrides survive re-selection +/// (#1127 / #1610). /// public class ModelEntryWriterTests { @@ -57,4 +61,325 @@ public void BuildModelEntry_BlankModelIdAndNullProvenance_OmitsBoth() Assert.False(entry.ContainsKey("ModelId")); Assert.False(entry.ContainsKey("Provenance")); } + + [Fact] + public void WriteRole_SameModelWithoutModalities_PreservesHandSetModalities() + { + // On-disk shape: an operator-set InputModalities override. + var models = Models( + """ + { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "ContextWindow": 262144, "InputModalities": "Text, Image" } } + """); + + // Re-set the same model with only a context-window change; no modality intent supplied. + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Manual, + ContextWindowOverride.Set(131072), ModalityOverride.Unset, ModalityOverride.Unset, discovered: null); + + var entry = ActiveEntry(models, "Main"); + Assert.Equal("Text, Image", entry["InputModalities"]); // preserved (#1127) + Assert.Equal(131072, entry["ContextWindow"]); // explicit override applied + } + + [Fact] + public void WriteRole_SameModel_PreservesExistingClampOverDiscoveredWindow() + { + // Operator clamped ContextWindow below what the provider reports. + var models = Models( + """ + { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "ContextWindow": 32000 } } + """); + + // Re-select the same model (picker path): no explicit --context-window, but the probe + // reports the model's full window. The operator's clamp must win (#1610): ContextWindow + // is documented to take precedence over provider-reported detection. + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, + Discovered(contextWindow: 128000)); + + var entry = ActiveEntry(models, "Main"); + Assert.Equal(32000, entry["ContextWindow"]); + } + + [Fact] + public void WriteRole_FirstTimeSet_UsesDiscoveredWindow() + { + // No existing entry for the role: discovery is the fallback that seeds the value. + var models = new Dictionary(); + + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, + Discovered(contextWindow: 128000)); + + var entry = ActiveEntry(models, "Main"); + Assert.Equal(128000, entry["ContextWindow"]); + } + + [Fact] + public void WriteRole_ClearContextWindow_DropsStoredClampAndDiscoveredWindow() + { + // Operator clamped the window; now --clear-context-window removes the clamp so runtime + // detection resolves it. Clear wins over BOTH the stored value and a probe that reports + // a window (#1610) — mirroring --clear-modalities. + var models = Models( + """ + { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "ContextWindow": 32000 } } + """); + + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, + ContextWindowOverride.Clear, ModalityOverride.Unset, ModalityOverride.Unset, + Discovered(contextWindow: 128000)); + + var entry = ActiveEntry(models, "Main"); + Assert.False(entry.ContainsKey("ContextWindow")); // clamp removed → runtime detection + } + + [Fact] + public void WriteRole_SameDefinitionWithoutWindow_DiscoveryDoesNotResurrect() + { + var models = Models( + """ + { "Main": { "Provider": "spark", "ModelId": "qwen-vl" } } + """); + + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, + Discovered(contextWindow: 128000)); + + Assert.False(ActiveEntry(models, "Main").ContainsKey("ContextWindow")); + } + + [Fact] + public void WriteRole_SameModelFreshProbe_DoesNotOverrideExistingModalities() + { + // Operator override on disk; a fresh probe reports a coarser Text-only capability. + var models = Models( + """ + { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "InputModalities": "Text, Image" } } + """); + + // The stored override wins — discovery never silently overwrites it (#1610 / #5). This is + // the same rule as ContextWindow: the field is documented to bypass automated detection. + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, + Discovered(input: ModelModality.Text, output: ModelModality.Text)); + + var entry = ActiveEntry(models, "Main"); + Assert.Equal("Text, Image", entry["InputModalities"]); + } + + [Fact] + public void WriteRole_SameModelEntryClearedModality_DiscoveryDoesNotResurrect() + { + // The entry exists for this model but carries NO modality — the on-disk shape left behind + // by a prior --clear-modalities. A later same-model re-set that carries a probe result must + // NOT re-add the discovered modality, or it would silently undo the operator's clear and + // re-demote a multimodal-misreporting model on next boot (#1610). + var models = Models( + """ + { "Main": { "Provider": "spark", "ModelId": "qwen-vl" } } + """); + + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, + Discovered(input: ModelModality.Text | ModelModality.Image)); + + var entry = ActiveEntry(models, "Main"); + Assert.False(entry.ContainsKey("InputModalities")); // stays cleared, not resurrected + } + + [Fact] + public void WriteRole_ExplicitModalityOverride_ReplacesExisting() + { + var models = Models( + """ + { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "InputModalities": "Text, Image" } } + """); + + // Explicit operator input (--input-modalities Text) is the authority: it replaces the + // stored override so the operator can actually change a value discovery no longer touches. + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Manual, + ContextWindowOverride.Unset, + ModalityOverride.Set(ModelModality.Text), ModalityOverride.Unset, discovered: null); + + var entry = ActiveEntry(models, "Main"); + Assert.Equal("Text", entry["InputModalities"]); + } + + [Fact] + public void WriteRole_ClearModalities_RemovesOverrideEvenWhenProbeReportsOne() + { + var models = Models( + """ + { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "InputModalities": "Text, Image", "OutputModalities": "Text" } } + """); + + // --clear-modalities removes both overrides so runtime detection resolves them; clear + // wins over BOTH the stored value and a probe that still reports modalities (#1610 / #4). + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Manual, + ContextWindowOverride.Unset, ModalityOverride.Clear, ModalityOverride.Clear, + Discovered(input: ModelModality.Text | ModelModality.Image)); + + var entry = ActiveEntry(models, "Main"); + Assert.False(entry.ContainsKey("InputModalities")); + Assert.False(entry.ContainsKey("OutputModalities")); + } + + [Fact] + public void WriteRole_ExistingEntryOmitsModelId_DoesNotFalseMatchDefaultModel() + { + // The entry omits ModelId; ModelReference would default it to the stock "qwen3:30b". + // Setting the stock default model must NOT treat this as the same model and carry the + // stray modalities onto a model the entry never named (#1610). + var models = Models( + """ + { "Main": { "Provider": "local-ollama", "InputModalities": "Text, Image" } } + """); + + ModelEntryWriter.WriteRole( + models, "Main", "local-ollama", "qwen3:30b", ModelDiscoverySource.Manual, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, discovered: null); + + var entry = ActiveEntry(models, "Main"); + Assert.Equal("qwen3:30b", entry["ModelId"]); + Assert.False(entry.ContainsKey("InputModalities")); // stray modality dropped, not carried over + } + + [Theory] + [InlineData(ModelDiscoverySource.Live, ModelDiscoverySource.Manual, ModelDiscoverySource.Live)] + [InlineData(ModelDiscoverySource.Live, ModelDiscoverySource.Defaults, ModelDiscoverySource.Live)] + [InlineData(ModelDiscoverySource.Defaults, ModelDiscoverySource.Live, ModelDiscoverySource.Live)] + public void WriteRole_SameModel_PreservesProvenanceUnlessFreshlyDiscovered( + ModelDiscoverySource existing, + ModelDiscoverySource incoming, + ModelDiscoverySource expected) + { + var models = Models( + $$""" + { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "Provenance": "{{existing}}" } } + """); + + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", incoming, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, discovered: null); + + var entry = ActiveEntry(models, "Main"); + Assert.Equal(expected.ToString(), entry["Provenance"]); + } + + [Fact] + public void WriteRole_CorruptExistingEntry_OverwritesInsteadOfThrowing() + { + // A legacy/hand-corrupted entry: "Vision" is not a valid ModelModality enum name, so + // deserializing it throws JsonException. `model set` must still succeed and repair it + // (#1610) rather than aborting on an entry it is about to overwrite. + var models = Models( + """ + { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "InputModalities": "Vision" } } + """); + + var ex = Record.Exception(() => ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, + Discovered(contextWindow: 128000))); + + Assert.Null(ex); + var entry = ActiveEntry(models, "Main"); + Assert.Equal("qwen-vl", entry["ModelId"]); + Assert.False(entry.ContainsKey("ContextWindow")); // existing absence stays runtime detection + Assert.False(entry.ContainsKey("InputModalities")); // corrupt value dropped, not frozen + } + + [Fact] + public void WriteRole_CorruptModalityButValidWindow_PreservesWindow() + { + // A hand-edited entry with a VALID ContextWindow clamp but an unparseable InputModalities + // string. Discarding the whole entry (the old catch behavior) would silently clobber the + // operator's still-valid window; the field-tolerant read must preserve it (#1610). + var models = Models( + """ + { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "ContextWindow": 32768, "InputModalities": "text_and_image" } } + """); + + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, + Discovered(contextWindow: 128000)); + + var entry = ActiveEntry(models, "Main"); + Assert.Equal(32768, entry["ContextWindow"]); // operator clamp preserved, not clobbered + Assert.False(entry.ContainsKey("InputModalities")); // unparseable override dropped + } + + [Fact] + public void WriteRole_CorruptEntryForDifferentModel_DoesNotLeakWindow() + { + // A corrupt entry that names a DIFFERENT model must not have its ContextWindow carried + // onto the model being set — the resilient read only preserves a same-model entry. + var models = Models( + """ + { "Main": { "Provider": "spark", "ModelId": "other-model", "ContextWindow": 32768, "InputModalities": "Vision" } } + """); + + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, + Discovered(contextWindow: 128000)); + + var entry = ActiveEntry(models, "Main"); + Assert.Equal("qwen-vl", entry["ModelId"]); + Assert.Equal(128000, entry["ContextWindow"]); // discovered window, not the other model's 32768 + } + + [Fact] + public void WriteRole_SwitchAwayAndBack_PreservesPreviousModelModalities() + { + var models = Models( + """ + { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "InputModalities": "Text, Image" } } + """); + + // Switching roles changes only the role reference. The old definition remains intact. + ModelEntryWriter.WriteRole( + models, "Main", "spark", "other-model", ModelDiscoverySource.Manual, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, discovered: null); + + Assert.Equal("other-model", ActiveEntry(models, "Main")["ModelId"]); + + ModelEntryWriter.WriteRole( + models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Manual, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, discovered: null); + + Assert.Equal("Text, Image", ActiveEntry(models, "Main")["InputModalities"]); + } + + private static Dictionary Models(string json) + => JsonSerializer.Deserialize>(json)!; + + private static Dictionary ActiveEntry( + Dictionary models, string role) + { + var roles = (Dictionary)models["Roles"]; + var definitionName = (string)roles[role]; + var definitions = (Dictionary)models["Definitions"]; + return (Dictionary)definitions[definitionName]; + } + + private static DiscoveredModel Discovered( + int? contextWindow = null, ModelModality? input = null, ModelModality? output = null) + => new() + { + ModelId = new ModelId("qwen-vl"), + ContextWindowTokens = contextWindow, + InputModalities = input, + OutputModalities = output, + }; } diff --git a/src/Netclaw.Cli.Tests/Doctor/DoctorFixServiceTests.cs b/src/Netclaw.Cli.Tests/Doctor/DoctorFixServiceTests.cs index f673b5443..245bb3d22 100644 --- a/src/Netclaw.Cli.Tests/Doctor/DoctorFixServiceTests.cs +++ b/src/Netclaw.Cli.Tests/Doctor/DoctorFixServiceTests.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Netclaw.Cli.Daemon; +using System.Text.Json; using Netclaw.Cli.Doctor; using Netclaw.Configuration; using Xunit; @@ -19,6 +20,40 @@ public sealed class DoctorFixServiceTests // ── Config-file fixes (systemd PATH rehydration disabled so these stay hermetic // on machines where netclaw is actually installed as a --user service) ── + [Fact] + public async Task MigratesLegacyModelsWithoutChangingEffectiveMetadata() + { + var paths = NewPaths(); + await File.WriteAllTextAsync(paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Models": { + "Main": { + "Provider": "vllm", + "ModelId": "qwen-vl", + "ContextWindow": 32768, + "InputModalities": "Text, Image" + } + } + } + """, TestContext.Current.CancellationToken); + + var service = ConfigOnlyService(paths); + var plan = await service.BuildPlanAsync(TestContext.Current.CancellationToken); + await service.ApplyAsync(plan, TestContext.Current.CancellationToken); + + using var document = JsonDocument.Parse(await File.ReadAllTextAsync( + paths.NetclawConfigPath, TestContext.Current.CancellationToken)); + var models = document.RootElement.GetProperty("Models"); + var name = models.GetProperty("Roles").GetProperty("Main").GetString()!; + var definition = models.GetProperty("Definitions").GetProperty(name); + Assert.Equal("qwen-vl", definition.GetProperty("ModelId").GetString()); + Assert.Equal(32768, definition.GetProperty("ContextWindow").GetInt32()); + Assert.Equal("Text, Image", definition.GetProperty("InputModalities").GetString()); + Assert.True(File.Exists(paths.NetclawConfigPath + ".legacy-models.bak")); + } + [Fact] public async Task PlansConfigVersionFix_WhenMissing() { diff --git a/src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs b/src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs index 72de9fade..b541261d7 100644 --- a/src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs @@ -88,7 +88,7 @@ public async Task Set_MainModel_WritesConfig() var config = ReadConfigFile(_paths.NetclawConfigPath); Assert.True(config.RootElement.TryGetProperty("Models", out var models)); - Assert.True(models.TryGetProperty("Main", out var main)); + var main = ReadActiveModel(config, "Main"); Assert.Equal("my-ollama", main.GetProperty("Provider").GetString()); Assert.Equal("qwen3:30b", main.GetProperty("ModelId").GetString()); Assert.Equal("Manual", main.GetProperty("Provenance").GetString()); @@ -127,7 +127,7 @@ public async Task Set_OpenAiOAuthModel_StoresLiveDiscoveredMetadata() Assert.Equal(0, exitCode); var config = ReadConfigFile(_paths.NetclawConfigPath); - var main = config.RootElement.GetProperty("Models").GetProperty("Main"); + var main = ReadActiveModel(config, "Main"); Assert.Equal("Live", main.GetProperty("Provenance").GetString()); Assert.Equal(512000, main.GetProperty("ContextWindow").GetInt32()); Assert.Equal("Text, Image", main.GetProperty("InputModalities").GetString()); @@ -354,8 +354,9 @@ public async Task Clear_Fallback_RemovesFromConfig() var config = ReadConfigFile(_paths.NetclawConfigPath); Assert.True(config.RootElement.TryGetProperty("Models", out var models)); - Assert.True(models.TryGetProperty("Main", out _)); // Main still exists - Assert.False(models.TryGetProperty("Fallback", out _)); // Fallback removed + var roles = models.GetProperty("Roles"); + Assert.True(roles.TryGetProperty("Main", out _)); // Main still exists + Assert.False(roles.TryGetProperty("Fallback", out _)); // Fallback removed } [Fact] @@ -396,11 +397,264 @@ public async Task Set_FallbackModel_WritesCorrectRole() var config = ReadConfigFile(_paths.NetclawConfigPath); var models = config.RootElement.GetProperty("Models"); - Assert.True(models.TryGetProperty("Main", out _)); // Main preserved - Assert.True(models.TryGetProperty("Fallback", out var fallback)); + var roles = models.GetProperty("Roles"); + Assert.True(roles.TryGetProperty("Main", out _)); // Main preserved + var fallback = ReadActiveModel(config, "Fallback"); Assert.Equal("qwen3:8b", fallback.GetProperty("ModelId").GetString()); } + [Fact] + public async Task Set_InputModalities_WritesOverrideWithoutProbing() + { + WriteConfig(ProvidersOnly()); + + // A non-OAuth provider never probes; --input-modalities is the manual override channel. + var exitCode = await ModelCommand.RunAsync( + ["model", "set", "main", "my-ollama", "qwen3:30b", "--input-modalities", "Text, Image"], + _paths, output: _output); + + Assert.Equal(0, exitCode); + using var config = ReadConfigFile(_paths.NetclawConfigPath); + var main = ReadActiveModel(config, "Main"); + Assert.Equal("Text, Image", main.GetProperty("InputModalities").GetString()); + } + + [Fact] + public async Task Set_ClearModalities_RemovesExistingOverride() + { + WriteConfig(WithMainModalities("Text, Image")); + + var exitCode = await ModelCommand.RunAsync( + ["model", "set", "main", "my-ollama", "qwen3:30b", "--clear-modalities"], + _paths, output: _output); + + Assert.Equal(0, exitCode); + using var config = ReadConfigFile(_paths.NetclawConfigPath); + var main = ReadActiveModel(config, "Main"); + Assert.False(main.TryGetProperty("InputModalities", out _)); // cleared → runtime detection + } + + [Theory] + [InlineData("invalid modalities", "--input-modalities", "Vision")] + [InlineData("--input-modalities requires a value", "--input-modalities")] + [InlineData("unknown argument '--input-modalites'", "--input-modalites", "Text")] + [InlineData("invalid modalities", "--input-modalities", "3")] + [InlineData("cannot be combined", "--context-window", "32768", "--clear-context-window")] + public async Task Set_InvalidOptions_ReturnErrorWithoutWriting( + string expectedError, + params string[] options) + { + WriteConfig(ProvidersOnly()); + + var exitCode = await ModelCommand.RunAsync( + ["model", "set", "main", "my-ollama", "qwen3:30b", .. options], + _paths, output: _output); + + Assert.Equal(1, exitCode); + Assert.Contains(expectedError, _output.ToString()); + Assert.False(ReadConfigFile(_paths.NetclawConfigPath).RootElement.TryGetProperty("Models", out _)); + } + + [Fact] + public async Task Set_SameModelReSetWithContextWindow_PreservesExistingModalities() + { + WriteConfig(WithMainModalities("Text, Image")); + + // End-to-end: a --context-window-only re-set of the same model must keep the operator's + // modality override (#1127 / #5) — discovery/rebuild no longer wipes it. + var exitCode = await ModelCommand.RunAsync( + ["model", "set", "main", "my-ollama", "qwen3:30b", "--context-window", "65536"], + _paths, output: _output); + + Assert.Equal(0, exitCode); + using var config = ReadConfigFile(_paths.NetclawConfigPath); + var main = ReadActiveModel(config, "Main"); + Assert.Equal("Text, Image", main.GetProperty("InputModalities").GetString()); + Assert.Equal(65536, main.GetProperty("ContextWindow").GetInt32()); + } + + [Fact] + public async Task Set_ClearContextWindow_RemovesStoredClamp() + { + var config = ProvidersOnly(); + config["Models"] = new Dictionary + { + ["Main"] = new Dictionary + { + ["Provider"] = "my-ollama", + ["ModelId"] = "qwen3:30b", + ["ContextWindow"] = 32768 + } + }; + WriteConfig(config); + + var exitCode = await ModelCommand.RunAsync( + ["model", "set", "main", "my-ollama", "qwen3:30b", "--clear-context-window"], + _paths, output: _output); + + Assert.Equal(0, exitCode); + using var written = ReadConfigFile(_paths.NetclawConfigPath); + var main = ReadActiveModel(written, "Main"); + Assert.False(main.TryGetProperty("ContextWindow", out _)); // clamp removed → runtime detection + } + + [Fact] + public async Task Set_OAuthModelWithModalityOverride_StillProbesAndValidates() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["openai-codex"] = new Dictionary + { + ["Type"] = "openai", + ["AuthMethod"] = "OAuthDevice" + } + } + }); + _fakeProbe.NextResult = new ProviderProbeResult(true, null, + [ + new DiscoveredModel + { + ModelId = new Netclaw.Configuration.ModelId("gpt-new-codex"), + ContextWindowTokens = 512000, + InputModalities = ModelModality.Text | ModelModality.Image, + OutputModalities = ModelModality.Text, + } + ]); + + // A modality override no longer short-circuits the probe: the probe must still run to + // validate the model and discover the context window, while the operator's modality wins. + var exitCode = await ModelCommand.RunAsync( + ["model", "set", "main", "openai-codex", "gpt-new-codex", "--input-modalities", "Text"], + _paths, _fakeProbe, output: _output); + + Assert.Equal(0, exitCode); + Assert.Equal(1, _fakeProbe.ProbeCallCount); // probe ran despite the modality flag + using var config = ReadConfigFile(_paths.NetclawConfigPath); + var main = ReadActiveModel(config, "Main"); + Assert.Equal("Live", main.GetProperty("Provenance").GetString()); // resolved via probe + Assert.Equal(512000, main.GetProperty("ContextWindow").GetInt32()); // discovered window captured + Assert.Equal("Text", main.GetProperty("InputModalities").GetString());// operator override wins + } + + [Fact] + public async Task Set_OAuthModelWithModalityOverride_WhenModelNotReturned_ReturnsError() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["openai-codex"] = new Dictionary + { + ["Type"] = "openai", + ["AuthMethod"] = "OAuthDevice" + } + } + }); + _fakeProbe.NextResult = new ProviderProbeResult(true, null, + [ + new DiscoveredModel { ModelId = new Netclaw.Configuration.ModelId("gpt-other-codex") } + ]); + + // The modality flag must not let an unvalidated model slip through: the probe reports a + // different model, so the set must fail rather than write an unverified entry (#1610). + var exitCode = await ModelCommand.RunAsync( + ["model", "set", "main", "openai-codex", "gpt-new-codex", "--input-modalities", "Text"], + _paths, _fakeProbe, output: _output); + + Assert.Equal(1, exitCode); + Assert.Contains("was not returned", _output.ToString()); + Assert.False(ReadConfigFile(_paths.NetclawConfigPath).RootElement.TryGetProperty("Models", out _)); + } + + [Fact] + public async Task List_CorruptModalityConfig_ReturnsErrorWithoutCrashing() + { + // A config with an unparseable modality enum string must not crash `model list` with an + // unhandled JsonException, nor be silently reported as "no models configured" (#1610). + WriteConfig(WithMainEntry(new Dictionary + { + ["Provider"] = "my-ollama", + ["ModelId"] = "qwen3:30b", + ["ContextWindow"] = 32768, + ["InputModalities"] = "text_and_image" + })); + + var exitCode = await ModelCommand.RunAsync(["model", "list"], _paths, output: _output); + + Assert.Equal(1, exitCode); + Assert.Contains("could not be parsed", _output.ToString()); + } + + [Fact] + public async Task Set_CorruptModalityButValidWindow_PreservesWindowEndToEnd() + { + // End-to-end regression for the full `model set` path: a re-set over a corrupt entry that + // has a VALID ContextWindow must succeed and keep the window (repairing the bad modality), + // not crash in the downgrade-check load path before reaching the writer (#1610). + WriteConfig(WithMainEntry(new Dictionary + { + ["Provider"] = "my-ollama", + ["ModelId"] = "qwen3:30b", + ["ContextWindow"] = 32768, + ["InputModalities"] = "text_and_image" + })); + + var exitCode = await ModelCommand.RunAsync( + ["model", "set", "main", "my-ollama", "qwen3:30b"], _paths, output: _output); + + Assert.Equal(0, exitCode); + using var config = ReadConfigFile(_paths.NetclawConfigPath); + var main = ReadActiveModel(config, "Main"); + Assert.Equal(32768, main.GetProperty("ContextWindow").GetInt32()); // valid clamp preserved + Assert.False(main.TryGetProperty("InputModalities", out _)); // corrupt override dropped + } + + private static Dictionary WithMainEntry(Dictionary main) + { + var config = ProvidersOnly(); + config["Models"] = new Dictionary { ["Main"] = main }; + return config; + } + + private static JsonElement ReadActiveModel(JsonDocument config, string role) + { + var models = config.RootElement.GetProperty("Models"); + var definitionName = models.GetProperty("Roles").GetProperty(role).GetString()!; + return models.GetProperty("Definitions").GetProperty(definitionName); + } + + private static Dictionary ProvidersOnly() => new() + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-ollama"] = new Dictionary + { + ["Type"] = "ollama", + ["Endpoint"] = "http://localhost:11434" + } + } + }; + + private static Dictionary WithMainModalities(string inputModalities) + { + var config = ProvidersOnly(); + config["Models"] = new Dictionary + { + ["Main"] = new Dictionary + { + ["Provider"] = "my-ollama", + ["ModelId"] = "qwen3:30b", + ["InputModalities"] = inputModalities + } + }; + return config; + } + private void WriteConfig(Dictionary data) { File.WriteAllText(_paths.NetclawConfigPath, diff --git a/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs index 249954fb5..460d12dc0 100644 --- a/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs @@ -159,7 +159,7 @@ public async Task ConfirmAssignment_WritesCorrectConfig() // Verify config var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); - var main = config.RootElement.GetProperty("Models").GetProperty("Main"); + var main = ReadActiveModel(config, "Main"); Assert.Equal("my-ollama", main.GetProperty("Provider").GetString()); Assert.Equal("model-a", main.GetProperty("ModelId").GetString()); Assert.Equal("Live", main.GetProperty("Provenance").GetString()); @@ -205,7 +205,7 @@ public async Task ConfirmAssignment_DiscoveredModelWithMetadata_WritesMetadata() vm.ConfirmAssignment(); var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); - var main = config.RootElement.GetProperty("Models").GetProperty("Main"); + var main = ReadActiveModel(config, "Main"); Assert.Equal("Live", main.GetProperty("Provenance").GetString()); Assert.Equal(512000, main.GetProperty("ContextWindow").GetInt32()); Assert.Equal("Text, Image", main.GetProperty("InputModalities").GetString()); @@ -391,8 +391,9 @@ public void ClearRole_Fallback_RemovesFromConfig() var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); var models = config.RootElement.GetProperty("Models"); - Assert.True(models.TryGetProperty("Main", out _)); - Assert.False(models.TryGetProperty("Fallback", out _)); + var roles = models.GetProperty("Roles"); + Assert.True(roles.TryGetProperty("Main", out _)); + Assert.False(roles.TryGetProperty("Fallback", out _)); } [Fact] @@ -497,11 +498,45 @@ public void Refresh_FallsBackToTypeWhenNoRegistry() Assert.Equal("ollama", vm.Providers[0].DisplayName); } + [Fact] + public void Refresh_MissingNamedDefinition_SurfacesInvalidConfiguration() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Models"] = new Dictionary + { + ["Definitions"] = new Dictionary + { + ["known"] = new Dictionary + { + ["Provider"] = "my-ollama", + ["ModelId"] = "qwen3:30b" + } + }, + ["Roles"] = new Dictionary { ["Main"] = "missing" } + } + }); + + using var vm = CreateViewModel(); + vm.Refresh(); + + Assert.Null(vm.Models); + Assert.Contains("invalid", vm.StatusMessage.Value, StringComparison.OrdinalIgnoreCase); + } + private ModelManagerViewModel CreateViewModel() { return new ModelManagerViewModel(_paths, _fakeProbe); } + private static JsonElement ReadActiveModel(JsonDocument config, string role) + { + var models = config.RootElement.GetProperty("Models"); + var definitionName = models.GetProperty("Roles").GetProperty(role).GetString()!; + return models.GetProperty("Definitions").GetProperty(definitionName); + } + private void WriteConfig(Dictionary data) { File.WriteAllText(_paths.NetclawConfigPath, diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs index 24643d279..ab8d25070 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs @@ -29,6 +29,8 @@ public void BuildContribution_EnteredCredential_EmitsSensitiveSecretLeaf() Assert.Equal(SectionSecretActionKind.Set, action.Action); Assert.NotNull(action.Value); Assert.Equal("sk-test", action.Value.Value); + Assert.DoesNotContain(contribution.FieldActionsOrEmpty, field => + field.Path.StartsWith("Models", StringComparison.Ordinal)); } [Fact] diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs index eb7d3cb94..d02f56f42 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs @@ -83,7 +83,9 @@ public void BuildConfigDictionary_IncludesModelsSection() var config = builder.BuildConfigDictionary(); var models = (Dictionary)config["Models"]; - var main = (Dictionary)models["Main"]; + var roles = (Dictionary)models["Roles"]; + var definitions = (Dictionary)models["Definitions"]; + var main = (Dictionary)definitions[(string)roles["Main"]]; Assert.Equal("openai", main["Provider"]); Assert.Equal("gpt-4.1", main["ModelId"]); } diff --git a/src/Netclaw.Cli/Config/ConfigFileHelper.cs b/src/Netclaw.Cli/Config/ConfigFileHelper.cs index a3079917f..2fd3f3fed 100644 --- a/src/Netclaw.Cli/Config/ConfigFileHelper.cs +++ b/src/Netclaw.Cli/Config/ConfigFileHelper.cs @@ -159,7 +159,37 @@ internal static Dictionary GetOrCreateSection( /// Serialize a config dictionary and write it to disk, creating parent directories if needed. /// internal static void WriteConfigFile(string path, Dictionary data) - => AtomicFile.WriteAllText(path, JsonSerializer.Serialize(data, JsonDefaults.ConfigFile)); + { + PreserveLegacyModelsBackup(path, data); + AtomicFile.WriteAllText(path, JsonSerializer.Serialize(data, JsonDefaults.ConfigFile)); + } + + private static void PreserveLegacyModelsBackup(string path, Dictionary data) + { + if (!File.Exists(path) + || GetSectionOrNull(data, "Models") is not { } newModels + || !newModels.ContainsKey("Definitions")) + return; + + using var existing = JsonDocument.Parse(File.ReadAllText(path)); + if (!existing.RootElement.TryGetProperty("Models", out var oldModels) + || oldModels.ValueKind != JsonValueKind.Object + || !oldModels.TryGetProperty("Main", out _)) + return; + + var legacyEnvironmentOverride = ModelEntryWriter.FindLegacyEnvironmentOverride(); + if (legacyEnvironmentOverride is not null) + { + throw new InvalidOperationException( + $"Cannot migrate Models while legacy environment override '{legacyEnvironmentOverride}' is set. " + + "Move model overrides to NETCLAW_Models__Definitions____* and " + + "NETCLAW_Models__Roles__* first."); + } + + var backupPath = path + ".legacy-models.bak"; + if (!File.Exists(backupPath)) + File.Copy(path, backupPath); + } /// /// Serialize and write secrets.json using hardened permissions and encryption-at-rest. diff --git a/src/Netclaw.Cli/Config/ModelEntryWriter.cs b/src/Netclaw.Cli/Config/ModelEntryWriter.cs index dd0a22560..0c3f7e697 100644 --- a/src/Netclaw.Cli/Config/ModelEntryWriter.cs +++ b/src/Netclaw.Cli/Config/ModelEntryWriter.cs @@ -3,6 +3,8 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Cli.Json; using Netclaw.Configuration; namespace Netclaw.Cli.Config; @@ -16,6 +18,434 @@ namespace Netclaw.Cli.Config; /// internal static class ModelEntryWriter { + private static readonly string[] LegacyEnvironmentPrefixes = + [ + "NETCLAW_Models__Main__", + "NETCLAW_Models__Fallback__", + "NETCLAW_Models__Compaction__", + ]; + + internal static string? FindLegacyEnvironmentOverride() + => Environment.GetEnvironmentVariables().Keys + .OfType() + .FirstOrDefault(key => LegacyEnvironmentPrefixes.Any(prefix => + key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))); + + internal static bool MigrateLegacy(Dictionary modelsSection) + { + if (modelsSection.ContainsKey("Definitions") || modelsSection.ContainsKey("Roles")) + return false; + if (!modelsSection.Keys.Any(key => key is "Main" or "Fallback" or "Compaction")) + return false; + EnsureNamedShape(modelsSection); + return true; + } + + internal static bool ClearRole(Dictionary modelsSection, string roleKey) + { + var (_, roles) = EnsureNamedShape(modelsSection); + return roles.Remove(roleKey); + } + + /// + /// Write a role's model entry into non-destructively. + /// Two on-disk attributes are treated as operator-owned overrides that provider discovery + /// must never silently clobber on a same-(provider, modelId) re-set: + /// + /// + /// ContextWindow and the modalities are documented to "take precedence over + /// provider-reported capability detection". So the precedence for each is: + /// explicit operator input (this call) > existing stored value > probe. A fresh probe + /// tops up a first-time set or a model switch, but never overwrites a value already on disk + /// — that overwrite was the #1127 loss (a re-set wiped a hand-set modality) and its + /// context-window twin (#1610). + /// + /// + /// Because the stored value now wins, the operator needs a way to *change* it: an explicit + /// replaces it and + /// removes it (falling back to runtime detection). Switching a role to a different + /// model does not carry the old model's attributes over — they belonged to that model. + /// + /// + /// Operator intent for the context window (set / clear / unset). --context-window sets + /// it (wins over everything), --clear-context-window removes the clamp so detection + /// resolves it, and the picker — which has no such input — passes . + /// + /// Operator intent for input modalities (set / clear / unset). + /// Operator intent for output modalities (set / clear / unset). + /// + /// The probe result, if a probe ran. Its context window and modalities seed a first-time set + /// or a model switch only; an existing stored value wins over them. + /// + internal static void WriteRole( + Dictionary modelsSection, + string roleKey, + string provider, + string? modelId, + ModelDiscoverySource? provenance, + ValueOverride contextWindow, + ValueOverride inputModalities, + ValueOverride outputModalities, + DiscoveredModel? discovered) + { + var (definitions, roles) = EnsureNamedShape(modelsSection, roleKey); + var definitionName = FindDefinition(definitions, provider, modelId) + ?? CreateDefinitionName(definitions, provider, modelId); + var existing = ReadSameModelEntry(definitions, definitionName, provider, modelId); + + // Provenance records how the model ID was resolved, not how the entry was last edited. A + // same-model re-set that did not itself freshly resolve the ID — no probe (the caller + // passes Manual) or a probe that failed (Defaults) — must not downgrade a previously + // discovered origin. Only a fresh successful discovery (Live) re-stamps it (#1610). + if (existing?.Provenance is { } priorProvenance && provenance != ModelDiscoverySource.Live) + provenance = priorProvenance; + + // Precedence for every operator-owned attribute: explicit input > existing stored value + // > probe. Collapsing the explicit and discovered values in the caller defeated + // preservation, because the probe/picker paths always pass a discovered value, so the + // stored value was overwritten on every re-selection. + var sameModelEntry = existing is not null; + var resolvedWindow = ResolveContextWindow( + contextWindow, sameModelEntry, existing?.ContextWindow, discovered?.ContextWindowTokens); + var resolvedInput = ResolveModality(inputModalities, sameModelEntry, existing?.InputModalities, discovered?.InputModalities); + var resolvedOutput = ResolveModality(outputModalities, sameModelEntry, existing?.OutputModalities, discovered?.OutputModalities); + + definitions[definitionName] = BuildModelEntry( + provider, modelId, provenance, resolvedWindow, resolvedInput, resolvedOutput); + roles[roleKey] = definitionName; + } + + private static (Dictionary Definitions, Dictionary Roles) + EnsureNamedShape(Dictionary modelsSection, string? overwrittenRole = null) + { + var hasNamed = modelsSection.ContainsKey("Definitions") || modelsSection.ContainsKey("Roles"); + var hasLegacy = modelsSection.Keys.Any(key => + key is "Main" or "Fallback" or "Compaction"); + + if (hasNamed && hasLegacy) + throw new InvalidOperationException("Models configuration mixes legacy roles with Definitions/Roles."); + + if (hasNamed) + { + if (!modelsSection.ContainsKey("Definitions") || !modelsSection.ContainsKey("Roles")) + throw new InvalidOperationException("Named Models configuration requires Definitions and Roles."); + + return (GetDictionary(modelsSection, "Definitions"), GetDictionary(modelsSection, "Roles")); + } + + var definitions = new Dictionary(StringComparer.OrdinalIgnoreCase); + var roles = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var role in new[] { "Main", "Fallback", "Compaction" }) + { + if (!modelsSection.TryGetValue(role, out var raw) || raw is null) + continue; + + if (!RawContainsProperty(raw, nameof(ModelReference.Provider)) + || !RawContainsProperty(raw, nameof(ModelReference.ModelId))) + { + if (string.Equals(role, overwrittenRole, StringComparison.OrdinalIgnoreCase)) + continue; + throw new InvalidOperationException( + $"Models:{role} must explicitly declare Provider and ModelId before migration."); + } + + ModelReference model; + try + { + model = ConfigFileHelper.DeserializeSection(raw) + ?? throw new InvalidOperationException($"Models:{role} could not be parsed."); + } + catch (JsonException) + { + model = ReadLegacyIdentity(raw) + ?? throw new InvalidOperationException($"Models:{role} could not be repaired."); + } + var existingName = FindDefinition(definitions, model.Provider, model.ModelId); + if (existingName is not null) + { + var existing = ConfigFileHelper.DeserializeSection(definitions[existingName])!; + if (!Equivalent(existing, model)) + { + throw new InvalidOperationException( + $"Legacy model roles conflict for {model.Provider}/{model.ModelId}; " + + $"align their metadata before migration."); + } + + roles[role] = existingName; + continue; + } + + var name = CreateDefinitionName(definitions, model.Provider, model.ModelId); + definitions[name] = BuildModelEntry( + model.Provider, model.ModelId, model.Provenance, model.ContextWindow, + model.InputModalities, model.OutputModalities); + roles[role] = name; + } + + modelsSection.Clear(); + modelsSection["Definitions"] = definitions; + modelsSection["Roles"] = roles; + return (definitions, roles); + } + + private static ModelReference? ReadLegacyIdentity(object raw) + { + var json = raw is JsonElement element + ? element.GetRawText() + : JsonSerializer.Serialize(raw, JsonDefaults.ConfigFile); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !TryGetString(root, nameof(ModelReference.Provider), out var provider) + || !TryGetString(root, nameof(ModelReference.ModelId), out var modelId)) + return null; + + var model = new ModelReference { Provider = provider, ModelId = modelId }; + if (TryGetInt32(root, nameof(ModelReference.ContextWindow), out var contextWindow)) + model.ContextWindow = contextWindow; + return model; + } + + private static Dictionary GetDictionary(Dictionary parent, string key) + { + var raw = parent[key]; + if (raw is Dictionary dictionary) + return dictionary; + if (raw is JsonElement { ValueKind: JsonValueKind.Object } element) + { + dictionary = JsonSerializer.Deserialize>(element.GetRawText()) ?? []; + parent[key] = dictionary; + return dictionary; + } + + throw new InvalidOperationException($"Models:{key} must be an object."); + } + + private static string? FindDefinition( + Dictionary definitions, string provider, string? modelId) + { + foreach (var (name, raw) in definitions) + { + ModelReference? model; + try + { + model = ConfigFileHelper.DeserializeSection(raw); + } + catch (JsonException) + { + model = ReadLegacyIdentity(raw); + } + + if (model is not null + && string.Equals(model.Provider, provider, StringComparison.OrdinalIgnoreCase) + && string.Equals(model.ModelId, modelId, StringComparison.OrdinalIgnoreCase)) + return name; + } + + return null; + } + + private static string CreateDefinitionName( + Dictionary definitions, string provider, string? modelId) + { + var raw = $"{provider}-{modelId}"; + var stem = string.Join('-', raw.ToLowerInvariant() + .Split(['/', ':', '.', '_', ' '], StringSplitOptions.RemoveEmptyEntries)) + .Trim('-'); + if (string.IsNullOrWhiteSpace(stem)) + stem = "model"; + + var candidate = stem; + for (var suffix = 2; definitions.ContainsKey(candidate); suffix++) + candidate = $"{stem}-{suffix}"; + return candidate; + } + + private static bool Equivalent(ModelReference left, ModelReference right) + => string.Equals(left.Provider, right.Provider, StringComparison.OrdinalIgnoreCase) + && string.Equals(left.ModelId, right.ModelId, StringComparison.OrdinalIgnoreCase) + && left.ContextWindow == right.ContextWindow + && left.Provenance == right.Provenance + && left.InputModalities == right.InputModalities + && left.OutputModalities == right.OutputModalities; + + /// + /// Resolves the modality actually written. An explicit operator set or clear wins outright + /// (the operator is the authority on a manual override). Otherwise, when the same model already + /// has an entry on disk, that entry is honored verbatim — including a deliberately-cleared + /// (absent) modality, so a later probe cannot silently resurrect an override the operator + /// removed with --clear-modalities (#1610). Provider discovery only seeds a genuine gap: + /// a first-time set or a switch to a different model, where no entry exists yet. + /// + private static ModelModality? ResolveModality( + ValueOverride @override, bool sameModelEntryExists, + ModelModality? existing, ModelModality? discovered) + => @override.Supplied + ? @override.Value // Set(value) → value; Clear → null (key omitted downstream) + : sameModelEntryExists ? existing // same model on disk: honor it, incl. a cleared (null) value + : discovered; // first set / model switch: seed from discovery + + /// + /// Resolves the context window actually written. Mirrors for the + /// explicit cases: --context-window (Set) or --clear-context-window (Clear) wins, + /// otherwise an existing definition is honored verbatim, including absence. Discovery seeds + /// only a new definition. This keeps manual JSON edits and explicit clears stable without a + /// hidden tombstone representation. + /// + private static int? ResolveContextWindow( + ValueOverride @override, bool sameModelEntryExists, int? existing, int? discovered) + => @override.Supplied + ? @override.Value // Set(n) → n; Clear → null (drop the clamp → runtime detects) + : sameModelEntryExists ? existing : discovered; + + /// + /// The role's current entry, but only when it already references the same + /// (provider, modelId); null when the role is unset or points at a different + /// model (whose attributes must not carry over to the newly-set one). + /// + private static ModelReference? ReadSameModelEntry( + Dictionary modelsSection, string roleKey, string provider, string? modelId) + { + if (!modelsSection.TryGetValue(roleKey, out var raw) || raw is null) + return null; + + // ModelReference defaults Provider/ModelId to the stock local-ollama model, so an + // entry that OMITS either key deserializes to that default and would false-match a + // re-set of the stock model — carrying stray attributes onto a model the entry never + // actually named. Only treat the entry as the same model when it explicitly declares + // both keys (#1610). + if (!RawContainsProperty(raw, nameof(ModelReference.Provider)) + || !RawContainsProperty(raw, nameof(ModelReference.ModelId))) + return null; + + // A legacy or hand-corrupted entry (e.g. an unrecognized modality enum string, or a + // shape that predates a schema change) must not abort `model set`: we are about to + // overwrite this role anyway, and before the non-destructive rewrite existed the + // command simply clobbered it. + ModelReference? existing; + try + { + existing = ConfigFileHelper.DeserializeSection(raw); + } + catch (JsonException) + { + // Strict deserialization failed — in practice a stale/unknown modality or provenance + // enum string, which JsonStringEnumConverter rejects by throwing. Discarding the whole + // entry here would silently clobber a still-valid operator-owned ContextWindow (#1610), + // so recover the throw-proof fields and drop only the unparseable overrides. + return ReadResilient(raw, provider, modelId); + } + + if (existing is null) + return null; + + return string.Equals(existing.Provider, provider, StringComparison.OrdinalIgnoreCase) + && string.Equals(existing.ModelId, modelId, StringComparison.OrdinalIgnoreCase) + ? existing + : null; + } + + /// + /// Recovers the preservation-worthy fields from an entry that failed strict deserialization. + /// Provider, ModelId and ContextWindow are plain strings/int that never throw, so they are read + /// directly; the modality/provenance overrides — the fields that failed to parse — are dropped + /// (left null) because a value we could not read must not be preserved. Returns null when the + /// recovered entry names a different model (its attributes must not carry over) or is not a + /// JSON object. + /// + private static ModelReference? ReadResilient(object raw, string provider, string? modelId) + { + var json = raw is JsonElement element + ? element.GetRawText() + : JsonSerializer.Serialize(raw, JsonDefaults.ConfigFile); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + return null; + + // Confirm the poisoned entry still names the model being set before preserving anything. + if (!TryGetString(root, nameof(ModelReference.Provider), out var storedProvider) + || !TryGetString(root, nameof(ModelReference.ModelId), out var storedModelId) + || !string.Equals(storedProvider, provider, StringComparison.OrdinalIgnoreCase) + || !string.Equals(storedModelId, modelId, StringComparison.OrdinalIgnoreCase)) + return null; + + var recovered = new ModelReference { Provider = storedProvider, ModelId = storedModelId }; + if (TryGetInt32(root, nameof(ModelReference.ContextWindow), out var window)) + recovered.ContextWindow = window; + + return recovered; + } + + private static bool TryGetString(JsonElement root, string name, out string value) + { + foreach (var property in root.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase) + && property.Value.ValueKind == JsonValueKind.String) + { + value = property.Value.GetString()!; + return true; + } + } + + value = string.Empty; + return false; + } + + private static bool TryGetInt32(JsonElement root, string name, out int value) + { + foreach (var property in root.EnumerateObject()) + { + if (!string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + continue; + + // Web-default reads coerce numeric strings, so accept both a JSON number and a + // stringified integer to match what the strict path would have parsed. + if (property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt32(out value)) + return true; + if (property.Value.ValueKind == JsonValueKind.String + && int.TryParse(property.Value.GetString(), out value)) + return true; + } + + value = 0; + return false; + } + + /// + /// Whether the raw config value explicitly declares . The + /// value is either a (loaded from disk) or an in-memory + /// dictionary (rewritten this run) — the two shapes + /// accepts. The match is case-insensitive to mirror the case-insensitive deserialization. + /// + private static bool RawContainsProperty(object raw, string propertyName) + { + switch (raw) + { + case JsonElement { ValueKind: JsonValueKind.Object } element: + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + case IDictionary dict: + foreach (var key in dict.Keys) + { + if (string.Equals(key, propertyName, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + default: + return false; + } + } + /// /// Builds the dictionary written under Models[role]. /// @@ -55,3 +485,25 @@ internal static Dictionary BuildModelEntry( return entry; } } + +/// +/// An operator's intent for an overridable, operator-owned model attribute on model set — +/// a modality set () or the context window (). A plain +/// T? cannot express it, because two of the three states both resolve to null yet behave +/// oppositely: "not supplied" must preserve any existing override, while "clear" must win over it. +/// The tri-state is: (leave it to the stored value / discovery), +/// (replace with an explicit value), and (remove the override +/// so runtime detection resolves it). +/// +internal readonly record struct ValueOverride(bool Supplied, T? Value) + where T : struct +{ + /// Operator said nothing — preserve the stored value, else fall back to discovery. + internal static ValueOverride Unset => default; + + /// Operator asked to remove the override so runtime capability detection resolves it. + internal static ValueOverride Clear => new(true, null); + + /// Operator supplied an explicit value that replaces whatever is stored. + internal static ValueOverride Set(T value) => new(true, value); +} diff --git a/src/Netclaw.Cli/Doctor/ChatClientDoctorCheck.cs b/src/Netclaw.Cli/Doctor/ChatClientDoctorCheck.cs index 9f07bd2e8..492ed76c6 100644 --- a/src/Netclaw.Cli/Doctor/ChatClientDoctorCheck.cs +++ b/src/Netclaw.Cli/Doctor/ChatClientDoctorCheck.cs @@ -61,7 +61,7 @@ public Task RunAsync(CancellationToken cancellationToken = de try { providers = ProviderConfigurationLoader.Load(_configuration.GetSection("Providers")); - models = _configuration.GetSection("Models").Get() ?? new ModelSelection(); + models = ModelConfigurationResolver.Resolve(_configuration).Selection; // File present: read explicit provider types from the file (matches // historical behavior). Env-only: derive them from the bound // configuration, exactly like the daemon does at startup. diff --git a/src/Netclaw.Cli/Doctor/ContextWindowDoctorCheck.cs b/src/Netclaw.Cli/Doctor/ContextWindowDoctorCheck.cs index b88770310..d68bfe68c 100644 --- a/src/Netclaw.Cli/Doctor/ContextWindowDoctorCheck.cs +++ b/src/Netclaw.Cli/Doctor/ContextWindowDoctorCheck.cs @@ -43,8 +43,8 @@ public async Task RunAsync(CancellationToken cancellationToke if (root is null) return DoctorCheckResult.Pass("Context Window", "No config file to check."); - var models = root["Models"] as JsonObject; - var main = models?["Main"] as JsonObject; + var resolvedModels = ModelConfigurationResolver.Resolve(_configuration).Selection; + var main = resolvedModels.Main; var runtimeValidation = ValidateRuntimeConfiguration(root); if (runtimeValidation.Status != ProviderRuntimeStatus.Valid) @@ -55,7 +55,7 @@ public async Task RunAsync(CancellationToken cancellationToke BuildInferenceRemediation(runtimeValidation.AvailableProviders)); } - if (main is null) + if (string.IsNullOrWhiteSpace(main.Provider) || string.IsNullOrWhiteSpace(main.ModelId)) { return DoctorCheckResult.Warning( "Context Window", @@ -63,15 +63,12 @@ public async Task RunAsync(CancellationToken cancellationToke "Run `netclaw init` to configure a provider and main model, or add Models.Main to netclaw.json."); } - var contextWindow = main["ContextWindow"]; - if (contextWindow is null) + if (main.ContextWindow is null) { - var modelId = _configuration.GetSection("Models:Main:ModelId").Value ?? "unknown"; - var providerName = _configuration.GetSection("Models:Main:Provider").Value ?? "unknown"; - return await ResolveEffectiveContextWindowAsync(modelId, providerName, cancellationToken); + return await ResolveEffectiveContextWindowAsync(main.ModelId, main.Provider, cancellationToken); } - if (TryGetInt32(contextWindow, out var cw) && cw > 0) + if (main.ContextWindow is > 0 and var cw) { // Runtime (ContextWindowResolution.ResolveRuntimeAsync) prefers the // daemon's live context window over the pinned config when the daemon @@ -100,8 +97,7 @@ public async Task RunAsync(CancellationToken cancellationToke private ProviderRuntimeValidation ValidateRuntimeConfiguration(JsonObject root) { var providers = ProviderConfigurationLoader.Load(_configuration.GetSection("Providers")); - var models = _configuration.GetSection("Models") - .Get() ?? new ModelSelection(); + var models = ModelConfigurationResolver.Resolve(_configuration).Selection; return ProviderRuntimeValidation.Evaluate( providers, diff --git a/src/Netclaw.Cli/Doctor/DoctorFixService.cs b/src/Netclaw.Cli/Doctor/DoctorFixService.cs index c77225c88..57e5e19ce 100644 --- a/src/Netclaw.Cli/Doctor/DoctorFixService.cs +++ b/src/Netclaw.Cli/Doctor/DoctorFixService.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using System.Text.Json.Nodes; +using System.Text.Json; using Json.Schema; using Netclaw.Cli.Config; using Netclaw.Cli.Daemon; @@ -64,6 +65,25 @@ public Task BuildPlanAsync(CancellationToken cancellationToken = var appliedFixes = new List(); + if (obj["Models"] is JsonObject modelsNode) + { + var legacyEnvironmentOverride = ModelEntryWriter.FindLegacyEnvironmentOverride(); + if (legacyEnvironmentOverride is not null) + { + throw new InvalidOperationException( + $"Cannot migrate Models while legacy environment override '{legacyEnvironmentOverride}' is set. " + + "Move model overrides to NETCLAW_Models__Definitions____* and " + + "NETCLAW_Models__Roles__* first."); + } + + var models = JsonSerializer.Deserialize>(modelsNode.ToJsonString())!; + if (ModelEntryWriter.MigrateLegacy(models)) + { + obj["Models"] = JsonNode.Parse(JsonSerializer.Serialize(models, JsonDefaults.ConfigFile)); + appliedFixes.Add("named model definitions"); + } + } + // --- Manual fixes (not derivable from schema alone) --- if (obj["configVersion"] is null) @@ -256,7 +276,7 @@ private void TryAddDaemonPathEnvironmentFix(List fixes) UpdatedText: updated)); } - public async Task ApplyAsync(DoctorFixPlan plan, CancellationToken cancellationToken = default) + public Task ApplyAsync(DoctorFixPlan plan, CancellationToken cancellationToken = default) { foreach (var fix in plan.Fixes) { @@ -267,8 +287,19 @@ public async Task ApplyAsync(DoctorFixPlan plan, CancellationToken cancellationT if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - await File.WriteAllTextAsync(fix.FilePath, fix.UpdatedText, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (fix.Description.Contains("named model definitions", StringComparison.Ordinal) + && File.Exists(fix.FilePath)) + { + var backupPath = fix.FilePath + ".legacy-models.bak"; + if (!File.Exists(backupPath)) + File.Copy(fix.FilePath, backupPath); + } + + AtomicFile.WriteAllText(fix.FilePath, fix.UpdatedText); } + + return Task.CompletedTask; } } diff --git a/src/Netclaw.Cli/Model/ModelCommand.cs b/src/Netclaw.Cli/Model/ModelCommand.cs index 768b13efa..44021cc61 100644 --- a/src/Netclaw.Cli/Model/ModelCommand.cs +++ b/src/Netclaw.Cli/Model/ModelCommand.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using System.Text.Json; +using Microsoft.Extensions.Configuration; using Netclaw.Cli.Config; using Netclaw.Cli.Json; using Netclaw.Cli.Provider; @@ -37,7 +38,15 @@ public static async Task RunAsync( private static int RunList(NetclawPaths paths, TextWriter writer) { - var models = LoadModelSelection(paths); + if (!TryLoadModelSelection(paths, out var models)) + { + // Config present but unparseable — surface it rather than showing a corrupt config as + // "no models configured", which would send the operator down the wrong recovery path. + writer.WriteLine("Error: model configuration could not be parsed."); + writer.WriteLine("Run `netclaw doctor` to diagnose, or `netclaw doctor --fix` to repair it."); + return 1; + } + if (models is null) { writer.WriteLine("No models configured."); @@ -73,10 +82,13 @@ private static void WriteModelRow(string role, ModelReference model, TextWriter private static async Task RunSetAsync( string[] args, NetclawPaths paths, IProviderProbe? probe, TextWriter writer) { - // Parse: netclaw model set [--context-window ] + // Parse: netclaw model set + // [--context-window ] [--input-modalities ] + // [--output-modalities ] [--clear-modalities] if (args.Length < 5) { - writer.WriteLine("Usage: netclaw model set [--context-window ]"); + writer.WriteLine("Usage: netclaw model set [--context-window ] [--clear-context-window]"); + writer.WriteLine(" [--input-modalities ] [--output-modalities ] [--clear-modalities]"); writer.WriteLine(); writer.WriteLine("Roles: main, fallback, compaction"); return 1; @@ -86,21 +98,96 @@ private static async Task RunSetAsync( var providerName = args[3]; var modelId = args[4]; int? contextWindow = null; + var clearContextWindow = false; + ModelModality? inputModalitySet = null; + ModelModality? outputModalitySet = null; + var clearModalities = false; for (var i = 5; i < args.Length; i++) { - if (args[i] is "--context-window" && i + 1 < args.Length) + switch (args[i]) { - if (!int.TryParse(args[++i], out var cw) || cw <= 0) - { - writer.WriteLine("Error: --context-window must be a positive integer."); + case "--context-window": + if (!TryTakeValue(args, ref i, out var cwRaw)) + { + writer.WriteLine("Error: --context-window requires a value."); + return 1; + } + + if (!int.TryParse(cwRaw, out var cw) || cw <= 0) + { + writer.WriteLine("Error: --context-window must be a positive integer."); + return 1; + } + + contextWindow = cw; + break; + case "--clear-context-window": + clearContextWindow = true; + break; + case "--input-modalities": + if (!TryTakeValue(args, ref i, out var inputRaw)) + { + writer.WriteLine("Error: --input-modalities requires a value."); + return 1; + } + + if (!TryParseModalities(inputRaw, out var input, out var inputError)) + { + writer.WriteLine(inputError); + return 1; + } + + inputModalitySet = input; + break; + case "--output-modalities": + if (!TryTakeValue(args, ref i, out var outputRaw)) + { + writer.WriteLine("Error: --output-modalities requires a value."); + return 1; + } + + if (!TryParseModalities(outputRaw, out var outputModality, out var outputError)) + { + writer.WriteLine(outputError); + return 1; + } + + outputModalitySet = outputModality; + break; + case "--clear-modalities": + clearModalities = true; + break; + default: + // Fail loudly on a stray token: a trailing flag with a missing value, a + // mistyped flag name, or an unexpected positional would otherwise be silently + // ignored while the command still reported success. + writer.WriteLine($"Error: unknown argument '{args[i]}'."); return 1; - } - - contextWindow = cw; } } + if (contextWindow.HasValue && clearContextWindow) + { + writer.WriteLine("Error: --context-window and --clear-context-window cannot be combined."); + return 1; + } + + // An explicit --context-window sets the clamp; --clear-context-window removes it so + // detection resolves it; neither leaves the stored value / discovery to decide. + var contextWindowOverride = contextWindow is { } cwv + ? ValueOverride.Set(cwv) + : clearContextWindow ? ValueOverride.Clear : ValueOverride.Unset; + + // An explicit --input/--output-modalities set wins over --clear-modalities (regardless of + // arg order); --clear-modalities applies to whichever side was not explicitly set. + var inputOverride = inputModalitySet is { } iv + ? ValueOverride.Set(iv) + : clearModalities ? ValueOverride.Clear : ValueOverride.Unset; + var outputOverride = outputModalitySet is { } ov + ? ValueOverride.Set(ov) + : clearModalities ? ValueOverride.Clear : ValueOverride.Unset; + // Validate role var roleKey = role switch { @@ -128,7 +215,9 @@ private static async Task RunSetAsync( return 1; } - // Check for context window downgrade + // Check for context window downgrade. LoadModelSelection degrades a corrupt current config + // to null (this re-set is about to overwrite/repair it), so a bad existing entry skips the + // advisory warning instead of aborting the repair. var currentModels = LoadModelSelection(paths); if (roleKey == "Main" && currentModels?.Main.ContextWindow is > 0 && contextWindow.HasValue) { @@ -139,9 +228,17 @@ private static async Task RunSetAsync( } } + // Only an explicit --context-window short-circuits the probe: it supplies the one datum the + // probe would discover, so it is the documented "configure this model manually" escape + // hatch. A modality override must NOT skip the probe — the probe also validates the model + // exists and discovers the context window, and WriteRole already lets an explicit modality + // override win over any discovered value, so it is safe to keep probing. --clear-context-window + // likewise keeps the probe: "re-detect" is exactly what the probe does. + var manualMetadataSupplied = contextWindow.HasValue; + DiscoveredModel? discoveredModel = null; var provenance = ModelDiscoverySource.Manual; - if (contextWindow is null && ShouldProbeForMetadata(providerEntry)) + if (!manualMetadataSupplied && ShouldProbeForMetadata(providerEntry)) { probe ??= ProviderCommand.CreateDefaultRegistry(); ProviderProbeResult probeResult; @@ -170,15 +267,19 @@ private static async Task RunSetAsync( var (config, _) = ConfigFileHelper.LoadConfigFiles(paths); var modelsSection = ConfigFileHelper.GetOrCreateSection(config, "Models"); - var modelEntry = ModelEntryWriter.BuildModelEntry( + // Definitions own model metadata, so role switches never destroy another model's + // operator-owned overrides. Discovery seeds only a new definition; explicit input edits + // an existing definition and absence remains runtime detection (#1127, #1610). + ModelEntryWriter.WriteRole( + modelsSection, + roleKey, providerName, modelId, provenance, - contextWindow ?? discoveredModel?.ContextWindowTokens, - discoveredModel?.InputModalities, - discoveredModel?.OutputModalities); - - modelsSection[roleKey] = modelEntry; + contextWindowOverride, + inputOverride, + outputOverride, + discoveredModel); ConfigFileHelper.WriteConfigFile(paths.NetclawConfigPath, config); writer.WriteLine($"Set {role} model to {providerName}/{modelId}"); @@ -189,6 +290,59 @@ private static bool ShouldProbeForMetadata(ProviderEntry entry) => string.Equals(entry.Type, "openai", StringComparison.OrdinalIgnoreCase) && entry.AuthMethod is AuthMethod.OAuthDevice or AuthMethod.OAuthPkce; + /// + /// Parses a --input/output-modalities value: a comma-separated list of + /// flags (e.g. Text, "Text, Image"). Rejects + /// None and any unknown flag so only schema-valid overrides reach config — a model + /// with no modalities is meaningless, and --clear-modalities is the way to remove one. + /// + private static bool TryParseModalities(string value, out ModelModality modalities, out string error) + { + const ModelModality all = ModelModality.Text | ModelModality.Image | ModelModality.Audio | ModelModality.Video; + modalities = default; + error = $"Error: invalid modalities '{value}'. Use a comma-separated list of: Text, Image, Audio, Video " + + "(or --clear-modalities to remove the override)."; + + var result = ModelModality.None; + foreach (var token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + // Enum.TryParse also accepts the underlying integer ("3" → Text|Image), but the contract + // advertised in the help text and error message is named flags only. Enum.IsDefined + // rejects a numeric token because its parsed value is not a single declared member, so a + // mistyped or scripted number is not silently coerced into a modality set. + if (!Enum.TryParse(token, ignoreCase: true, out ModelModality parsed) + || !Enum.IsDefined(parsed) + || parsed == ModelModality.None) + return false; + + result |= parsed; + } + + if (result == ModelModality.None || (result & ~all) != 0) + return false; + + modalities = result; + error = string.Empty; + return true; + } + + /// + /// Consumes the value token following a value-requiring flag, advancing . + /// Returns false when the flag is the final token (its value is missing) so the caller can + /// reject it rather than silently dropping the flag. + /// + private static bool TryTakeValue(string[] args, ref int i, out string value) + { + if (i + 1 < args.Length) + { + value = args[++i]; + return true; + } + + value = string.Empty; + return false; + } + /// /// The endpoint the probe will actually hit, for display. When a provider has no /// explicit endpoint we surface the descriptor's default (e.g. a self-hosted @@ -305,13 +459,29 @@ private static int RunClear(string[] args, NetclawPaths paths, TextWriter writer var (config, _) = ConfigFileHelper.LoadConfigFiles(paths); var modelsSection = ConfigFileHelper.GetSectionOrNull(config, "Models"); - if (modelsSection is null || !modelsSection.ContainsKey(roleKey)) + if (modelsSection is null) + { + writer.WriteLine($"Role '{role}' is not configured."); + return 0; + } + + bool removed; + try + { + removed = ModelEntryWriter.ClearRole(modelsSection, roleKey); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + writer.WriteLine($"Error: {ex.Message}"); + return 1; + } + + if (!removed) { writer.WriteLine($"Role '{role}' is not configured."); return 0; } - modelsSection.Remove(roleKey); ConfigFileHelper.WriteConfigFile(paths.NetclawConfigPath, config); writer.WriteLine($"Cleared {role} model role."); @@ -319,18 +489,44 @@ private static int RunClear(string[] args, NetclawPaths paths, TextWriter writer } /// - /// Load model selection from config file. + /// Loads the model selection, or null when no config / Models section exists OR the config is + /// present but unparseable (e.g. a legacy or hand-edited entry with an unrecognized modality + /// enum string, which the strict enum-aware deserializer rejects by throwing). The model + /// commands are the repair surface for exactly such configs, so they must degrade rather than + /// abort with an unhandled JsonException. Callers that must distinguish "absent" from "corrupt" + /// — to fail loudly instead of showing a corrupt config as empty — use + /// . /// internal static ModelSelection? LoadModelSelection(NetclawPaths paths) + => TryLoadModelSelection(paths, out var models) ? models : null; + + /// + /// Attempts to load the model selection. Returns false when the config file is present but its + /// Models section cannot be parsed, so the caller can report the corruption rather than silently + /// treating it as "no models". Returns true (with null ) when no config + /// file or Models section exists. + /// + internal static bool TryLoadModelSelection(NetclawPaths paths, out ModelSelection? models) { + models = null; if (!File.Exists(paths.NetclawConfigPath)) - return null; + return true; - using var doc = JsonDocument.Parse(File.ReadAllText(paths.NetclawConfigPath)); - if (!doc.RootElement.TryGetProperty("Models", out var modelsElement)) - return null; - - return JsonSerializer.Deserialize(modelsElement.GetRawText(), JsonDefaults.EnumAware); + try + { + var configuration = new ConfigurationBuilder() + .AddJsonFile(paths.NetclawConfigPath, optional: false, reloadOnChange: false) + .Build(); + if (!configuration.GetSection("Models").Exists()) + return true; + + models = ModelConfigurationResolver.Resolve(configuration).Selection; + return true; + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + return false; + } } private static int WriteHelp(TextWriter writer) @@ -349,11 +545,21 @@ private static int WriteHelp(TextWriter writer) writer.WriteLine(); writer.WriteLine("Options for 'set':"); writer.WriteLine(" --context-window Override context window size"); + writer.WriteLine(" --clear-context-window Remove the context-window override (re-detect from provider)"); + writer.WriteLine(" --input-modalities Override input modalities, e.g. \"Text, Image\""); + writer.WriteLine(" --output-modalities Override output modalities, e.g. \"Text\""); + writer.WriteLine(" --clear-modalities Remove modality overrides (fall back to runtime detection)"); + writer.WriteLine(); + writer.WriteLine(" Overrides live on named model definitions and survive role switches;"); + writer.WriteLine(" discovery never rewrites a definition. Use the set or matching clear flag to change it."); writer.WriteLine(); writer.WriteLine("Examples:"); writer.WriteLine(" netclaw model list"); writer.WriteLine(" netclaw model discover my-ollama"); writer.WriteLine(" netclaw model set main my-ollama qwen3:30b --context-window 32768"); + writer.WriteLine(" netclaw model set main my-openai gpt-x --clear-context-window"); + writer.WriteLine(" netclaw model set main my-vllm qwen-vl --input-modalities \"Text, Image\""); + writer.WriteLine(" netclaw model set main my-ollama qwen3:30b --clear-modalities"); writer.WriteLine(" netclaw model clear fallback"); return 0; } diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index a00234bf9..f5b953b37 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -2001,8 +2001,7 @@ static void ConfigureCliChatServices(IServiceCollection services, IConfiguration static ModelCapabilities BuildModelCapabilities(IConfiguration configuration, DaemonApi daemonApi) { var providers = ProviderConfigurationLoader.Load(configuration.GetSection("Providers")); - var models = configuration.GetSection("Models") - .Get() ?? new ModelSelection(); + var models = ModelConfigurationResolver.Resolve(configuration).Selection; var validation = ProviderRuntimeValidation.Evaluate( providers, models, diff --git a/src/Netclaw.Cli/Provider/ProviderRenamer.cs b/src/Netclaw.Cli/Provider/ProviderRenamer.cs index 07741eb22..ed0620763 100644 --- a/src/Netclaw.Cli/Provider/ProviderRenamer.cs +++ b/src/Netclaw.Cli/Provider/ProviderRenamer.cs @@ -96,6 +96,27 @@ private static List CascadeRenameModelRoles( var models = ConfigFileHelper.GetSectionOrNull(config, "Models"); if (models is null) return reassigned; + if (models.ContainsKey("Definitions")) + { + var definitions = ConfigFileHelper.GetSectionOrNull(models, "Definitions") + ?? throw new InvalidOperationException("Models:Definitions must be an object."); + foreach (var definitionName in definitions.Keys.ToList()) + { + var definition = ConfigFileHelper.GetSectionOrNull(definitions, definitionName); + if (definition is null || !definition.TryGetValue("Provider", out var providerValue)) + continue; + var current = providerValue is JsonElement element + ? element.GetString() + : providerValue as string; + if (!string.Equals(current, oldName, StringComparison.OrdinalIgnoreCase)) + continue; + definition["Provider"] = newName; + reassigned.Add(definitionName); + } + + return reassigned; + } + foreach (var roleName in ModelRoleNames) { var role = ConfigFileHelper.GetSectionOrNull(models, roleName); diff --git a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs index 49a9a7f09..4a5410b8e 100644 --- a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs @@ -95,7 +95,15 @@ public override void OnActivated() public void Refresh() { - Models = Model.ModelCommand.LoadModelSelection(_paths); + if (!Model.ModelCommand.TryLoadModelSelection(_paths, out var models)) + { + Models = null; + StatusMessage.Value = "Model configuration is invalid. Run `netclaw doctor` for details."; + } + else + { + Models = models; + } Providers.Clear(); var loaded = Provider.ProviderCommand.LoadProviders(_paths); foreach (var (name, entry) in loaded.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)) @@ -186,13 +194,20 @@ public void ConfirmAssignment() var (config, _) = ConfigFileHelper.LoadConfigFiles(_paths); var modelsSection = ConfigFileHelper.GetOrCreateSection(config, "Models"); - modelsSection[roleKey] = ModelEntryWriter.BuildModelEntry( + // Non-destructive: re-assigning the same model preserves an existing context-window + // clamp and modality overrides, none of which the picker can supply (#1127, #1610). The + // picker has no manual-override inputs, so it passes no explicit context window and Unset + // modality intent — the probe result seeds a first-time set only; existing values win. + ModelEntryWriter.WriteRole( + modelsSection, + roleKey, SelectedProvider, SelectedModelId, provenance, - discoveredModel?.ContextWindowTokens, - discoveredModel?.InputModalities, - discoveredModel?.OutputModalities); + ValueOverride.Unset, + ValueOverride.Unset, + ValueOverride.Unset, + discoveredModel); ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); Refresh(); @@ -223,7 +238,7 @@ public void ClearRole(string role) var (config, _) = ConfigFileHelper.LoadConfigFiles(_paths); var modelsSection = ConfigFileHelper.GetSectionOrNull(config, "Models"); - if (modelsSection?.Remove(roleKey) == true) + if (modelsSection is not null && ModelEntryWriter.ClearRole(modelsSection, roleKey)) { ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); Refresh(); diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs index b40444d2a..4a684e89f 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs @@ -559,15 +559,9 @@ public SectionContribution BuildContribution(IWizardStepViewModel editor) var providerType = vm.SelectedProviderType.ToLowerInvariant(); var fieldActions = new List { - new("Providers", SectionFieldActionKind.Set, BuildProvidersDictionary(vm, providerType)), - new("Models.Main.Provider", SectionFieldActionKind.Set, providerType) + new("Providers", SectionFieldActionKind.Set, BuildProvidersDictionary(vm, providerType)) }; - if (string.IsNullOrWhiteSpace(vm.SelectedModelId)) - fieldActions.Add(new SectionFieldAction("Models.Main.ModelId", SectionFieldActionKind.Delete)); - else - fieldActions.Add(new SectionFieldAction("Models.Main.ModelId", SectionFieldActionKind.Set, vm.SelectedModelId)); - var secretPath = $"Providers.{providerType}"; var secretActions = new List(); if (!string.IsNullOrWhiteSpace(vm.ApiKeyInput)) diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs index f8bc4bf18..bac870f88 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs @@ -135,13 +135,22 @@ internal Dictionary BuildConfigDictionary() if (Model is not null) { var models = ConfigFileHelper.GetOrCreateSection(config, "Models"); - models["Main"] = ModelEntryWriter.BuildModelEntry( + ModelEntryWriter.WriteRole( + models, + "Main", Model.Provider, Model.ModelId, Model.Provenance, - Model.ContextWindow, - Model.InputModalities, - Model.OutputModalities); + Model.ContextWindow is { } contextWindow + ? ValueOverride.Set(contextWindow) + : ValueOverride.Unset, + Model.InputModalities is { } input + ? ValueOverride.Set(input) + : ValueOverride.Unset, + Model.OutputModalities is { } output + ? ValueOverride.Set(output) + : ValueOverride.Unset, + discovered: null); } // Slack section diff --git a/src/Netclaw.Configuration.Tests/NamedModelConfigurationTests.cs b/src/Netclaw.Configuration.Tests/NamedModelConfigurationTests.cs new file mode 100644 index 000000000..42dbefa0e --- /dev/null +++ b/src/Netclaw.Configuration.Tests/NamedModelConfigurationTests.cs @@ -0,0 +1,86 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Netclaw.Configuration.Tests; + +public sealed class NamedModelConfigurationTests +{ + [Fact] + public void Resolve_LegacyShape_PreservesRuntimeValues() + { + var configuration = Build(new Dictionary + { + ["Models:Main:Provider"] = "vllm", + ["Models:Main:ModelId"] = "qwen-vl", + ["Models:Main:ContextWindow"] = "32768", + ["Models:Main:InputModalities"] = "Text, Image", + }); + + var result = ModelConfigurationResolver.Resolve(configuration); + + Assert.True(result.IsLegacy); + Assert.Equal("vllm", result.Selection.Main.Provider); + Assert.Equal(32768, result.Selection.Main.ContextWindow); + Assert.Equal(ModelModality.Text | ModelModality.Image, result.Selection.Main.InputModalities); + } + + [Fact] + public void Resolve_NamedShape_ResolvesRoleWithoutMutatingDefinition() + { + var configuration = Build(new Dictionary + { + ["Models:Definitions:vision:Provider"] = "vllm", + ["Models:Definitions:vision:ModelId"] = "qwen-vl", + ["Models:Definitions:vision:InputModalities"] = "Text, Image", + ["Models:Roles:Main"] = "vision", + }); + + var result = ModelConfigurationResolver.Resolve(configuration); + + Assert.False(result.IsLegacy); + Assert.Equal("qwen-vl", result.Selection.Main.ModelId); + Assert.Equal(ModelModality.Text | ModelModality.Image, result.Selection.Main.InputModalities); + } + + [Fact] + public void Resolve_MixedShape_FailsLoudly() + { + var configuration = Build(new Dictionary + { + ["Models:Main:Provider"] = "vllm", + ["Models:Main:ModelId"] = "qwen-vl", + ["Models:Definitions:vision:Provider"] = "vllm", + ["Models:Definitions:vision:ModelId"] = "qwen-vl", + ["Models:Roles:Main"] = "vision", + }); + + var exception = Assert.Throws( + () => ModelConfigurationResolver.Resolve(configuration)); + + Assert.Contains("mixes legacy", exception.Message); + } + + [Fact] + public void Resolve_MissingDefinition_FailsLoudly() + { + var configuration = Build(new Dictionary + { + ["Models:Definitions:vision:Provider"] = "vllm", + ["Models:Definitions:vision:ModelId"] = "qwen-vl", + ["Models:Roles:Main"] = "missing", + }); + + var exception = Assert.Throws( + () => ModelConfigurationResolver.Resolve(configuration)); + + Assert.Contains("unknown definition 'missing'", exception.Message); + } + + private static IConfiguration Build(Dictionary values) + => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); +} diff --git a/src/Netclaw.Configuration.Tests/ProviderRuntimeValidationTests.cs b/src/Netclaw.Configuration.Tests/ProviderRuntimeValidationTests.cs index 3d84e1801..b3f9e2c43 100644 --- a/src/Netclaw.Configuration.Tests/ProviderRuntimeValidationTests.cs +++ b/src/Netclaw.Configuration.Tests/ProviderRuntimeValidationTests.cs @@ -3,12 +3,46 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Text.Json.Nodes; using Xunit; namespace Netclaw.Configuration.Tests; public sealed class ProviderRuntimeValidationTests { + [Theory] + [InlineData("vision", true, true, true)] + [InlineData("VISION", true, true, true)] + [InlineData("missing", true, false, false)] + [InlineData("provider-only", true, true, false)] + [InlineData(null, false, false, false)] + public void NamedRolePresence_ComesFromReferencedDefinition( + string? roleReference, + bool roleConfigured, + bool providerConfigured, + bool modelIdConfigured) + { + var models = JsonNode.Parse( + """ + { + "Definitions": { + "vision": { "Provider": "vllm", "ModelId": "qwen-vl" }, + "provider-only": { "Provider": "vllm" } + }, + "Roles": {} + } + """)!.AsObject(); + if (roleReference is not null) + models["Roles"]!["Main"] = roleReference; + + var root = new JsonObject { ["Models"] = models }; + var result = ProviderRuntimeConfiguration.FromJson(root).Main; + + Assert.Equal(roleConfigured, result.RoleConfigured); + Assert.Equal(providerConfigured, result.ProviderConfigured); + Assert.Equal(modelIdConfigured, result.ModelIdConfigured); + } + [Fact] public void MainModelAbsent_DefaultModelSelection_ReturnsNoProviderConfigured() { diff --git a/src/Netclaw.Configuration/NamedModelConfiguration.cs b/src/Netclaw.Configuration/NamedModelConfiguration.cs new file mode 100644 index 000000000..5d45997ea --- /dev/null +++ b/src/Netclaw.Configuration/NamedModelConfiguration.cs @@ -0,0 +1,126 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Configuration; + +namespace Netclaw.Configuration; + +/// +/// Canonical model configuration. Definitions own model metadata; roles only select definitions. +/// +public sealed class NamedModelConfiguration +{ + public Dictionary Definitions { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + + public ModelRoleAssignments Roles { get; set; } = new(); +} + +public sealed class ModelRoleAssignments +{ + public string Main { get; set; } = string.Empty; + public string? Fallback { get; set; } + public string? Compaction { get; set; } +} + +/// +/// Resolves either the legacy inline role shape or the canonical named-definition shape into the +/// runtime representation consumed by provider and actor composition. Mixed shapes fail loudly. +/// +public static class ModelConfigurationResolver +{ + private static readonly string[] LegacyRoles = ["Main", "Fallback", "Compaction"]; + + public static ModelConfigurationResolution Resolve(IConfigurationSection modelsSection) + { + var hasLegacy = LegacyRoles.Any(role => modelsSection.GetSection(role).Exists()); + var hasDefinitions = modelsSection.GetSection(nameof(NamedModelConfiguration.Definitions)).Exists(); + var hasRoles = modelsSection.GetSection(nameof(NamedModelConfiguration.Roles)).Exists(); + var hasNamed = hasDefinitions || hasRoles; + + if (hasLegacy && hasNamed) + throw new InvalidOperationException( + "Models configuration mixes legacy inline roles with named Definitions/Roles. " + + "Run `netclaw doctor --fix` after removing one representation."); + + if (!hasNamed) + { + return new ModelConfigurationResolution( + modelsSection.Get() ?? new ModelSelection(), + IsLegacy: hasLegacy); + } + + if (!hasDefinitions || !hasRoles) + throw new InvalidOperationException( + "Named Models configuration requires both Definitions and Roles sections."); + + var duplicateDefinition = modelsSection.GetSection(nameof(NamedModelConfiguration.Definitions)) + .GetChildren() + .GroupBy(child => child.Key, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateDefinition is not null) + { + throw new InvalidOperationException( + $"Models:Definitions contains duplicate case-insensitive name '{duplicateDefinition.Key}'."); + } + + var named = modelsSection.Get() ?? new NamedModelConfiguration(); + if (named.Definitions.Count == 0) + throw new InvalidOperationException("Models:Definitions must contain at least one model definition."); + + var selection = new ModelSelection + { + Main = ResolveRequired(named, nameof(named.Roles.Main), named.Roles.Main), + Fallback = ResolveOptional(named, nameof(named.Roles.Fallback), named.Roles.Fallback), + Compaction = ResolveOptional(named, nameof(named.Roles.Compaction), named.Roles.Compaction), + }; + + return new ModelConfigurationResolution(selection, IsLegacy: false); + } + + public static ModelConfigurationResolution Resolve(IConfiguration configuration) + => Resolve(configuration.GetSection("Models")); + + private static ModelReference ResolveRequired( + NamedModelConfiguration named, string role, string definitionName) + { + if (string.IsNullOrWhiteSpace(definitionName)) + throw new InvalidOperationException($"Models:Roles:{role} must reference a model definition."); + + return ResolveDefinition(named, role, definitionName); + } + + private static ModelReference? ResolveOptional( + NamedModelConfiguration named, string role, string? definitionName) + => string.IsNullOrWhiteSpace(definitionName) + ? null + : ResolveDefinition(named, role, definitionName); + + private static ModelReference ResolveDefinition( + NamedModelConfiguration named, string role, string definitionName) + { + var match = named.Definitions.FirstOrDefault(pair => + string.Equals(pair.Key, definitionName, StringComparison.OrdinalIgnoreCase)); + if (string.IsNullOrEmpty(match.Key)) + { + throw new InvalidOperationException( + $"Models:Roles:{role} references unknown definition '{definitionName}'."); + } + + return Clone(match.Value); + } + + private static ModelReference Clone(ModelReference source) => new() + { + Provider = source.Provider, + ModelId = source.ModelId, + ContextWindow = source.ContextWindow, + Provenance = source.Provenance, + InputModalities = source.InputModalities, + OutputModalities = source.OutputModalities, + }; +} + +public sealed record ModelConfigurationResolution(ModelSelection Selection, bool IsLegacy); diff --git a/src/Netclaw.Configuration/ProviderRuntimeValidation.cs b/src/Netclaw.Configuration/ProviderRuntimeValidation.cs index be21c1027..9c9c94ca6 100644 --- a/src/Netclaw.Configuration/ProviderRuntimeValidation.cs +++ b/src/Netclaw.Configuration/ProviderRuntimeValidation.cs @@ -182,6 +182,18 @@ public static ProviderRuntimeConfiguration FromConfiguration(IConfiguration conf var models = configuration.GetSection("Models"); var providers = configuration.GetSection("Providers"); + if (models.GetSection(nameof(NamedModelConfiguration.Definitions)).Exists() + || models.GetSection(nameof(NamedModelConfiguration.Roles)).Exists()) + { + var selection = ModelConfigurationResolver.Resolve(models).Selection; + return FromExplicitRoles( + ProviderConfigurationLoader.Load(providers), + main: !string.IsNullOrWhiteSpace(selection.Main.Provider) + && !string.IsNullOrWhiteSpace(selection.Main.ModelId), + fallback: selection.Fallback is not null, + compaction: selection.Compaction is not null); + } + return new ProviderRuntimeConfiguration( Main: ModelReferenceRuntimeConfiguration.FromConfiguration(models.GetSection("Main")), Fallback: ModelReferenceRuntimeConfiguration.FromConfiguration(models.GetSection("Fallback")), @@ -197,6 +209,21 @@ public static ProviderRuntimeConfiguration FromJson(JsonObject? root) var models = root?["Models"] as JsonObject; var providers = root?["Providers"] as JsonObject; + if (models?["Roles"] is JsonObject roles) + { + var definitions = models["Definitions"] as JsonObject; + return new ProviderRuntimeConfiguration( + Main: ModelReferenceRuntimeConfiguration.FromNamedRole(roles, definitions, "Main"), + Fallback: ModelReferenceRuntimeConfiguration.FromNamedRole(roles, definitions, "Fallback"), + Compaction: ModelReferenceRuntimeConfiguration.FromNamedRole(roles, definitions, "Compaction"), + ProvidersWithExplicitType: providers is null + ? [] + : providers + .Where(provider => provider.Value is JsonObject obj && HasProperty(obj, nameof(ProviderEntry.Type))) + .Select(provider => provider.Key) + .ToList()); + } + return new ProviderRuntimeConfiguration( Main: ModelReferenceRuntimeConfiguration.FromJson(models?["Main"] as JsonObject), Fallback: ModelReferenceRuntimeConfiguration.FromJson(models?["Fallback"] as JsonObject), @@ -227,6 +254,7 @@ public static ProviderRuntimeConfiguration FromExplicitRoles( internal static bool HasProperty(JsonObject obj, string propertyName) => obj.Any(property => string.Equals(property.Key, propertyName, StringComparison.OrdinalIgnoreCase)); + } public sealed record ModelReferenceRuntimeConfiguration( @@ -250,6 +278,33 @@ public static ModelReferenceRuntimeConfiguration FromJson(JsonObject? obj) ModelIdConfigured: obj is not null && ProviderRuntimeConfiguration.HasProperty(obj, nameof(ModelReference.ModelId))); } + public static ModelReferenceRuntimeConfiguration FromNamedRole( + JsonObject roles, JsonObject? definitions, string roleName) + { + var role = roles.FirstOrDefault(property => + string.Equals(property.Key, roleName, StringComparison.OrdinalIgnoreCase)); + if (role.Value is not JsonValue roleValue + || !roleValue.TryGetValue(out var definitionName) + || string.IsNullOrWhiteSpace(definitionName)) + { + return FromCompleteRole(false); + } + + var definition = definitions? + .FirstOrDefault(property => string.Equals( + property.Key, definitionName, StringComparison.OrdinalIgnoreCase)) + .Value as JsonObject; + + return new ModelReferenceRuntimeConfiguration( + RoleConfigured: true, + ProviderConfigured: definition is not null + && ProviderRuntimeConfiguration.HasProperty( + definition, nameof(ModelReference.Provider)), + ModelIdConfigured: definition is not null + && ProviderRuntimeConfiguration.HasProperty( + definition, nameof(ModelReference.ModelId))); + } + public static ModelReferenceRuntimeConfiguration FromCompleteRole(bool configured) => configured ? new ModelReferenceRuntimeConfiguration(true, true, true) diff --git a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json index a183c0bb2..83c0064dd 100644 --- a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json +++ b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json @@ -333,14 +333,40 @@ "additionalProperties": true }, "Models": { - "type": "object", - "description": "Model selection by role (Main, Fallback, Compaction).", - "properties": { - "Main": { "$ref": "#/$defs/ModelReference" }, - "Fallback": { "$ref": "#/$defs/ModelReference" }, - "Compaction": { "$ref": "#/$defs/ModelReference" } - }, - "additionalProperties": false + "description": "Named model definitions and role assignments. The legacy inline role shape remains accepted for upgrades.", + "oneOf": [ + { + "type": "object", + "properties": { + "Main": { "$ref": "#/$defs/ModelReference" }, + "Fallback": { "$ref": "#/$defs/ModelReference" }, + "Compaction": { "$ref": "#/$defs/ModelReference" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["Definitions", "Roles"], + "properties": { + "Definitions": { + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/ModelReference" } + }, + "Roles": { + "type": "object", + "required": ["Main"], + "properties": { + "Main": { "type": "string", "minLength": 1 }, + "Fallback": { "type": ["string", "null"] }, + "Compaction": { "type": ["string", "null"] } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] }, "Memory": { "type": "object", diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index d40dc308c..95f16f595 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -360,8 +360,7 @@ static NetclawPaths ConfigureConfigServices(IServiceCollection services, IConfig // No silent fallback to local-ollama: an empty Providers section yields // the NoProviderConfigured outcome and the host registers NoOpChatClientProvider. var providers = ProviderConfigurationLoader.Load(configuration.GetSection("Providers")); - var models = configuration.GetSection("Models") - .Get() ?? new ModelSelection(); + var models = ModelConfigurationResolver.Resolve(configuration).Selection; var validation = ProviderRuntimeValidation.Evaluate( providers, models, @@ -398,9 +397,15 @@ static void ConfigureDaemonServices( services.AddHostedService(); services.AddHostedService(); + var resolvedModels = ModelConfigurationResolver.Resolve(configuration).Selection; services .AddOptions() - .Bind(configuration.GetSection("Models")) + .Configure(options => + { + options.Main = resolvedModels.Main; + options.Fallback = resolvedModels.Fallback; + options.Compaction = resolvedModels.Compaction; + }) .ValidateOnStart(); services.AddSingleton, ModelSelectionValidator>(); services @@ -418,8 +423,7 @@ static void ConfigureDaemonServices( }); // Resolve models for session config - var models = configuration.GetSection("Models") - .Get() ?? new ModelSelection(); + var models = resolvedModels; services.AddSingleton(models); // Auto-detect model capabilities via the runtime IModelCapabilityResolver diff --git a/tests/smoke/assertions/init-wizard.sh b/tests/smoke/assertions/init-wizard.sh index cd0df752a..11c513222 100755 --- a/tests/smoke/assertions/init-wizard.sh +++ b/tests/smoke/assertions/init-wizard.sh @@ -47,8 +47,9 @@ fi echo "init-wizard: checking expected fields in netclaw.json..." assert_field '.Providers.ollama.Type' 'ollama' "$config_json" || : assert_field '.Providers.ollama.Endpoint' 'http://localhost:11434' "$config_json" || : -assert_field '.Models.Main.Provider' 'ollama' "$config_json" || : -assert_field '.Models.Main.ModelId' 'qwen2:0.5b' "$config_json" || : +assert_field '.Models.Roles.Main' 'ollama-qwen2-0-5b' "$config_json" || : +assert_field '.Models.Definitions[.Models.Roles.Main].Provider' 'ollama' "$config_json" || : +assert_field '.Models.Definitions[.Models.Roles.Main].ModelId' 'qwen2:0.5b' "$config_json" || : assert_field '.Security.DeploymentPosture' 'Personal' "$config_json" || : echo "init-wizard: checking identity/SOUL.md for typed user name..."