Quellcode durchsuchen

reasoning fixed

Lukas Goldschmidt vor 1 Monat
Ursprung
Commit
45585d7654

+ 10 - 0
.env.example

@@ -0,0 +1,10 @@
+# Copy this to .env and fill in your tokens.
+OPENAI_API_KEY=
+GROQ_API_KEY=
+OPENROUTER_API_KEY=
+
+# Optional overrides.
+OPENCLAW_CONFIG_PATH=/home/lucky/.openclaw/openclaw.json
+MODEL_SELECTOR_PORT=3300
+MODEL_SELECTOR_REFRESH_INTERVAL=1800
+MODEL_SELECTOR_CACHE_DIR=data/model-cache

+ 3 - 2
.gitignore

@@ -1,4 +1,5 @@
+__pycache__/
+*.py[cod]
+*.pyo
 .env
 .env
-.env.*
 data/
 data/
-

+ 72 - 76
README.md

@@ -1,53 +1,66 @@
 # Model Selector
 # Model Selector
 
 
-A FastAPI dashboard for browsing and editing the model/provider configuration in `~/.openclaw/openclaw.json`.
+FastAPI dashboard for browsing and editing the model/provider config in `~/.openclaw/openclaw.json`.
 
 
-![Dashboard](https://img.shields.io/badge/FastAPI-0.115+-green)
-![Python](https://img.shields.io/badge/python-3.10+-blue)
-![License](https://img.shields.io/badge/license-MIT-lightgray)
+It is intentionally small:
 
 
-## Features
+- canonical config stays in `/home/lucky/.openclaw/openclaw.json`
+- provider snapshots are cached locally under `data/model-cache/`
+- alias edits land in `agents.defaults.models.<provider/model>.alias`
+- the server runs on port `3300`
 
 
-- **Live provider discovery** — Pull available models from OpenAI, Groq, and OpenRouter APIs
-- **Smart caching** — Provider snapshots saved locally to `.cache/` for offline browsing
-- **Alias management** — Give your models friendly names (e.g., `gpt-4o` → "osiris")
-- **Config sync** — All changes written atomically to your central `openclaw.json`
-- **Auto-refresh** — Periodic provider updates (default: every 30 minutes)
-- **Zero-build UI** — Single-page vanilla JS, no frontend build step
+## What it shows
+
+- configured models from the canonical OpenClaw config
+- live provider catalogs from OpenAI, Groq, and OpenRouter
+- aliases, reasoning flags, context window, max tokens, and provider metadata when present
+- missing capabilities as `—`, rather than guessing
+
+## Files That Matter
+
+- [`model_selector/main.py`](model_selector/main.py) - FastAPI app and API routes
+- [`model_selector/config.py`](model_selector/config.py) - canonical config read/write logic
+- [`model_selector/providers.py`](model_selector/providers.py) - provider adapters and config serialization
+- [`model_selector/service.py`](model_selector/service.py) - dashboard state assembly
+- [`model_selector/cache.py`](model_selector/cache.py) - local JSON snapshot cache
+- [`run.sh`](run.sh) - start the app
+- [`killserver.sh`](killserver.sh) - clear stale listeners on port `3300`
+- [`restart.sh`](restart.sh) - kill then start
+- [`tests.sh`](tests.sh) - run the test suite
 
 
 ## Quick Start
 ## Quick Start
 
 
 ```bash
 ```bash
-# Copy env template and add your API keys
+# Create a local virtualenv and install dependencies
+python3 -m venv .venv
+source .venv/bin/activate
+pip install -r requirements.txt
+
+# Configure API keys if you want live provider catalogs
 cp .env.example .env
 cp .env.example .env
-# Edit .env with your OPENAI_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY
 
 
-# Start the server (defaults to port 3300)
+# Start the dashboard
 ./run.sh
 ./run.sh
-
-# Or with custom port
-MODEL_SELECTOR_PORT=8080 ./run.sh
 ```
 ```
 
 
-Open <http://localhost:3300> in your browser.
+Open `http://localhost:3300`.
 
 
 ## Configuration
 ## Configuration
 
 
-### Environment Variables
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `OPENAI_API_KEY` | — | OpenAI API token |
-| `GROQ_API_KEY` | — | Groq API token |
-| `OPENROUTER_API_KEY` | — | OpenRouter API token |
-| `OPENCLAW_CONFIG_PATH` | `~/.openclaw/openclaw.json` | Path to your OpenClaw config |
-| `MODEL_SELECTOR_PORT` | `3300` | Dashboard server port |
-| `MODEL_SELECTOR_REFRESH_INTERVAL` | `1800` | Provider refresh interval (seconds) |
-| `MODEL_SELECTOR_CACHE_DIR` | `data/model-cache` | Cache directory for provider snapshots |
+The app reads these environment variables:
 
 
-### Canonical Config Path
+| Variable | Default | Purpose |
+| --- | --- | --- |
+| `OPENCLAW_CONFIG_PATH` | `/home/lucky/.openclaw/openclaw.json` | Canonical config file |
+| `MODEL_SELECTOR_PORT` | `3300` | Web server port |
+| `MODEL_SELECTOR_HOST` | `0.0.0.0` | Bind host |
+| `MODEL_SELECTOR_REFRESH_INTERVAL` | `1800` | Live provider refresh interval, in seconds |
+| `MODEL_SELECTOR_CACHE_DIR` | `data/model-cache` | Local JSON snapshot directory |
+| `OPENAI_API_KEY` | unset | OpenAI live catalog access |
+| `GROQ_API_KEY` | unset | Groq live catalog access |
+| `OPENROUTER_API_KEY` | unset | OpenRouter live catalog access |
 
 
-The dashboard reads from `/home/lucky/.openclaw/openclaw.json` by default. This file uses the OpenClaw schema:
+The config schema is intentionally narrow:
 
 
 ```json
 ```json
 {
 {
@@ -55,7 +68,14 @@ The dashboard reads from `/home/lucky/.openclaw/openclaw.json` by default. This
     "providers": {
     "providers": {
       "openai": {
       "openai": {
         "models": [
         "models": [
-          {"id": "gpt-4o", "name": "GPT-4o", "reasoning": false, "input": ["text"], "contextWindow": 128000, "maxTokens": 16000}
+          {
+            "id": "gpt-4o",
+            "name": "GPT-4o",
+            "reasoning": false,
+            "input": ["text"],
+            "contextWindow": 128000,
+            "maxTokens": 16000
+          }
         ]
         ]
       }
       }
     }
     }
@@ -63,63 +83,39 @@ The dashboard reads from `/home/lucky/.openclaw/openclaw.json` by default. This
   "agents": {
   "agents": {
     "defaults": {
     "defaults": {
       "models": {
       "models": {
-        "openai/gpt-4o": {"alias": "my-favorite"}
+        "openai/gpt-4o": {
+          "alias": "my-favorite"
+        }
       }
       }
     }
     }
   }
   }
 }
 }
 ```
 ```
 
 
-## API Endpoints
+## API
 
 
-| Endpoint | Method | Description |
-|----------|--------|-------------|
-| `/` | GET | Dashboard UI |
-| `/api/state` | GET | Full dashboard state (configured + providers) |
-| `/api/config` | GET | Config path and configured models |
-| `/api/alias` | POST | Update model alias (`{full_key, alias}`) |
-| `/api/config/models/add` | POST | Add model to config (`{provider_key, model_id}`) |
-| `/api/config/models/delete` | POST | Remove model from config (`{full_key}`) |
-| `/api/refresh` | POST | Force provider refresh |
+- `GET /` - dashboard UI
+- `GET /api/state` - full dashboard state
+- `GET /api/config` - config path and configured models
+- `POST /api/alias` - update alias for a configured model
+- `POST /api/config/models/add` - add a provider model to config
+- `POST /api/config/models/delete` - remove a configured model
+- `POST /api/refresh` - force a live provider refresh
 
 
-## Development
+## Testing
 
 
-### Running Tests
+Use the repo-local test runner:
 
 
 ```bash
 ```bash
-# Uses temp config fixtures; never touches the live file
-python -m pytest tests/ -v
+bash tests.sh
 ```
 ```
 
 
-### Project Structure
-
-```
-.
-├── model_selector/
-│   ├── main.py        # FastAPI app, routes
-│   ├── config.py      # OpenClawConfigRepository
-│   ├── providers.py   # ProviderAdapter, feature detection
-│   ├── service.py     # ModelDashboardService
-│   ├── cache.py       # SnapshotCache
-│   ├── settings.py    # AppSettings from env
-│   └── ui.py          # Jinja2-style HTML rendering
-├── data/model-cache/  # Provider snapshots (gitignored)
-├── tests/
-├── run.sh             # Start server
-├── restart.sh         # Kill + start
-└── killserver.sh      # Clear port 3300
-```
-
-## Provider Support
-
-| Provider | Key | Features |
-|----------|-----|----------|
-| OpenAI | `openai` | reasoning, context_window, max_tokens |
-| Groq | `groq-cloud` | reasoning, context_window, active status |
-| OpenRouter | `openrouter` | pricing (prompt/completion/request), full metadata |
+That script uses `.venv` when present and falls back to `python3 -m pytest -q` if not.
 
 
-Missing capabilities display as "—" rather than being invented.
+The tests use temporary config fixtures and do not touch the live `~/.openclaw/openclaw.json`.
 
 
-## License
+## Notes
 
 
-MIT
+- Runtime cache files under `data/model-cache/` are local-only and ignored by git.
+- The dashboard keeps the configured-model view separate from provider catalogs on purpose.
+- OpenRouter config writes only include parameters that the dashboard knows are valid.

BIN
model_selector/__pycache__/__init__.cpython-313.pyc


BIN
model_selector/__pycache__/cache.cpython-313.pyc


BIN
model_selector/__pycache__/config.cpython-313.pyc


BIN
model_selector/__pycache__/main.cpython-313.pyc


BIN
model_selector/__pycache__/providers.cpython-313.pyc


BIN
model_selector/__pycache__/service.cpython-313.pyc


BIN
model_selector/__pycache__/settings.cpython-313.pyc


BIN
model_selector/__pycache__/ui.cpython-313.pyc


+ 38 - 4
model_selector/providers.py

@@ -9,6 +9,9 @@ import httpx
 from .config import provider_label
 from .config import provider_label
 
 
 
 
+_VALID_INPUT_MODALITIES = {"text", "image", "video", "audio"}
+
+
 @dataclass(frozen=True)
 @dataclass(frozen=True)
 class ProviderModel:
 class ProviderModel:
     provider_key: str
     provider_key: str
@@ -28,6 +31,7 @@ class ProviderModel:
     active: bool | None
     active: bool | None
     features: tuple[str, ...]
     features: tuple[str, ...]
     raw: dict[str, Any]
     raw: dict[str, Any]
+    default_parameters: dict[str, Any] | None = None
 
 
     @property
     @property
     def key(self) -> str:
     def key(self) -> str:
@@ -53,18 +57,43 @@ def infer_reasoning(raw: dict[str, Any], features: tuple[str, ...]) -> bool | No
         return bool(raw.get("reasoning"))
         return bool(raw.get("reasoning"))
     if "reasoning" in features:
     if "reasoning" in features:
         return True
         return True
+    default_parameters = raw.get("default_parameters")
+    if isinstance(default_parameters, dict) and default_parameters.get("reasoning") is not None:
+        return True
     return None
     return None
 
 
 
 
 def provider_model_to_config_entry(model: ProviderModel) -> dict[str, Any]:
 def provider_model_to_config_entry(model: ProviderModel) -> dict[str, Any]:
-    return {
+    input_modalities = _sanitize_input_modalities(model.input_modalities)
+    entry = {
         "id": model.model_id,
         "id": model.model_id,
         "name": model.name,
         "name": model.name,
-        "reasoning": bool(model.reasoning) if model.reasoning is not None else False,
-        "input": list(model.input_modalities or ("text",)),
+        "input": list(input_modalities),
         "contextWindow": model.context_window,
         "contextWindow": model.context_window,
         "maxTokens": model.max_tokens,
         "maxTokens": model.max_tokens,
     }
     }
+    if model.reasoning is True:
+        entry["reasoning"] = True
+    thinking = _openrouter_thinking_parameters(model)
+    if thinking:
+        entry["thinking"] = thinking
+    return entry
+
+
+def _sanitize_input_modalities(modalities: tuple[str, ...] | list[str] | None) -> tuple[str, ...]:
+    filtered = tuple(modality for modality in (modalities or ()) if modality in _VALID_INPUT_MODALITIES)
+    return filtered or ("text",)
+
+
+def _openrouter_thinking_parameters(model: ProviderModel) -> dict[str, Any] | None:
+    if model.provider_key != "openrouter":
+        return None
+    default_parameters = model.default_parameters or {}
+    for key in ("reasoning", "thinking"):
+        value = default_parameters.get(key)
+        if isinstance(value, dict) and value:
+            return dict(value)
+    return None
 
 
 
 
 def derive_features(provider_key: str, raw: dict[str, Any]) -> tuple[str, ...]:
 def derive_features(provider_key: str, raw: dict[str, Any]) -> tuple[str, ...]:
@@ -134,6 +163,8 @@ def derive_features(provider_key: str, raw: dict[str, Any]) -> tuple[str, ...]:
                 features.append("structured output")
                 features.append("structured output")
             elif param in {"tools", "tool_choice"}:
             elif param in {"tools", "tool_choice"}:
                 features.append("tool use")
                 features.append("tool use")
+            elif param in {"reasoning", "thinking"} or param.startswith("reasoning"):
+                features.append("reasoning")
             elif param == "seed":
             elif param == "seed":
                 features.append("seed")
                 features.append("seed")
 
 
@@ -187,10 +218,12 @@ class OpenRouterAdapter(ProviderAdapter):
         for item in payload.get("data", []):
         for item in payload.get("data", []):
             pricing = item.get("pricing") or {}
             pricing = item.get("pricing") or {}
             architecture = item.get("architecture") or {}
             architecture = item.get("architecture") or {}
+            default_parameters = item.get("default_parameters") or {}
             raw = dict(item)
             raw = dict(item)
             raw["supported_parameters"] = item.get("supported_parameters") or []
             raw["supported_parameters"] = item.get("supported_parameters") or []
             raw["pricing"] = pricing
             raw["pricing"] = pricing
             raw["architecture"] = architecture
             raw["architecture"] = architecture
+            raw["default_parameters"] = default_parameters
             features = derive_features(self.provider_key, raw)
             features = derive_features(self.provider_key, raw)
             models.append(
             models.append(
                 ProviderModel(
                 ProviderModel(
@@ -205,12 +238,13 @@ class OpenRouterAdapter(ProviderAdapter):
                     pricing_prompt=_format_price(pricing.get("prompt")),
                     pricing_prompt=_format_price(pricing.get("prompt")),
                     pricing_completion=_format_price(pricing.get("completion")),
                     pricing_completion=_format_price(pricing.get("completion")),
                     pricing_request=_format_price(pricing.get("request")),
                     pricing_request=_format_price(pricing.get("request")),
-                    input_modalities=tuple(architecture.get("input_modalities") or ()),
+                    input_modalities=_sanitize_input_modalities(architecture.get("input_modalities")),
                     output_modalities=tuple(architecture.get("output_modalities") or ()),
                     output_modalities=tuple(architecture.get("output_modalities") or ()),
                     supported_parameters=tuple(item.get("supported_parameters") or ()),
                     supported_parameters=tuple(item.get("supported_parameters") or ()),
                     active=None,
                     active=None,
                     features=features,
                     features=features,
                     raw=raw,
                     raw=raw,
+                    default_parameters=default_parameters if isinstance(default_parameters, dict) else None,
                 )
                 )
             )
             )
         return models
         return models

+ 1 - 0
model_selector/service.py

@@ -87,6 +87,7 @@ class ModelDashboardService:
                     active=item.get("active"),
                     active=item.get("active"),
                     features=tuple(item.get("features", ())),
                     features=tuple(item.get("features", ())),
                     raw=item.get("raw", {}),
                     raw=item.get("raw", {}),
+                    default_parameters=item.get("default_parameters"),
                 )
                 )
             )
             )
         return ProviderState(
         return ProviderState(

+ 6 - 0
requirements.txt

@@ -0,0 +1,6 @@
+fastapi>=0.115,<1.0
+httpx>=0.27,<1.0
+pydantic>=2.7,<3.0
+uvicorn>=0.30,<1.0
+
+pytest>=8.0,<9.0

+ 16 - 0
tests.sh

@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PYTHON_BIN="${PYTHON_BIN:-}"
+
+if [[ -z "${PYTHON_BIN}" ]]; then
+  if [[ -x "${ROOT_DIR}/.venv/bin/python" ]]; then
+    PYTHON_BIN="${ROOT_DIR}/.venv/bin/python"
+  else
+    PYTHON_BIN="python3"
+  fi
+fi
+
+cd "${ROOT_DIR}"
+exec "${PYTHON_BIN}" -m pytest -q

BIN
tests/__pycache__/test_app.cpython-313.pyc


BIN
tests/__pycache__/test_config.cpython-313.pyc


BIN
tests/__pycache__/test_providers.cpython-313.pyc


+ 1 - 1
tests/test_config.py

@@ -37,7 +37,7 @@ class ConfigRepositoryTests(unittest.TestCase):
             models = repo.configured_models()
             models = repo.configured_models()
 
 
             self.assertTrue(any(model.full_key == "openai/gpt-5.4" for model in models))
             self.assertTrue(any(model.full_key == "openai/gpt-5.4" for model in models))
-            self.assertTrue(any(model.full_key == "groq-cloud/openai/gpt-oss-20b" for model in models))
+            self.assertTrue(any(model.provider_key == "openrouter" for model in models))
 
 
     def test_add_and_remove_model_updates_only_copy(self) -> None:
     def test_add_and_remove_model_updates_only_copy(self) -> None:
         with tempfile.TemporaryDirectory() as tmp:
         with tempfile.TemporaryDirectory() as tmp:

+ 30 - 1
tests/test_providers.py

@@ -7,7 +7,7 @@ from pathlib import Path
 import unittest
 import unittest
 
 
 from model_selector.cache import SnapshotCache
 from model_selector.cache import SnapshotCache
-from model_selector.providers import derive_features
+from model_selector.providers import ProviderModel, derive_features, provider_model_to_config_entry
 from model_selector.service import ModelDashboardService
 from model_selector.service import ModelDashboardService
 from model_selector.config import OpenClawConfigRepository
 from model_selector.config import OpenClawConfigRepository
 
 
@@ -40,6 +40,7 @@ class FakeAdapter:
                 active=item.get("active"),
                 active=item.get("active"),
                 features=tuple(item.get("features", ())),
                 features=tuple(item.get("features", ())),
                 raw=item,
                 raw=item,
+                default_parameters=item.get("default_parameters"),
             )
             )
             for item in self.models
             for item in self.models
         ]
         ]
