mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
Add multi-model support with ModelRegistry, fallback routing, and per… (#9)
* Add multi-model support with ModelRegistry, fallback routing, and per-workstream selection Introduces a ModelRegistry that holds named model configurations loaded from [models.*] sections in config.toml. Each workstream can select its model at creation time or switch mid-session via /model <alias>. When the primary model is unreachable, a configurable fallback chain tries alternative models. Sub-agents (plan/task) can optionally use a cheaper model via the agent_model setting. Core changes: - New turnstone/core/model_registry.py: ModelConfig (frozen, api_key redacted from repr), ModelRegistry (thread-safe lazy client creation, resolve, fallback chain), load_model_registry() with backwards-compatible config loading - session.py: registry/model_alias params, /model show+switch command, fallback in _create_stream_with_retry (extracted _try_stream), agent model override in _run_agent - workstream.py: factory signature accepts optional model_alias, create() gains model param - cli.py + server.py: build registry, updated session factories, banner, shutdown - protocol.py: model field on CreateWorkstreamMessage - bridge.py: pass model through workstream creation chain Frontend: - MODEL column added to dashboard tables in both server and console UIs - Responsive: hidden alongside NODE at narrow viewports - ARIA labels include model info, title attributes for truncated text - SSE connected event includes model_alias Documentation: - README: architecture tree, Multi-Model Support section, config keys - docs/architecture.md: module map, Multi-Model Registry subsection - docs/api-reference.md: model field in workstream creation, model_alias in SSE - PlantUML diagrams 02 + 03 updated with ModelRegistry Tests: 43 new tests (576 total), mypy clean, ruff clean. * Fix Copilot PR #9 review: model_alias property, preserve manual tool_truncation - Expose model_alias as a public @property on ChatSession instead of accessing the private _model_alias from server.py and tests - Track _manual_tool_truncation flag so /model switch only recomputes tool_truncation when it was auto-derived, preserving --tool-truncation overrides - Update PlantUML diagram to reflect the public property
This commit is contained in:
@@ -111,6 +111,7 @@ turnstone/
|
||||
│ ├── tools.py # Tool definitions (auto-loaded from JSON)
|
||||
│ ├── workstream.py # WorkstreamManager — parallel independent sessions
|
||||
│ ├── mcp_client.py # MCP client manager (external tool servers)
|
||||
│ ├── model_registry.py # ModelRegistry — named models, fallback routing, per-workstream selection
|
||||
│ ├── config.py # Unified TOML config (~/.config/turnstone/config.toml)
|
||||
│ ├── memory.py # SQLite persistence (memories, conversations, FTS5)
|
||||
│ ├── metrics.py # Prometheus-compatible metrics collector
|
||||
@@ -238,6 +239,29 @@ turnstone-server --mcp-config ~/.config/turnstone/mcp.json
|
||||
|
||||
Use `/mcp` in the REPL to list connected tools. MCP tools require user approval by default (overridden by `--skip-permissions` or UI auto-approve).
|
||||
|
||||
### Multi-Model Support
|
||||
|
||||
Turnstone supports multiple model backends per server instance. Define named models in `config.toml` and select per-workstream or switch mid-session with `/model <alias>`.
|
||||
|
||||
```toml
|
||||
[models.local]
|
||||
base_url = "http://localhost:8000/v1"
|
||||
model = "qwen3-32b"
|
||||
|
||||
[models.openai]
|
||||
base_url = "https://api.openai.com/v1"
|
||||
api_key = "sk-..."
|
||||
model = "gpt-4o"
|
||||
context_window = 128000
|
||||
|
||||
[model]
|
||||
default = "local" # which model to use by default
|
||||
fallback = ["openai"] # try these if the primary is unreachable
|
||||
agent_model = "local" # optional: cheaper model for plan/task sub-agents
|
||||
```
|
||||
|
||||
Use `/model` to show available models, `/model openai` to switch. Workstreams created via the API accept an optional `model` parameter.
|
||||
|
||||
## Configuration
|
||||
|
||||
All entry points read `~/.config/turnstone/config.toml`. CLI flags override config values.
|
||||
@@ -252,6 +276,9 @@ tavily_key = ""
|
||||
name = "" # empty = auto-detect
|
||||
temperature = 0.5
|
||||
reasoning_effort = "medium"
|
||||
default = "default" # model alias for new workstreams
|
||||
fallback = [] # ordered list of fallback model aliases
|
||||
agent_model = "" # model alias for plan/task sub-agents
|
||||
|
||||
[tools]
|
||||
timeout = 30
|
||||
|
||||
@@ -51,6 +51,7 @@ not recognized.
|
||||
{
|
||||
"type": "connected",
|
||||
"model": "kappa_20b_131k",
|
||||
"model_alias": "default",
|
||||
"skip_permissions": false
|
||||
}
|
||||
```
|
||||
@@ -505,10 +506,16 @@ Creates a new workstream. The server supports up to 10 concurrent workstreams.
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{}
|
||||
{"name": "my-ws", "model": "openai"}
|
||||
```
|
||||
|
||||
No fields are required. The body can be empty or an empty JSON object.
|
||||
All fields are optional. The body can be empty or an empty JSON object.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|----------------|--------|---------|------------------------------------------------|
|
||||
| `name` | string | auto | Workstream display name |
|
||||
| `model` | string | default | Model alias from the registry (`[models.*]`) |
|
||||
| `auto_approve` | bool | false | Auto-approve all tool calls for this workstream |
|
||||
|
||||
**Response (success):**
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ turnstone/
|
||||
workstream.py Parallel workstream manager (WorkstreamState, Workstream, WorkstreamManager)
|
||||
tools.py Tool schema loader (JSON -> OpenAI function-calling format)
|
||||
mcp_client.py MCPClientManager — MCP server connections, tool discovery, async-sync bridge
|
||||
model_registry.py ModelRegistry — named model configs, lazy client creation, fallback routing
|
||||
memory.py SQLite persistence (conversations, memories, FTS5 search)
|
||||
metrics.py Prometheus-compatible metrics collector (MetricsCollector)
|
||||
edit.py File edit utilities (find_occurrences, pick_nearest)
|
||||
@@ -467,6 +468,47 @@ at connection time (server names with `__` are rejected).
|
||||
servers still connect. Tool execution errors return error strings to the LLM
|
||||
rather than crashing the session.
|
||||
|
||||
### Multi-Model Registry
|
||||
|
||||
`ModelRegistry` (`turnstone/core/model_registry.py`) manages named model
|
||||
configurations so workstreams can use different LLM backends.
|
||||
|
||||
**Config format:**
|
||||
```toml
|
||||
[models.local]
|
||||
base_url = "http://localhost:8000/v1"
|
||||
model = "qwen3-32b"
|
||||
|
||||
[models.openai]
|
||||
base_url = "https://api.openai.com/v1"
|
||||
api_key = "sk-..."
|
||||
model = "gpt-4o"
|
||||
context_window = 128000
|
||||
|
||||
[model]
|
||||
default = "local"
|
||||
fallback = ["openai"]
|
||||
agent_model = "local"
|
||||
```
|
||||
|
||||
**Lifecycle:**
|
||||
1. `load_model_registry()` reads `[models.*]` sections from config.toml and
|
||||
builds a `"default"` entry from CLI `--base-url`/`--model`/`--api-key` args
|
||||
2. The registry is passed to the session factory closure in both `cli.py` and
|
||||
`server.py`; each workstream resolves its model on creation
|
||||
3. `ModelRegistry.get_client()` lazily creates `OpenAI` client instances
|
||||
(thread-safe via `_client_lock`)
|
||||
4. `/model` command shows available models; `/model <alias>` switches the
|
||||
active workstream's client, model, and context window
|
||||
5. `_create_stream_with_retry()` tries the primary model, then each fallback
|
||||
alias in order if the primary is unreachable
|
||||
6. `_run_agent()` resolves `registry.agent_model` (if set) for plan/task
|
||||
sub-agents, allowing a cheaper model for autonomous loops
|
||||
|
||||
**Per-workstream selection:** `POST /api/workstreams/new` accepts an optional
|
||||
`"model"` field. The bridge `CreateWorkstreamMessage` carries the same field
|
||||
through the MQ protocol.
|
||||
|
||||
### Tool Output Truncation
|
||||
|
||||
Tool execution results (bash, read_file, search, math, man) are truncated by
|
||||
|
||||
@@ -35,6 +35,7 @@ package "turnstone/core/" <<Rectangle>> {
|
||||
component [web.py\nWeb helpers] as web <<core>>
|
||||
component [auth.py\nAuthentication] as auth <<core>>
|
||||
component [mcp_client.py\nMCPClientManager] as mcp <<core>>
|
||||
component [model_registry.py\nModelRegistry] as registry <<core>>
|
||||
}
|
||||
|
||||
' MQ subsystem
|
||||
@@ -107,7 +108,9 @@ session --> sandbox
|
||||
session --> edit
|
||||
session --> web
|
||||
session --> mcp : optional
|
||||
session --> registry : optional
|
||||
mcp --> config
|
||||
registry --> config
|
||||
tools --> schemas
|
||||
|
||||
' MQ dependencies
|
||||
|
||||
@@ -72,6 +72,8 @@ class "ChatSession" as ChatSession {
|
||||
- _msg_tokens: list[int]
|
||||
- _session_id: str
|
||||
- _mcp_client: MCPClientManager | None
|
||||
- _registry: ModelRegistry | None
|
||||
+ model_alias: str | None {property}
|
||||
- _tools: list[dict]
|
||||
- _task_tools: list[dict]
|
||||
- _agent_tools: list[dict]
|
||||
@@ -83,7 +85,8 @@ class "ChatSession" as ChatSession {
|
||||
+ resume_session(session_id: str)
|
||||
- _save_config()
|
||||
- _stream_response(stream) → dict
|
||||
- _create_stream_with_retry(msgs) → Stream
|
||||
- _create_stream_with_retry(msgs) → Stream (+ fallback)
|
||||
- _try_stream(client, model, msgs) → Stream
|
||||
- _execute_tools(tool_calls) → (results, feedback)
|
||||
- _prepare_tool(tc) → item dict
|
||||
- _prepare_mcp_tool(call_id, name, args) → item dict
|
||||
@@ -167,6 +170,35 @@ class "MCPClientManager" as MCPMgr {
|
||||
core/mcp_client.py
|
||||
}
|
||||
|
||||
' ModelRegistry
|
||||
class "ModelRegistry" as ModelReg {
|
||||
- _models: dict[str, ModelConfig]
|
||||
- _clients: dict[str, OpenAI]
|
||||
- _client_lock: Lock
|
||||
+ default: str
|
||||
+ fallback: list[str]
|
||||
+ agent_model: str | None
|
||||
--
|
||||
+ resolve(alias) → (client, model, config)
|
||||
+ get_client(alias) → OpenAI
|
||||
+ has_alias(alias) → bool
|
||||
+ list_aliases() → list[str]
|
||||
+ shutdown()
|
||||
--
|
||||
Thread-safe lazy client creation.
|
||||
Loaded by load_model_registry()
|
||||
from CLI args + [models.*] config.
|
||||
--
|
||||
core/model_registry.py
|
||||
}
|
||||
|
||||
class "ModelConfig" as ModelCfg <<frozen>> {
|
||||
+ alias: str
|
||||
+ base_url: str
|
||||
+ model: str
|
||||
+ context_window: int
|
||||
}
|
||||
|
||||
' Relationships
|
||||
SessionUI <|.. TerminalUI
|
||||
TerminalUI <|-- WsTermUI
|
||||
@@ -175,6 +207,7 @@ SessionUI <|.. NullUI
|
||||
|
||||
ChatSession --> SessionUI : uses
|
||||
ChatSession --> MCPMgr : optional
|
||||
ChatSession --> ModelReg : optional
|
||||
ChatSession <|-- HeadlessSession
|
||||
|
||||
WsMgr --> "*" Ws : manages
|
||||
@@ -182,7 +215,9 @@ Ws --> "1" ChatSession : wraps
|
||||
Ws --> "1" SessionUI : wraps
|
||||
Ws --> "1" WsState : has
|
||||
|
||||
WsMgr ..> ChatSession : creates via\nsession_factory(ui)
|
||||
WsMgr ..> ChatSession : creates via\nsession_factory(ui, model_alias)
|
||||
|
||||
ModelReg --> "*" ModelCfg : holds
|
||||
|
||||
note bottom of ChatSession
|
||||
Central engine: multi-turn LLM loop
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e1aebded0994beccbf1af25c5c1c216775a59a4e343bcbafe69bcc1172907c71
|
||||
size 291899
|
||||
oid sha256:d66025ff18c6e28ef632e2ebf8bfc71554c64bd457741fda924e90a579580c34
|
||||
size 309783
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7456dcc6a68b22d841524062028298814293b0e4dfab4097c536847d1528bb8a
|
||||
size 275572
|
||||
oid sha256:168ed3f0a91729673526c98b0358c5ec59b511206dcfc8c227d6a2b365e4e5c5
|
||||
size 322776
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
"""Tests for turnstone.core.model_registry — model registry, loading, session integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.model_registry import (
|
||||
ModelConfig,
|
||||
ModelRegistry,
|
||||
load_model_registry,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ModelConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModelConfig:
|
||||
def test_construction(self) -> None:
|
||||
cfg = ModelConfig(
|
||||
alias="local",
|
||||
base_url="http://localhost:8000/v1",
|
||||
api_key="dummy",
|
||||
model="qwen3-32b",
|
||||
)
|
||||
assert cfg.alias == "local"
|
||||
assert cfg.model == "qwen3-32b"
|
||||
assert cfg.context_window == 131072 # default
|
||||
|
||||
def test_custom_context_window(self) -> None:
|
||||
cfg = ModelConfig(
|
||||
alias="oai",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="sk-test",
|
||||
model="gpt-4o",
|
||||
context_window=128000,
|
||||
)
|
||||
assert cfg.context_window == 128000
|
||||
|
||||
def test_frozen(self) -> None:
|
||||
cfg = ModelConfig(alias="x", base_url="x", api_key="x", model="x")
|
||||
with pytest.raises(AttributeError):
|
||||
cfg.alias = "y" # type: ignore[misc]
|
||||
|
||||
def test_api_key_not_in_repr(self) -> None:
|
||||
cfg = ModelConfig(alias="test", base_url="http://x", api_key="sk-secret-key", model="m")
|
||||
assert "sk-secret-key" not in repr(cfg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ModelRegistry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModelRegistry:
|
||||
def _make_registry(
|
||||
self,
|
||||
fallback: list[str] | None = None,
|
||||
agent_model: str | None = None,
|
||||
) -> ModelRegistry:
|
||||
models = {
|
||||
"default": ModelConfig("default", "http://localhost:8000/v1", "dummy", "qwen3-32b"),
|
||||
"openai": ModelConfig(
|
||||
"openai", "https://api.openai.com/v1", "sk-test", "gpt-4o", 128000
|
||||
),
|
||||
"cheap": ModelConfig(
|
||||
"cheap", "https://api.openai.com/v1", "sk-test", "gpt-4o-mini", 128000
|
||||
),
|
||||
}
|
||||
return ModelRegistry(
|
||||
models=models,
|
||||
default="default",
|
||||
fallback=fallback,
|
||||
agent_model=agent_model,
|
||||
)
|
||||
|
||||
def test_resolve_default(self) -> None:
|
||||
reg = self._make_registry()
|
||||
client, model, cfg = reg.resolve()
|
||||
assert model == "qwen3-32b"
|
||||
assert cfg.alias == "default"
|
||||
|
||||
def test_resolve_alias(self) -> None:
|
||||
reg = self._make_registry()
|
||||
client, model, cfg = reg.resolve("openai")
|
||||
assert model == "gpt-4o"
|
||||
assert cfg.context_window == 128000
|
||||
|
||||
def test_resolve_none_uses_default(self) -> None:
|
||||
reg = self._make_registry()
|
||||
_, model1, _ = reg.resolve(None)
|
||||
_, model2, _ = reg.resolve()
|
||||
assert model1 == model2
|
||||
|
||||
def test_lazy_client_creation(self) -> None:
|
||||
reg = self._make_registry()
|
||||
assert len(reg._clients) == 0
|
||||
reg.get_client("default")
|
||||
assert len(reg._clients) == 1
|
||||
# Second call reuses
|
||||
c1 = reg.get_client("default")
|
||||
c2 = reg.get_client("default")
|
||||
assert c1 is c2
|
||||
|
||||
def test_list_aliases(self) -> None:
|
||||
reg = self._make_registry()
|
||||
aliases = reg.list_aliases()
|
||||
assert set(aliases) == {"default", "openai", "cheap"}
|
||||
|
||||
def test_count(self) -> None:
|
||||
reg = self._make_registry()
|
||||
assert reg.count == 3
|
||||
|
||||
def test_unknown_alias_error(self) -> None:
|
||||
reg = self._make_registry()
|
||||
with pytest.raises(ValueError, match="Unknown model alias"):
|
||||
reg.get_config("nonexistent")
|
||||
with pytest.raises(ValueError, match="Unknown model alias"):
|
||||
reg.get_client("nonexistent")
|
||||
|
||||
def test_shutdown(self) -> None:
|
||||
reg = self._make_registry()
|
||||
reg.get_client("default")
|
||||
reg.get_client("openai")
|
||||
assert len(reg._clients) == 2
|
||||
reg.shutdown()
|
||||
assert len(reg._clients) == 0
|
||||
|
||||
def test_has_alias(self) -> None:
|
||||
reg = self._make_registry()
|
||||
assert reg.has_alias("default")
|
||||
assert reg.has_alias("openai")
|
||||
assert not reg.has_alias("nonexistent")
|
||||
|
||||
def test_concurrent_get_client(self) -> None:
|
||||
"""Thread-safe lazy client creation under concurrency."""
|
||||
import concurrent.futures
|
||||
|
||||
reg = self._make_registry()
|
||||
clients: list[Any] = []
|
||||
|
||||
def get_it() -> Any:
|
||||
return reg.get_client("default")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
|
||||
futs = [pool.submit(get_it) for _ in range(20)]
|
||||
clients = [f.result() for f in futs]
|
||||
|
||||
# All threads should get the same client instance
|
||||
assert all(c is clients[0] for c in clients)
|
||||
assert len(reg._clients) == 1
|
||||
|
||||
def test_fallback_stored(self) -> None:
|
||||
reg = self._make_registry(fallback=["openai", "cheap"])
|
||||
assert reg.fallback == ["openai", "cheap"]
|
||||
|
||||
def test_agent_model_stored(self) -> None:
|
||||
reg = self._make_registry(agent_model="cheap")
|
||||
assert reg.agent_model == "cheap"
|
||||
|
||||
|
||||
class TestModelRegistryValidation:
|
||||
def test_empty_models_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="at least one"):
|
||||
ModelRegistry(models={}, default="x")
|
||||
|
||||
def test_invalid_default_raises(self) -> None:
|
||||
models = {"a": ModelConfig("a", "x", "x", "x")}
|
||||
with pytest.raises(ValueError, match="Default model 'bad'"):
|
||||
ModelRegistry(models=models, default="bad")
|
||||
|
||||
def test_invalid_fallback_raises(self) -> None:
|
||||
models = {"a": ModelConfig("a", "x", "x", "x")}
|
||||
with pytest.raises(ValueError, match="Fallback model 'bad'"):
|
||||
ModelRegistry(models=models, default="a", fallback=["bad"])
|
||||
|
||||
def test_invalid_agent_model_raises(self) -> None:
|
||||
models = {"a": ModelConfig("a", "x", "x", "x")}
|
||||
with pytest.raises(ValueError, match="Agent model 'bad'"):
|
||||
ModelRegistry(models=models, default="a", agent_model="bad")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_model_registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadModelRegistry:
|
||||
def test_single_entry_from_args(self) -> None:
|
||||
"""No [models] config → single-entry registry from CLI args."""
|
||||
with patch("turnstone.core.model_registry.load_config", return_value={}):
|
||||
reg = load_model_registry(
|
||||
base_url="http://localhost:8000/v1",
|
||||
api_key="dummy",
|
||||
model="qwen3-32b",
|
||||
)
|
||||
assert reg.count == 1
|
||||
assert reg.default == "default"
|
||||
_, model, cfg = reg.resolve()
|
||||
assert model == "qwen3-32b"
|
||||
assert cfg.base_url == "http://localhost:8000/v1"
|
||||
|
||||
def test_models_from_config(self) -> None:
|
||||
"""[models.*] sections create additional entries."""
|
||||
fake_cfg: dict[str, Any] = {
|
||||
"models": {
|
||||
"openai": {
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-test",
|
||||
"model": "gpt-4o",
|
||||
"context_window": 128000,
|
||||
},
|
||||
},
|
||||
"model": {
|
||||
"default": "openai",
|
||||
},
|
||||
}
|
||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||
reg = load_model_registry(
|
||||
base_url="http://localhost:8000/v1",
|
||||
api_key="dummy",
|
||||
model="local-model",
|
||||
)
|
||||
assert reg.count == 2 # "openai" + "default"
|
||||
assert reg.default == "openai"
|
||||
_, model, _ = reg.resolve()
|
||||
assert model == "gpt-4o"
|
||||
|
||||
def test_fallback_from_config(self) -> None:
|
||||
fake_cfg: dict[str, Any] = {
|
||||
"models": {
|
||||
"fallback1": {
|
||||
"base_url": "http://fb1/v1",
|
||||
"model": "fb-model",
|
||||
},
|
||||
},
|
||||
"model": {
|
||||
"fallback": ["fallback1", "nonexistent"],
|
||||
},
|
||||
}
|
||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||
reg = load_model_registry("http://x/v1", "x", "x")
|
||||
# "nonexistent" is silently dropped
|
||||
assert reg.fallback == ["fallback1"]
|
||||
|
||||
def test_agent_model_from_config(self) -> None:
|
||||
fake_cfg: dict[str, Any] = {
|
||||
"models": {
|
||||
"cheap": {
|
||||
"base_url": "http://cheap/v1",
|
||||
"model": "cheap-model",
|
||||
},
|
||||
},
|
||||
"model": {
|
||||
"agent_model": "cheap",
|
||||
},
|
||||
}
|
||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||
reg = load_model_registry("http://x/v1", "x", "x")
|
||||
assert reg.agent_model == "cheap"
|
||||
|
||||
def test_invalid_agent_model_ignored(self) -> None:
|
||||
fake_cfg: dict[str, Any] = {
|
||||
"model": {"agent_model": "nonexistent"},
|
||||
}
|
||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||
reg = load_model_registry("http://x/v1", "x", "x")
|
||||
assert reg.agent_model is None
|
||||
|
||||
def test_invalid_default_falls_back(self) -> None:
|
||||
fake_cfg: dict[str, Any] = {
|
||||
"model": {"default": "nonexistent"},
|
||||
}
|
||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||
reg = load_model_registry("http://x/v1", "x", "x")
|
||||
assert reg.default == "default"
|
||||
|
||||
def test_empty_model_name_skipped(self) -> None:
|
||||
"""Config entries without a model name are skipped."""
|
||||
fake_cfg: dict[str, Any] = {
|
||||
"models": {
|
||||
"bad": {"base_url": "http://bad/v1"}, # no model key
|
||||
"good": {"base_url": "http://good/v1", "model": "good-model"},
|
||||
},
|
||||
}
|
||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||
reg = load_model_registry("http://x/v1", "x", "x")
|
||||
assert not reg.has_alias("bad")
|
||||
assert reg.has_alias("good")
|
||||
|
||||
def test_unknown_fallback_logged_and_dropped(self) -> None:
|
||||
fake_cfg: dict[str, Any] = {
|
||||
"model": {"fallback": ["good", "bad"]},
|
||||
"models": {
|
||||
"good": {"base_url": "http://g/v1", "model": "g-model"},
|
||||
},
|
||||
}
|
||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||
reg = load_model_registry("http://x/v1", "x", "x")
|
||||
assert reg.fallback == ["good"]
|
||||
|
||||
def test_models_inherit_cli_args(self) -> None:
|
||||
"""Model entries without base_url/api_key inherit from CLI args."""
|
||||
fake_cfg: dict[str, Any] = {
|
||||
"models": {
|
||||
"alt": {
|
||||
"model": "alt-model",
|
||||
},
|
||||
},
|
||||
}
|
||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||
reg = load_model_registry("http://base/v1", "my-key", "default-model")
|
||||
alt_cfg = reg.get_config("alt")
|
||||
assert alt_cfg.base_url == "http://base/v1"
|
||||
assert alt_cfg.api_key == "my-key"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeUI:
|
||||
"""Minimal SessionUI stub for testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.infos: list[str] = []
|
||||
self.errors: list[str] = []
|
||||
|
||||
def on_thinking_start(self) -> None: ...
|
||||
def on_thinking_stop(self) -> None: ...
|
||||
def on_reasoning_token(self, text: str) -> None: ...
|
||||
def on_content_token(self, text: str) -> None: ...
|
||||
def on_stream_end(self) -> None: ...
|
||||
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id: str, name: str, output: str) -> None: ...
|
||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
|
||||
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ...
|
||||
def on_plan_review(self, content: str) -> str:
|
||||
return "approve"
|
||||
|
||||
def on_info(self, message: str) -> None:
|
||||
self.infos.append(message)
|
||||
|
||||
def on_error(self, message: str) -> None:
|
||||
self.errors.append(message)
|
||||
|
||||
def on_state_change(self, state: str) -> None: ...
|
||||
def on_rename(self, name: str) -> None: ...
|
||||
|
||||
|
||||
def _make_session(
|
||||
registry: ModelRegistry | None = None,
|
||||
model_alias: str | None = None,
|
||||
) -> Any:
|
||||
"""Create a ChatSession with a mock client and optional registry."""
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
client = MagicMock()
|
||||
return ChatSession(
|
||||
client=client,
|
||||
model="test-model",
|
||||
ui=_FakeUI(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=4096,
|
||||
tool_timeout=30,
|
||||
registry=registry,
|
||||
model_alias=model_alias,
|
||||
)
|
||||
|
||||
|
||||
class TestSessionModelCommand:
|
||||
def test_model_show_without_registry(self) -> None:
|
||||
session = _make_session()
|
||||
session.handle_command("/model")
|
||||
assert "test-model" in session.ui.infos[-1]
|
||||
|
||||
def test_model_show_with_registry(self) -> None:
|
||||
reg = ModelRegistry(
|
||||
models={
|
||||
"default": ModelConfig("default", "x", "x", "test-model"),
|
||||
"alt": ModelConfig("alt", "y", "y", "alt-model"),
|
||||
},
|
||||
default="default",
|
||||
)
|
||||
session = _make_session(registry=reg, model_alias="default")
|
||||
session.handle_command("/model")
|
||||
info = session.ui.infos[-1]
|
||||
assert "test-model" in info
|
||||
assert "default" in info
|
||||
assert "alt" in info
|
||||
|
||||
def test_model_switch(self) -> None:
|
||||
reg = ModelRegistry(
|
||||
models={
|
||||
"default": ModelConfig("default", "x", "x", "default-model"),
|
||||
"alt": ModelConfig("alt", "y", "y", "alt-model", context_window=64000),
|
||||
},
|
||||
default="default",
|
||||
)
|
||||
session = _make_session(registry=reg, model_alias="default")
|
||||
session.handle_command("/model alt")
|
||||
assert session.model == "alt-model"
|
||||
assert session.model_alias == "alt"
|
||||
assert session.context_window == 64000
|
||||
assert "Switched to" in session.ui.infos[-1]
|
||||
|
||||
def test_model_switch_unknown_alias(self) -> None:
|
||||
reg = ModelRegistry(
|
||||
models={"default": ModelConfig("default", "x", "x", "test-model")},
|
||||
default="default",
|
||||
)
|
||||
session = _make_session(registry=reg, model_alias="default")
|
||||
session.handle_command("/model nonexistent")
|
||||
assert "Unknown model alias" in session.ui.infos[-1]
|
||||
|
||||
def test_model_switch_without_registry(self) -> None:
|
||||
session = _make_session()
|
||||
session.handle_command("/model something")
|
||||
assert "Unknown model alias" in session.ui.infos[-1]
|
||||
|
||||
def test_model_show_fallback_info(self) -> None:
|
||||
reg = ModelRegistry(
|
||||
models={
|
||||
"a": ModelConfig("a", "x", "x", "m-a"),
|
||||
"b": ModelConfig("b", "y", "y", "m-b"),
|
||||
},
|
||||
default="a",
|
||||
fallback=["b"],
|
||||
agent_model="b",
|
||||
)
|
||||
session = _make_session(registry=reg, model_alias="a")
|
||||
session.handle_command("/model")
|
||||
info = session.ui.infos[-1]
|
||||
assert "Fallback: b" in info
|
||||
assert "Agent model: b" in info
|
||||
|
||||
|
||||
class TestSessionFallback:
|
||||
def test_fallback_on_primary_failure(self) -> None:
|
||||
reg = ModelRegistry(
|
||||
models={
|
||||
"primary": ModelConfig("primary", "http://p/v1", "k", "p-model"),
|
||||
"fallback": ModelConfig("fallback", "http://f/v1", "k", "f-model"),
|
||||
},
|
||||
default="primary",
|
||||
fallback=["fallback"],
|
||||
)
|
||||
session = _make_session(registry=reg, model_alias="primary")
|
||||
|
||||
# _try_stream: first call (primary) raises, second call (fallback) succeeds
|
||||
call_count = 0
|
||||
|
||||
def fake_try_stream(client: Any, model: str, msgs: Any) -> str:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise ConnectionError("Primary down")
|
||||
return "fallback_response"
|
||||
|
||||
session._try_stream = fake_try_stream # type: ignore[assignment]
|
||||
result = session._create_stream_with_retry([{"role": "user", "content": "hi"}])
|
||||
assert result == "fallback_response"
|
||||
assert call_count == 2
|
||||
assert any("falling back" in i for i in session.ui.infos)
|
||||
|
||||
def test_no_fallback_without_registry(self) -> None:
|
||||
session = _make_session()
|
||||
|
||||
def fake_try_stream(client: Any, model: str, msgs: Any) -> str:
|
||||
raise ConnectionError("Down")
|
||||
|
||||
session._try_stream = fake_try_stream # type: ignore[assignment]
|
||||
with pytest.raises(ConnectionError):
|
||||
session._create_stream_with_retry([{"role": "user", "content": "hi"}])
|
||||
|
||||
|
||||
class TestSessionAgentModel:
|
||||
def test_agent_model_resolved(self) -> None:
|
||||
reg = ModelRegistry(
|
||||
models={
|
||||
"main": ModelConfig("main", "http://m/v1", "k", "main-model"),
|
||||
"agent": ModelConfig("agent", "http://a/v1", "k", "agent-model"),
|
||||
},
|
||||
default="main",
|
||||
agent_model="agent",
|
||||
)
|
||||
session = _make_session(registry=reg, model_alias="main")
|
||||
|
||||
# Mock the API to capture what model was used
|
||||
captured_model = None
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "done"
|
||||
mock_response.choices[0].message.tool_calls = None
|
||||
mock_response.choices[0].finish_reason = "stop"
|
||||
|
||||
def fake_create(**kwargs: Any) -> Any:
|
||||
nonlocal captured_model
|
||||
captured_model = kwargs.get("model")
|
||||
return mock_response
|
||||
|
||||
# Get the agent client from the registry and patch it
|
||||
agent_client = reg.get_client("agent")
|
||||
agent_client.chat.completions.create = fake_create
|
||||
|
||||
agent_msgs = [
|
||||
{"role": "developer", "content": "You are an agent."},
|
||||
{"role": "user", "content": "Do something."},
|
||||
]
|
||||
session._run_agent(agent_msgs)
|
||||
assert captured_model == "agent-model"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workstream integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWorkstreamModelParam:
|
||||
def test_create_with_model(self) -> None:
|
||||
"""WorkstreamManager.create passes model_alias to session_factory."""
|
||||
from turnstone.core.workstream import WorkstreamManager
|
||||
|
||||
captured_alias = None
|
||||
|
||||
def factory(ui: Any, model_alias: str | None = None) -> Any:
|
||||
nonlocal captured_alias
|
||||
captured_alias = model_alias
|
||||
mock_session = MagicMock()
|
||||
mock_session.session_id = "test123"
|
||||
return mock_session
|
||||
|
||||
mgr = WorkstreamManager(factory)
|
||||
mgr.create(name="test", model="openai")
|
||||
assert captured_alias == "openai"
|
||||
|
||||
def test_create_without_model(self) -> None:
|
||||
captured_alias = None
|
||||
|
||||
def factory(ui: Any, model_alias: str | None = None) -> Any:
|
||||
nonlocal captured_alias
|
||||
captured_alias = model_alias
|
||||
mock_session = MagicMock()
|
||||
mock_session.session_id = "test123"
|
||||
return mock_session
|
||||
|
||||
from turnstone.core.workstream import WorkstreamManager
|
||||
|
||||
mgr = WorkstreamManager(factory)
|
||||
mgr.create(name="test")
|
||||
assert captured_alias is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProtocolModel:
|
||||
def test_create_workstream_message_has_model(self) -> None:
|
||||
from turnstone.mq.protocol import CreateWorkstreamMessage
|
||||
|
||||
msg = CreateWorkstreamMessage(name="test", model="openai")
|
||||
assert msg.model == "openai"
|
||||
|
||||
def test_create_workstream_message_default(self) -> None:
|
||||
from turnstone.mq.protocol import CreateWorkstreamMessage
|
||||
|
||||
msg = CreateWorkstreamMessage(name="test")
|
||||
assert msg.model == ""
|
||||
|
||||
def test_round_trip(self) -> None:
|
||||
from turnstone.mq.protocol import CreateWorkstreamMessage, InboundMessage
|
||||
|
||||
msg = CreateWorkstreamMessage(name="ws1", model="local")
|
||||
raw = msg.to_json()
|
||||
restored = InboundMessage.from_json(raw)
|
||||
assert isinstance(restored, CreateWorkstreamMessage)
|
||||
assert restored.model == "local"
|
||||
assert restored.name == "ws1"
|
||||
@@ -20,7 +20,7 @@ class FakeSession:
|
||||
self.messages = []
|
||||
|
||||
|
||||
def _fake_factory(ui):
|
||||
def _fake_factory(ui, model_alias=None):
|
||||
return FakeSession()
|
||||
|
||||
|
||||
|
||||
+22
-7
@@ -845,39 +845,50 @@ def main() -> None:
|
||||
# Set up readline
|
||||
setup_readline()
|
||||
|
||||
# Create client
|
||||
# Create client and detect model
|
||||
api_key = args.api_key or os.environ.get("OPENAI_API_KEY") or "dummy"
|
||||
client = OpenAI(
|
||||
base_url=args.base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
# Detect or use provided model
|
||||
model = args.model or detect_model(client)
|
||||
|
||||
# Build model registry (reads [models.*] sections from config.toml)
|
||||
from turnstone.core.model_registry import load_model_registry
|
||||
|
||||
registry = load_model_registry(
|
||||
base_url=args.base_url,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
context_window=args.context_window,
|
||||
)
|
||||
|
||||
# Initialize MCP client (connects to configured MCP servers, if any)
|
||||
from turnstone.core.mcp_client import create_mcp_client
|
||||
|
||||
mcp_client = create_mcp_client(getattr(args, "mcp_config", None))
|
||||
|
||||
# Session factory — captures shared config for creating workstream sessions
|
||||
def session_factory(ui: SessionUI | None) -> ChatSession:
|
||||
def session_factory(ui: SessionUI | None, model_alias: str | None = None) -> ChatSession:
|
||||
assert ui is not None, "session_factory requires a non-None UI"
|
||||
r_client, r_model, r_cfg = registry.resolve(model_alias)
|
||||
return ChatSession(
|
||||
client=client,
|
||||
model=model,
|
||||
client=r_client,
|
||||
model=r_model,
|
||||
ui=ui,
|
||||
instructions=args.instructions,
|
||||
temperature=args.temperature,
|
||||
max_tokens=args.max_tokens,
|
||||
tool_timeout=args.tool_timeout,
|
||||
reasoning_effort=args.reasoning_effort,
|
||||
context_window=args.context_window,
|
||||
context_window=r_cfg.context_window,
|
||||
compact_max_tokens=args.compact_max_tokens,
|
||||
auto_compact_pct=args.auto_compact_pct,
|
||||
agent_max_turns=args.agent_max_turns,
|
||||
tool_truncation=args.tool_truncation,
|
||||
mcp_client=mcp_client,
|
||||
registry=registry,
|
||||
model_alias=model_alias or registry.default,
|
||||
)
|
||||
|
||||
# Create workstream manager and initial workstream
|
||||
@@ -921,6 +932,9 @@ def main() -> None:
|
||||
|
||||
# Print banner
|
||||
print(f"\n{bold('Chat')} with {cyan(model)}")
|
||||
if registry.count > 1:
|
||||
others = [a for a in registry.list_aliases() if a != registry.default]
|
||||
print(f"Models: {registry.default} (default), {', '.join(others)}")
|
||||
if mcp_client:
|
||||
mcp_tools = mcp_client.get_tools()
|
||||
if mcp_tools:
|
||||
@@ -980,6 +994,7 @@ def main() -> None:
|
||||
|
||||
if mcp_client:
|
||||
mcp_client.shutdown()
|
||||
registry.shutdown()
|
||||
|
||||
print("Goodbye.")
|
||||
|
||||
|
||||
@@ -753,6 +753,8 @@ function renderWsTable(container, wsList) {
|
||||
row.setAttribute("tabindex", "0");
|
||||
row.setAttribute("role", "button");
|
||||
var ariaLabel = sd.label + ": " + (ws.name || ws.id || "unnamed");
|
||||
if (ws.model_alias || ws.model)
|
||||
ariaLabel += ", model: " + (ws.model_alias || ws.model);
|
||||
if (ws.node) ariaLabel += " on " + ws.node;
|
||||
if (ws.title) ariaLabel += ", task: " + ws.title;
|
||||
if (ws.tokens) ariaLabel += ", " + formatTokens(ws.tokens) + " tokens";
|
||||
@@ -785,6 +787,13 @@ function renderWsTable(container, wsList) {
|
||||
nameCell.textContent = ws.name || ws.id || "";
|
||||
main.appendChild(nameCell);
|
||||
|
||||
// MODEL
|
||||
var modelCell = document.createElement("span");
|
||||
modelCell.className = "dash-cell-model";
|
||||
modelCell.textContent = ws.model_alias || ws.model || "";
|
||||
if (ws.model) modelCell.title = ws.model;
|
||||
main.appendChild(modelCell);
|
||||
|
||||
// NODE (clickable)
|
||||
var nodeCell = document.createElement("span");
|
||||
nodeCell.className = "dash-cell-node";
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<div class="dash-colheaders" aria-hidden="true">
|
||||
<span class="dash-col dash-col-state">STATE</span>
|
||||
<span class="dash-col dash-col-name">NAME</span>
|
||||
<span class="dash-col dash-col-model">MODEL</span>
|
||||
<span class="dash-col dash-col-node">NODE</span>
|
||||
<span class="dash-col dash-col-task">TASK</span>
|
||||
<span class="dash-col dash-col-tokens">TOKENS</span>
|
||||
@@ -60,6 +61,7 @@
|
||||
<div class="dash-colheaders" aria-hidden="true">
|
||||
<span class="dash-col dash-col-state">STATE</span>
|
||||
<span class="dash-col dash-col-name">NAME</span>
|
||||
<span class="dash-col dash-col-model">MODEL</span>
|
||||
<span class="dash-col dash-col-node">NODE</span>
|
||||
<span class="dash-col dash-col-task">TASK</span>
|
||||
<span class="dash-col dash-col-tokens">TOKENS</span>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
--code-bg: #0d1117;
|
||||
--radius: 6px;
|
||||
--radius-sm: 3px;
|
||||
--dash-grid: 72px 120px 100px 1fr 60px 48px;
|
||||
--dash-grid: 72px 120px 90px 100px 1fr 60px 48px;
|
||||
|
||||
/* Typography */
|
||||
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Cascadia Code', monospace;
|
||||
@@ -610,6 +610,7 @@ body {
|
||||
|
||||
.dash-cell-name { font-weight: 500; color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-row[data-state="idle"] .dash-cell-name { color: var(--fg-dim); }
|
||||
.dash-cell-model { color: var(--fg-dim); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.dash-cell-node {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
@@ -722,7 +723,7 @@ body {
|
||||
========================================================================== */
|
||||
@media (max-width: 700px) {
|
||||
:root { --dash-grid: 68px 110px 1fr 56px 44px; }
|
||||
.dash-col-node, .dash-cell-node { display: none; }
|
||||
.dash-col-model, .dash-cell-model, .dash-col-node, .dash-cell-node { display: none; }
|
||||
.node-colheaders, .node-row { grid-template-columns: 1fr 40px 40px 40px 60px; }
|
||||
.node-group-header { grid-template-columns: 1fr 40px 40px 40px 60px; }
|
||||
.node-group-header .node-group-cell:last-child { display: none; }
|
||||
@@ -731,7 +732,7 @@ body {
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
:root { --dash-grid: 50px 1fr 50px; }
|
||||
.dash-col-node, .dash-cell-node, .dash-col-task, .dash-cell-task, .dash-col-ctx, .dash-cell-ctx { display: none; }
|
||||
.dash-col-model, .dash-cell-model, .dash-col-node, .dash-cell-node, .dash-col-task, .dash-cell-task, .dash-col-ctx, .dash-cell-ctx { display: none; }
|
||||
#cluster-status-bar { height: auto; flex-wrap: wrap; padding: 8px 12px; gap: 6px; }
|
||||
.csb-states { flex-wrap: wrap; gap: 2px; }
|
||||
.csb-state { padding: 4px 6px; font-size: 11px; }
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Model registry — named model configurations with fallback routing.
|
||||
|
||||
Manages multiple OpenAI-compatible API backends so workstreams can select
|
||||
their model at creation time or switch mid-session. Supports a fallback
|
||||
chain for resilience when the primary model is unreachable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from turnstone.core.config import load_config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelConfig:
|
||||
"""Immutable configuration for a single model endpoint."""
|
||||
|
||||
alias: str
|
||||
base_url: str
|
||||
api_key: str = field(repr=False)
|
||||
model: str
|
||||
context_window: int = 131072
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModelRegistry:
|
||||
"""Holds named model configurations with thread-safe lazy client creation.
|
||||
|
||||
Args:
|
||||
models: Mapping of alias → ModelConfig.
|
||||
default: Alias of the default model.
|
||||
fallback: Ordered list of aliases to try when the primary model fails.
|
||||
agent_model: Optional alias for plan/task sub-agents.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
models: dict[str, ModelConfig],
|
||||
default: str,
|
||||
fallback: list[str] | None = None,
|
||||
agent_model: str | None = None,
|
||||
) -> None:
|
||||
if not models:
|
||||
raise ValueError("ModelRegistry requires at least one model config")
|
||||
if default not in models:
|
||||
raise ValueError(f"Default model '{default}' not found in registry")
|
||||
if fallback:
|
||||
for alias in fallback:
|
||||
if alias not in models:
|
||||
raise ValueError(f"Fallback model '{alias}' not found in registry")
|
||||
if agent_model and agent_model not in models:
|
||||
raise ValueError(f"Agent model '{agent_model}' not found in registry")
|
||||
|
||||
self._models = dict(models)
|
||||
self.default = default
|
||||
self.fallback = list(fallback) if fallback else []
|
||||
self.agent_model = agent_model
|
||||
self._clients: dict[str, OpenAI] = {}
|
||||
self._client_lock = threading.Lock()
|
||||
|
||||
# -- query methods -------------------------------------------------------
|
||||
|
||||
def get_client(self, alias: str) -> OpenAI:
|
||||
"""Get or lazily create an OpenAI client for *alias*. Thread-safe."""
|
||||
if alias not in self._models:
|
||||
raise ValueError(f"Unknown model alias: {alias}")
|
||||
with self._client_lock:
|
||||
if alias not in self._clients:
|
||||
cfg = self._models[alias]
|
||||
self._clients[alias] = OpenAI(
|
||||
base_url=cfg.base_url,
|
||||
api_key=cfg.api_key,
|
||||
)
|
||||
return self._clients[alias]
|
||||
|
||||
def get_config(self, alias: str) -> ModelConfig:
|
||||
"""Return the ModelConfig for *alias*."""
|
||||
if alias not in self._models:
|
||||
raise ValueError(f"Unknown model alias: {alias}")
|
||||
return self._models[alias]
|
||||
|
||||
def has_alias(self, alias: str) -> bool:
|
||||
"""Check if *alias* exists in the registry."""
|
||||
return alias in self._models
|
||||
|
||||
def list_aliases(self) -> list[str]:
|
||||
"""Return all registered model aliases."""
|
||||
return list(self._models.keys())
|
||||
|
||||
def resolve(self, alias: str | None = None) -> tuple[OpenAI, str, ModelConfig]:
|
||||
"""Resolve *alias* to ``(client, model_name, config)``.
|
||||
|
||||
Uses the default alias when *alias* is ``None``.
|
||||
"""
|
||||
alias = alias or self.default
|
||||
cfg = self.get_config(alias)
|
||||
return self.get_client(alias), cfg.model, cfg
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
"""Number of registered models."""
|
||||
return len(self._models)
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Close all OpenAI client connections."""
|
||||
with self._client_lock:
|
||||
for client in self._clients.values():
|
||||
client.close()
|
||||
self._clients.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loading from config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_model_registry(
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
context_window: int = 131072,
|
||||
) -> ModelRegistry:
|
||||
"""Build a ModelRegistry from CLI args and ``config.toml``.
|
||||
|
||||
Precedence:
|
||||
|
||||
1. ``[models.*]`` sections in config.toml define named models.
|
||||
2. CLI ``--base-url`` / ``--api-key`` / ``--model`` always create a
|
||||
``"default"`` entry (overrides any ``[models.default]`` section).
|
||||
3. ``[model].default``, ``[model].fallback``, ``[model].agent_model``
|
||||
control routing.
|
||||
4. If no ``[models.*]`` sections exist, a single-entry registry is built
|
||||
from the CLI args.
|
||||
"""
|
||||
cfg = load_config()
|
||||
models_section: dict[str, Any] = cfg.get("models", {})
|
||||
model_section: dict[str, Any] = cfg.get("model", {})
|
||||
|
||||
configs: dict[str, ModelConfig] = {}
|
||||
|
||||
# Build configs from [models.*] sections
|
||||
for alias, entry in models_section.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
model_name = entry.get("model", "")
|
||||
if not model_name:
|
||||
log.warning("Model entry '%s' has no model name, skipping", alias)
|
||||
continue
|
||||
configs[alias] = ModelConfig(
|
||||
alias=alias,
|
||||
base_url=entry.get("base_url", base_url),
|
||||
api_key=entry.get("api_key", api_key),
|
||||
model=model_name,
|
||||
context_window=entry.get("context_window", context_window),
|
||||
)
|
||||
|
||||
# Ensure a "default" entry from CLI args
|
||||
configs["default"] = ModelConfig(
|
||||
alias="default",
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
context_window=context_window,
|
||||
)
|
||||
|
||||
# Determine default alias
|
||||
default_alias = model_section.get("default", "default")
|
||||
if default_alias not in configs:
|
||||
log.warning("Configured default model '%s' not found, using 'default'", default_alias)
|
||||
default_alias = "default"
|
||||
|
||||
# Fallback chain
|
||||
fallback_raw = model_section.get("fallback", [])
|
||||
fallback: list[str] = []
|
||||
if isinstance(fallback_raw, list):
|
||||
for alias in fallback_raw:
|
||||
if alias in configs:
|
||||
fallback.append(alias)
|
||||
else:
|
||||
log.warning("Fallback alias '%s' not found in models, ignoring", alias)
|
||||
|
||||
# Agent model
|
||||
agent_model = model_section.get("agent_model")
|
||||
if agent_model and agent_model not in configs:
|
||||
log.warning("Configured agent_model '%s' not found, ignoring", agent_model)
|
||||
agent_model = None
|
||||
|
||||
return ModelRegistry(
|
||||
models=configs,
|
||||
default=default_alias,
|
||||
fallback=fallback,
|
||||
agent_model=agent_model,
|
||||
)
|
||||
@@ -63,6 +63,7 @@ if TYPE_CHECKING:
|
||||
from openai import OpenAI
|
||||
|
||||
from turnstone.core.mcp_client import MCPClientManager
|
||||
from turnstone.core.model_registry import ModelRegistry
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionUI protocol — the contract every frontend must implement
|
||||
@@ -108,9 +109,13 @@ class ChatSession:
|
||||
agent_max_turns: int = -1,
|
||||
tool_truncation: int = 0,
|
||||
mcp_client: MCPClientManager | None = None,
|
||||
registry: ModelRegistry | None = None,
|
||||
model_alias: str | None = None,
|
||||
):
|
||||
self.client = client
|
||||
self.model = model
|
||||
self._registry = registry
|
||||
self._model_alias = model_alias
|
||||
self.ui = ui
|
||||
self.instructions = instructions
|
||||
self.temperature = temperature
|
||||
@@ -123,6 +128,7 @@ class ChatSession:
|
||||
self.agent_max_turns = agent_max_turns
|
||||
self._chars_per_token = 4.0 # calibrated from API usage
|
||||
# Tool output truncation: 0 means auto (50% of context_window in chars)
|
||||
self._manual_tool_truncation = tool_truncation > 0
|
||||
if tool_truncation > 0:
|
||||
self.tool_truncation = tool_truncation
|
||||
else:
|
||||
@@ -158,6 +164,10 @@ class ChatSession:
|
||||
def session_id(self) -> str:
|
||||
return self._session_id
|
||||
|
||||
@property
|
||||
def model_alias(self) -> str | None:
|
||||
return self._model_alias
|
||||
|
||||
def _save_config(self) -> None:
|
||||
"""Persist LLM-affecting config so resumed sessions behave identically."""
|
||||
save_session_config(
|
||||
@@ -369,12 +379,36 @@ class ChatSession:
|
||||
_RETRY_BASE_DELAY = 1.0 # seconds
|
||||
|
||||
def _create_stream_with_retry(self, msgs: list[dict[str, Any]]) -> Any:
|
||||
"""Call chat.completions.create with retry on transient errors."""
|
||||
"""Call chat.completions.create with retry on transient errors.
|
||||
|
||||
If all retries fail and a fallback chain is configured, tries each
|
||||
fallback model in order before giving up.
|
||||
"""
|
||||
try:
|
||||
return self._try_stream(self.client, self.model, msgs)
|
||||
except Exception as primary_err:
|
||||
if not self._registry or not self._registry.fallback:
|
||||
raise
|
||||
# Try each fallback model
|
||||
for alias in self._registry.fallback:
|
||||
if alias == self._model_alias:
|
||||
continue
|
||||
try:
|
||||
fb_client, fb_model, _ = self._registry.resolve(alias)
|
||||
self.ui.on_info(f"[Primary model failed, falling back to {alias}]")
|
||||
return self._try_stream(fb_client, fb_model, msgs)
|
||||
except Exception as fb_err:
|
||||
self.ui.on_info(f"[Fallback {alias} also failed: {fb_err}]")
|
||||
continue
|
||||
raise primary_err
|
||||
|
||||
def _try_stream(self, client: Any, model: str, msgs: list[dict[str, Any]]) -> Any:
|
||||
"""Attempt a streaming API call with retries on transient errors."""
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(self._MAX_RETRIES + 1):
|
||||
try:
|
||||
return self.client.chat.completions.create( # type: ignore[call-overload]
|
||||
model=self.model,
|
||||
return client.chat.completions.create(
|
||||
model=model,
|
||||
messages=msgs,
|
||||
**({"tools": self._tools} if not self.creative_mode else {}),
|
||||
max_completion_tokens=self.max_tokens,
|
||||
@@ -2010,6 +2044,12 @@ class ChatSession:
|
||||
if reasoning_effort:
|
||||
kwargs["reasoning_effort"] = reasoning_effort
|
||||
|
||||
# Resolve agent model: use registry.agent_model if configured
|
||||
agent_client = self.client
|
||||
agent_model = self.model
|
||||
if self._registry and self._registry.agent_model:
|
||||
agent_client, agent_model, _ = self._registry.resolve(self._registry.agent_model)
|
||||
|
||||
def _api_call(
|
||||
messages: list[dict[str, Any]],
|
||||
_tools: list[dict[str, Any]] | None = tools,
|
||||
@@ -2017,8 +2057,8 @@ class ChatSession:
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(self._MAX_RETRIES + 1):
|
||||
try:
|
||||
return self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
return agent_client.chat.completions.create(
|
||||
model=agent_model,
|
||||
messages=messages, # type: ignore[arg-type]
|
||||
tools=_tools, # type: ignore[arg-type]
|
||||
max_completion_tokens=self.max_tokens,
|
||||
@@ -2733,7 +2773,34 @@ class ChatSession:
|
||||
self.ui.on_info("\n".join(lines))
|
||||
|
||||
elif cmd == "/model":
|
||||
self.ui.on_info(f"Model: {cyan(self.model)}")
|
||||
if not arg:
|
||||
info = f"Model: {cyan(self.model)}"
|
||||
if self._model_alias:
|
||||
info += f" ({self._model_alias})"
|
||||
if self._registry and self._registry.count > 1:
|
||||
avail = ", ".join(self._registry.list_aliases())
|
||||
info += f"\nAvailable: {avail}"
|
||||
if self._registry.fallback:
|
||||
info += f"\nFallback: {', '.join(self._registry.fallback)}"
|
||||
if self._registry.agent_model:
|
||||
info += f"\nAgent model: {self._registry.agent_model}"
|
||||
self.ui.on_info(info)
|
||||
elif self._registry and self._registry.has_alias(arg):
|
||||
client, model_name, cfg = self._registry.resolve(arg)
|
||||
self.client = client
|
||||
self.model = model_name
|
||||
self._model_alias = arg
|
||||
self.context_window = cfg.context_window
|
||||
if not self._manual_tool_truncation:
|
||||
self.tool_truncation = int(cfg.context_window * self._chars_per_token * 0.5)
|
||||
self._init_system_messages()
|
||||
self._save_config()
|
||||
self.ui.on_info(f"Switched to {cyan(arg)}: {model_name}")
|
||||
else:
|
||||
available = ""
|
||||
if self._registry:
|
||||
available = f" Available: {', '.join(self._registry.list_aliases())}"
|
||||
self.ui.on_info(f"Unknown model alias: {arg}.{available}")
|
||||
|
||||
elif cmd == "/raw":
|
||||
self.show_reasoning = not self.show_reasoning
|
||||
@@ -2815,7 +2882,7 @@ class ChatSession:
|
||||
" /history [query] Search conversation history (or show recent)",
|
||||
" /compact Compact conversation (summarize old messages)",
|
||||
"",
|
||||
" /model Show current model",
|
||||
" /model [alias] Show/switch model (alias from config)",
|
||||
" /raw Toggle reasoning content display",
|
||||
" /reason [low|med|high] Set/show reasoning effort",
|
||||
" /creative Toggle creative writing mode (no tools)",
|
||||
|
||||
@@ -67,15 +67,18 @@ class WorkstreamManager:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[SessionUI | None], ChatSession],
|
||||
session_factory: Callable[[SessionUI | None, str | None], ChatSession],
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
session_factory: callable(ui) -> ChatSession. Captures shared
|
||||
config (client, model, temperature, …) so the manager can
|
||||
create sessions without knowing those details.
|
||||
session_factory: callable(ui, model_alias) -> ChatSession.
|
||||
Captures shared config (registry, temperature, …) so the
|
||||
manager can create sessions without knowing those details.
|
||||
*model_alias* selects a model from the registry (None = default).
|
||||
"""
|
||||
self._session_factory: Callable[[SessionUI | None], ChatSession] = session_factory
|
||||
self._session_factory: Callable[[SessionUI | None, str | None], ChatSession] = (
|
||||
session_factory
|
||||
)
|
||||
self._workstreams: dict[str, Workstream] = {}
|
||||
self._order: list[str] = [] # creation order
|
||||
self._active_id: str | None = None
|
||||
@@ -88,12 +91,18 @@ class WorkstreamManager:
|
||||
self,
|
||||
name: str = "",
|
||||
ui_factory: Callable[..., SessionUI] | None = None,
|
||||
model: str | None = None,
|
||||
) -> Workstream:
|
||||
"""Create a new workstream. Returns the new ws."""
|
||||
"""Create a new workstream. Returns the new ws.
|
||||
|
||||
Args:
|
||||
model: Optional model alias from the registry. ``None`` uses the
|
||||
default model.
|
||||
"""
|
||||
ws = Workstream(name=name)
|
||||
if ui_factory:
|
||||
ws.ui = ui_factory(ws.id)
|
||||
ws.session = self._session_factory(ws.ui)
|
||||
ws.session = self._session_factory(ws.ui, model)
|
||||
with self._lock:
|
||||
if len(self._workstreams) >= self.MAX_WORKSTREAMS:
|
||||
raise RuntimeError(f"Maximum of {self.MAX_WORKSTREAMS} workstreams reached")
|
||||
|
||||
@@ -287,11 +287,13 @@ class Bridge:
|
||||
name = getattr(msg, "name", "")
|
||||
auto_approve = getattr(msg, "auto_approve", False)
|
||||
auto_approve_tools = getattr(msg, "auto_approve_tools", [])
|
||||
model = getattr(msg, "model", "")
|
||||
self._create_ws_on_server(
|
||||
name=name,
|
||||
auto_approve=auto_approve,
|
||||
auto_approve_tools=auto_approve_tools,
|
||||
correlation_id=msg.correlation_id,
|
||||
model=model,
|
||||
)
|
||||
|
||||
def _handle_close_ws(self, msg: InboundMessage) -> None:
|
||||
@@ -336,12 +338,16 @@ class Bridge:
|
||||
auto_approve: bool,
|
||||
auto_approve_tools: list[str],
|
||||
correlation_id: str,
|
||||
model: str = "",
|
||||
) -> str:
|
||||
"""Create a workstream on the server. Returns ws_id or empty on error."""
|
||||
try:
|
||||
payload: dict[str, Any] = {"name": name, "auto_approve": auto_approve}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
resp = self._http.post(
|
||||
"/api/workstreams/new",
|
||||
json={"name": name, "auto_approve": auto_approve},
|
||||
json=payload,
|
||||
)
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
|
||||
@@ -93,6 +93,7 @@ class CreateWorkstreamMessage(InboundMessage):
|
||||
auto_approve: bool = False
|
||||
auto_approve_tools: list[str] = field(default_factory=list)
|
||||
target_node: str = ""
|
||||
model: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+26
-7
@@ -423,6 +423,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler):
|
||||
{
|
||||
"type": "connected",
|
||||
"model": session.model,
|
||||
"model_alias": session.model_alias or "",
|
||||
"skip_permissions": ui.auto_approve,
|
||||
}
|
||||
)
|
||||
@@ -667,6 +668,7 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler):
|
||||
ws = mgr.create(
|
||||
name=body.get("name", ""),
|
||||
ui_factory=lambda wid: WebUI(ws_id=wid),
|
||||
model=body.get("model") or None,
|
||||
)
|
||||
assert isinstance(ws.ui, WebUI)
|
||||
if skip or body.get("auto_approve", False):
|
||||
@@ -776,6 +778,8 @@ class TurnstoneHTTPHandler(BaseHTTPRequestHandler):
|
||||
"activity_state": activity_state,
|
||||
"tool_calls": tc,
|
||||
"node": "local",
|
||||
"model": ws.session.model if ws.session else "",
|
||||
"model_alias": ws.session.model_alias if ws.session else "",
|
||||
}
|
||||
)
|
||||
uptime_sec = round(time.monotonic() - _metrics.start_time)
|
||||
@@ -1057,16 +1061,24 @@ def main() -> None:
|
||||
|
||||
prune_sessions(retention_days=args.session_retention_days, log_fn=print)
|
||||
|
||||
# Create OpenAI client
|
||||
# Create OpenAI client and detect model
|
||||
api_key = args.api_key or os.environ.get("OPENAI_API_KEY") or "dummy"
|
||||
client = OpenAI(
|
||||
base_url=args.base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
# Detect or use provided model
|
||||
model = args.model or detect_model(client)
|
||||
|
||||
# Build model registry (reads [models.*] sections from config.toml)
|
||||
from turnstone.core.model_registry import load_model_registry
|
||||
|
||||
registry = load_model_registry(
|
||||
base_url=args.base_url,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
context_window=args.context_window,
|
||||
)
|
||||
|
||||
# Initialize MCP client (connects to configured MCP servers, if any)
|
||||
from turnstone.core.mcp_client import create_mcp_client
|
||||
|
||||
@@ -1079,23 +1091,26 @@ def main() -> None:
|
||||
WebUI._global_queue = global_queue
|
||||
|
||||
# Session factory — captures shared config
|
||||
def session_factory(ui: SessionUI | None) -> ChatSession:
|
||||
def session_factory(ui: SessionUI | None, model_alias: str | None = None) -> ChatSession:
|
||||
assert ui is not None
|
||||
r_client, r_model, r_cfg = registry.resolve(model_alias)
|
||||
return ChatSession(
|
||||
client=client,
|
||||
model=model,
|
||||
client=r_client,
|
||||
model=r_model,
|
||||
ui=ui,
|
||||
instructions=args.instructions,
|
||||
temperature=args.temperature,
|
||||
max_tokens=args.max_tokens,
|
||||
tool_timeout=args.tool_timeout,
|
||||
reasoning_effort=args.reasoning_effort,
|
||||
context_window=args.context_window,
|
||||
context_window=r_cfg.context_window,
|
||||
compact_max_tokens=args.compact_max_tokens,
|
||||
auto_compact_pct=args.auto_compact_pct,
|
||||
agent_max_turns=args.agent_max_turns,
|
||||
tool_truncation=args.tool_truncation,
|
||||
mcp_client=mcp_client,
|
||||
registry=registry,
|
||||
model_alias=model_alias or registry.default,
|
||||
)
|
||||
|
||||
# Create workstream manager and initial workstream
|
||||
@@ -1159,6 +1174,9 @@ def main() -> None:
|
||||
|
||||
print(f"turnstone web server running on http://{args.host}:{args.port}")
|
||||
print(f"Model: {model}")
|
||||
if registry.count > 1:
|
||||
others = [a for a in registry.list_aliases() if a != registry.default]
|
||||
print(f"Models: {registry.default} (default), {', '.join(others)}")
|
||||
if mcp_client:
|
||||
mcp_tools = mcp_client.get_tools()
|
||||
if mcp_tools:
|
||||
@@ -1171,6 +1189,7 @@ def main() -> None:
|
||||
print("\nShutting down.")
|
||||
if mcp_client:
|
||||
mcp_client.shutdown()
|
||||
registry.shutdown()
|
||||
server.shutdown()
|
||||
|
||||
|
||||
|
||||
@@ -823,6 +823,8 @@ function renderDashboardTable(wsList, agg) {
|
||||
row.setAttribute("role", "button");
|
||||
row.setAttribute("tabindex", "0");
|
||||
var ariaLabel = liveName + " \u2014 " + sd.label;
|
||||
if (ws.model_alias || ws.model)
|
||||
ariaLabel += ", model: " + (ws.model_alias || ws.model);
|
||||
if (ws.title) ariaLabel += ", task: " + ws.title;
|
||||
if (ws.tokens) ariaLabel += ", " + formatTokens(ws.tokens) + " tokens";
|
||||
if (ws.context_ratio > 0)
|
||||
@@ -855,10 +857,18 @@ function renderDashboardTable(wsList, agg) {
|
||||
nameCell.textContent = liveName;
|
||||
main.appendChild(nameCell);
|
||||
|
||||
// MODEL cell
|
||||
var modelCell = document.createElement("span");
|
||||
modelCell.className = "dash-cell-model";
|
||||
modelCell.textContent = ws.model_alias || ws.model || "";
|
||||
if (ws.model) modelCell.title = ws.model;
|
||||
main.appendChild(modelCell);
|
||||
|
||||
// NODE cell
|
||||
var nodeCell = document.createElement("span");
|
||||
nodeCell.className = "dash-cell-node";
|
||||
nodeCell.textContent = ws.node || "local";
|
||||
if (ws.node) nodeCell.title = ws.node;
|
||||
main.appendChild(nodeCell);
|
||||
|
||||
// TASK cell
|
||||
@@ -1224,7 +1234,8 @@ function handleEvent(evt) {
|
||||
break;
|
||||
|
||||
case "connected":
|
||||
modelName.textContent = evt.model || "";
|
||||
modelName.textContent = evt.model_alias || evt.model || "";
|
||||
modelName.title = evt.model || "";
|
||||
if (evt.skip_permissions) {
|
||||
var existing = document.querySelector(".skip-permissions-warning");
|
||||
if (!existing) {
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<div class="dash-colheaders" aria-hidden="true">
|
||||
<span class="dash-col dash-col-state">STATE</span>
|
||||
<span class="dash-col dash-col-name">NAME</span>
|
||||
<span class="dash-col dash-col-model">MODEL</span>
|
||||
<span class="dash-col dash-col-node">NODE</span>
|
||||
<span class="dash-col dash-col-task">TASK</span>
|
||||
<span class="dash-col dash-col-tokens">TOKENS</span>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
--code-bg: #0d1117;
|
||||
--radius: 6px;
|
||||
--radius-sm: 3px;
|
||||
--dash-grid: 72px 120px 100px 1fr 60px 48px;
|
||||
--dash-grid: 72px 120px 90px 100px 1fr 60px 48px;
|
||||
|
||||
/* Typography */
|
||||
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Cascadia Code', monospace;
|
||||
@@ -792,6 +792,7 @@ body {
|
||||
|
||||
.dash-cell-name { font-weight: 500; color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-row[data-state="idle"] .dash-cell-name { color: var(--fg-dim); }
|
||||
.dash-cell-model { color: var(--fg-dim); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.dash-cell-node { color: var(--fg-dim); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-cell-task { color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-row[data-state="idle"] .dash-cell-task { color: var(--fg-dim); }
|
||||
@@ -822,7 +823,7 @@ body {
|
||||
/* Dashboard responsive */
|
||||
@media (max-width: 700px) {
|
||||
:root { --dash-grid: 68px 110px 1fr 56px 44px; }
|
||||
.dash-col-node, .dash-cell-node { display: none; }
|
||||
.dash-col-model, .dash-cell-model, .dash-col-node, .dash-cell-node { display: none; }
|
||||
.dash-row-sub { padding-left: 76px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
@@ -833,7 +834,7 @@ body {
|
||||
.dashboard-content { padding: 24px 12px 16px; }
|
||||
.dashboard-cards { grid-template-columns: 1fr; }
|
||||
:root { --dash-grid: 50px 1fr 50px; }
|
||||
.dash-col-node, .dash-cell-node, .dash-col-task, .dash-cell-task, .dash-col-ctx, .dash-cell-ctx { display: none; }
|
||||
.dash-col-model, .dash-cell-model, .dash-col-node, .dash-cell-node, .dash-col-task, .dash-cell-task, .dash-col-ctx, .dash-cell-ctx { display: none; }
|
||||
.dash-row-sub { padding-left: 66px; }
|
||||
.tool-output, .tool-output-stream { max-height: 200px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user