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:
Patrick Buckley
2026-03-02 20:58:35 -08:00
committed by GitHub
parent 5118808f24
commit 2c48f694db
22 changed files with 1100 additions and 45 deletions
+27
View File
@@ -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
+9 -2
View File
@@ -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):**
+42
View File
@@ -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
+3
View File
@@ -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
+37 -2
View File
@@ -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
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e1aebded0994beccbf1af25c5c1c216775a59a4e343bcbafe69bcc1172907c71
size 291899
oid sha256:d66025ff18c6e28ef632e2ebf8bfc71554c64bd457741fda924e90a579580c34
size 309783
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7456dcc6a68b22d841524062028298814293b0e4dfab4097c536847d1528bb8a
size 275572
oid sha256:168ed3f0a91729673526c98b0358c5ec59b511206dcfc8c227d6a2b365e4e5c5
size 322776
+587
View File
@@ -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"
+1 -1
View File
@@ -20,7 +20,7 @@ class FakeSession:
self.messages = []
def _fake_factory(ui):
def _fake_factory(ui, model_alias=None):
return FakeSession()
+22 -7
View File
@@ -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.")
+9
View File
@@ -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";
+2
View File
@@ -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>
+4 -3
View File
@@ -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; }
+212
View File
@@ -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,
)
+74 -7
View File
@@ -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)",
+16 -7
View File
@@ -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")
+7 -1
View File
@@ -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:
+1
View File
@@ -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
View File
@@ -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()
+12 -1
View File
@@ -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) {
+1
View File
@@ -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>
+4 -3
View File
@@ -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; }
}