@@ -58,6 +59,34 @@ class ProviderTests(unittest.IsolatedAsyncioTestCase):
         self.assertIn("structured output", features)
         self.assertIn("structured output", features)
         self.assertIn("tool use", features)
         self.assertIn("tool use", features)
 
 
+    def test_openrouter_reasoning_surfaces_in_config_entry(self) -> None:
+        model = ProviderModel(
+            provider_key="openrouter",
+            provider_label="OpenRouter",
+            model_id="~anthropic/claude-sonnet-latest",
+            name="Claude Sonnet",
+            source="live",
+            reasoning=True,
+            context_window=200000,
+            max_tokens=64000,
+            pricing_prompt=None,
+            pricing_completion=None,
+            pricing_request=None,
+            input_modalities=("text", "image", "file"),
+            output_modalities=("text",),
+            supported_parameters=("reasoning", "response_format"),
+            active=None,
+            features=("reasoning",),
+            raw={"id": "~anthropic/claude-sonnet-latest"},
+            default_parameters={"reasoning": {"effort": "high"}},
+        )
+
+        entry = provider_model_to_config_entry(model)
+
+        self.assertTrue(entry["reasoning"])
+        self.assertEqual(entry["thinking"], {"effort": "high"})
+        self.assertEqual(entry["input"], ["text", "image"])
+
     async def test_refresh_writes_snapshot_json(self) -> None:
     async def test_refresh_writes_snapshot_json(self) -> None:
         with tempfile.TemporaryDirectory() as tmp:
         with tempfile.TemporaryDirectory() as tmp:
             config_path = Path(tmp) / "openclaw.json"
             config_path = Path(tmp) / "openclaw.json"