Compare commits

..

8 Commits

Author SHA1 Message Date
Patrick Buckley 06c41f0a59 chore: bump version to 1.0.2 2026-04-03 15:40:23 -07:00
Patrick Buckley d107e6edf0 Fix/web fetch reliability (#290)
* fix: improve web_fetch reliability — strip scripts, dynamic truncation, more tokens

- strip_html() now removes <script>, <style>, <template>, <noscript>
  element content instead of just their tags
- Truncation budget scales with context window (75% in chars, 50k floor)
  and takes from the beginning only instead of head+tail splice
- max_tokens bumped from 2000 to 8192 so thinking models don't starve
  the visible extraction answer
- reasoning_effort="low" on summarization call to avoid wasting tokens
- Empty responses and empty extractions now report as tool errors

* refactor: extract _utility_completion to fix reasoning_effort duplication

Callers previously had to pass reasoning_effort both as a direct keyword
(for commercial providers) and via _provider_extra_params (for local
model servers).  This duplication was easy to get wrong — web_fetch was
already missing the direct keyword.

_utility_completion threads it through both paths from a single call,
used by title generation, compaction, and web_fetch extraction.

* fix: disable thinking when max_tokens too small, cap extraction at 500k

_reasoning_params now returns empty dict when max_tokens can't fit a
thinking budget (e.g. title gen with max_tokens=200).  Previously
produced budget_tokens >= max_tokens which is an API error on
manual-thinking Anthropic models.

Also caps web_fetch content truncation at 500k chars — the dynamic
context-window calc was producing 3M chars on 1M-context models.

* fix: clamp utility max_tokens to model output limit, add strip_html tests

_utility_completion now clamps max_tokens to the model's advertised
max_output_tokens so small/local models don't reject 8192-token
requests.

Adds 8 tests for invisible element stripping (script, style, template,
noscript) including multiline, case-insensitive, and attribute cases.

* fix: mock get_capabilities in title retry tests for _utility_completion

_utility_completion calls _get_capabilities to clamp max_tokens.  The
existing title tests mocked _provider as a bare MagicMock, so
caps.max_output_tokens was a truthy MagicMock instead of an int.  Set
get_capabilities to return a real ModelCapabilities instance.
2026-04-03 15:39:34 -07:00
Patrick Buckley 22c20a8dbd fix: share single Docker image across all compose services
Build the image once via the profileless console service and reference
it as turnstone:local from server/channel.  Prevents stale images when
users run docker compose build without --profile.
2026-04-03 15:39:34 -07:00
Patrick Buckley 179143431d fix: include prompt .md files in wheel, add wheel-completeness CI (#289) (#291)
* fix: include prompt .md files in wheel, add wheel-completeness CI (#289)

Prompt markdown files were missing from PyPI wheels since the modular
prompts refactor, causing FileNotFoundError on startup for pip-installed
users.  Add the missing include pattern and a new CI job that diffs
source-tree data files against wheel contents so omissions are caught
before merge.

* fix: sanitise ALLOW patterns in wheel-completeness check

Strip blank lines and leading whitespace from the allowlist before
passing to grep -vFxf so empty patterns cannot silently match all lines.
2026-04-03 15:39:34 -07:00
Patrick Buckley d4a6866045 fix: log clean one-liner when PostgreSQL becomes unavailable (#288)
* fix: log clean one-liner when PostgreSQL becomes unavailable

Wrap all 174 connection sites in PostgreSQLBackend through a _conn()
context manager that catches OperationalError, emits a single
database.unavailable log line (with connection URL), and suppresses
repeats until the connection is restored (database.connection_restored).

* fix: add StorageUnavailableError and cover all heartbeat loops

Address review feedback:
- Separate connect-phase from execution-phase in _conn() so that
  OperationalError during caller code (e.g. BEGIN IMMEDIATE lock
  contention) is not misclassified as a connectivity failure.
- Add StorageUnavailableError exception class so callers can
  distinguish transient DB outages without redundant tracebacks.
- Apply the same _conn() wrapper to SQLiteBackend for consistency.
- Catch StorageUnavailableError in all 7 periodic loops: watch
  runner, server heartbeat, channel heartbeat, console heartbeat,
  collector discovery, rebalancer, and scheduler.
- Guard dedup flag with threading.Lock.
- Add tests for dedup logging and PostgreSQL path.
2026-04-03 15:39:34 -07:00
Patrick Buckley c4abd62226 fix: add concurrency groups to publish workflows
Multiple CI completions for the same commit (tag push + branch push)
caused duplicate publish and docker runs. Concurrency group keyed on
head_sha ensures only one publish runs per commit.
2026-04-03 15:39:34 -07:00
Patrick Buckley b3926c372a chore: bump version to 1.0.1 2026-04-02 20:29:02 -07:00
Patrick Buckley 5efe52d433 fix: chunk IN clauses to stay within DB parameter limits (#286)
* fix: chunk IN clauses to stay within DB parameter limits

psycopg caps query parameters at 65 535 and SQLite defaults to 999.
assign_buckets, prune_workstreams, and count_skill_resources_bulk were
passing unbounded lists into single IN(...) clauses, causing
OperationalError during rebalancer runs on full-size hash rings.

Chunk sizes: 10 000 (PostgreSQL), 500 (SQLite).

* fix: deduplicate assign_buckets input, add chunking regression tests

Address review feedback: deduplicate bucket list before chunking to
prevent inflated rowcount from cross-chunk duplicates. Add tests that
exercise the multi-chunk path (1200 buckets > SQLite chunk_size of 500)
and verify dedup preserves accurate counts.
2026-04-02 20:28:54 -07:00
64 changed files with 1049 additions and 5053 deletions
+1 -9
View File
@@ -41,14 +41,6 @@
"matchStrings": ["mermaid-(?<currentValue>[\\d.]+)/"],
"depNameTemplate": "mermaid",
"datasourceTemplate": "npm"
},
{
"customType": "regex",
"description": "Track vendored hls.js version",
"managerFilePatterns": ["/pyproject\\.toml$/"],
"matchStrings": ["hls-(?<currentValue>[\\d.]+)/"],
"depNameTemplate": "hls.js",
"datasourceTemplate": "npm"
}
],
"packageRules": [
@@ -99,7 +91,7 @@
{
"description": "Vendored JS — CI workflow downloads files automatically",
"groupName": "Vendored JS",
"matchPackageNames": ["katex", "highlight.js", "mermaid", "hls.js"],
"matchPackageNames": ["katex", "highlight.js", "mermaid"],
"schedule": ["before 9am on the first day of the month"],
"automerge": false
},
-3
View File
@@ -7,9 +7,6 @@ on:
pull_request:
branches: [main, "stable/*"]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
+4 -6
View File
@@ -19,9 +19,7 @@ env:
jobs:
docker:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -43,7 +41,7 @@ jobs:
- name: Log in to GHCR
if: steps.tag.outputs.skip == 'false'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -67,12 +65,12 @@ jobs:
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
if: steps.tag.outputs.skip == 'false'
- name: Build and push
if: steps.tag.outputs.skip == 'false'
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6
with:
context: .
push: true
-19
View File
@@ -95,22 +95,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
hls.js 1.6.15
https://github.com/video-dev/hls.js
Copyright 2017 Dailymotion
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+3 -8
View File
@@ -1,10 +1,5 @@
# =============================================================================
# Turnstone Docker Compose Stack — Development
#
# This file is for local development from a git clone. It builds images
# locally from the Dockerfile. If you installed via pip/pipx, run
# `turnstone-bootstrap` instead — it writes a production compose.yaml
# that pulls pre-built images from ghcr.io.
# Turnstone Docker Compose Stack
#
# Usage:
# Infra only: docker compose up
@@ -166,7 +161,7 @@ services:
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql}
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
networks:
- turnstone-net
@@ -216,7 +211,7 @@ services:
MODEL: ${MODEL:-}
MCP_CONFIG: ${MCP_CONFIG:-}
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
TURNSTONE_DB_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
TURNSTONE_DB_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
TURNSTONE_NODE_ID: node-1
TURNSTONE_ADVERTISE_URL: http://server-1:8080
extra_hosts: ["host.docker.internal:host-gateway"]
-15
View File
@@ -547,21 +547,6 @@ expanded tools).
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
at connection time (server names with `__` are rejected).
**Resilience:** Each MCP server has an independent circuit breaker that opens
after 3 consecutive transport failures (timeouts, broken pipes, connection
resets). Cooldown uses capped exponential backoff (30 s base, 5 min max) with
per-server jitter to avoid thundering herd. Protocol-level errors (`McpError`)
from a healthy connection do not trip the breaker. When the cooldown expires
(half-open), the next operation attempt triggers automatic reconnection. Manual
`/mcp refresh` also clears the circuit on success. All sync bridge methods
(`call_tool_sync`, `read_resource_sync`, `get_prompt_sync`, `refresh_sync`)
cancel orphaned futures on timeout to prevent coroutine accumulation on the
background event loop. Push notification refreshes are debounced (5 s per
server) to protect against notification storms. The periodic refresh loop
attempts reconnection for disconnected servers with exponential backoff
(60 s1 h). Transport stream references are pre-closed before stack teardown to
work around the MCP SDK's anyio cancel-scope CPU busy-loop (SDK #2147).
**Error isolation:** Per-server connection/refresh failures are caught and logged; other
servers are unaffected. Tool execution errors return error strings to the LLM
rather than crashing the session.
+1 -28
View File
@@ -152,34 +152,10 @@ MCPMgr -> MCPSrv : prompts/get
MCPSrv --> MCPMgr : GetPromptResult
MCPMgr --> Session : messages [{role, content}]
== Resilience: Circuit Breaker & Stream Safety ==
note over MCPMgr
**Per-server circuit breaker**
CLOSED --(3 failures)--> OPEN
OPEN --(cooldown expires)--> half-open probe
Probe success --> CLOSED (trip_count decays by 1)
Probe failure --> OPEN (cooldown doubles, max 5 min)
McpError (protocol) does NOT trip breaker.
BrokenPipeError / EOFError evicts dead session.
All sync methods cancel orphaned futures on timeout.
Transport streams pre-closed before stack teardown
to avoid anyio cancel-scope CPU busy-loop (SDK #2147).
end note
Session -> MCPMgr : call_tool_sync()
MCPMgr -> MCPMgr : _cb_gate(server)\n[reject if circuit open]
MCPMgr -> MCPMgr : _cb_auto_reconnect()\n[if session gone + cooldown expired]
MCPMgr -> MCPSrv : tools/call
MCPSrv --> MCPMgr : result or error
MCPMgr -> MCPMgr : _cb_record_success()\nor _cb_record_failure()
== Three-Tier Refresh ==
group Push Notifications (debounced 5s per server)
group Push Notifications
MCPSrv -> MCPMgr : ToolListChangedNotification
MCPMgr -> MCPMgr : debounce check\n(skip if < 5s since last)
MCPMgr -> MCPMgr : _refresh_server_tools()
MCPSrv -> MCPMgr : ResourceListChangedNotification
@@ -196,9 +172,6 @@ group Periodic Polling (default 4h)
Only polls capabilities
without push support.
Staggered per-server.
Disconnected servers get
reconnect attempts with
exponential backoff (60s-1h).
end note
end
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7623df33be9baf7647ca1c2450640df57e1cd73e8be1f8168aae16e546ad683c
size 459941
oid sha256:a6b7769aa7e732ffbeb1eb7f5b65273a135fb3a78d9802ec36d3b92801c34f6b
size 427745
+1 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.1.0"
version = "1.0.2"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
@@ -80,9 +80,7 @@ include = [
"turnstone/shared_static/katex-0.16.44/**/*",
"turnstone/shared_static/hljs-11.11.1/**/*",
"turnstone/shared_static/mermaid-11.14.0/**/*",
"turnstone/shared_static/hls-1.6.15/**/*",
"turnstone/sdk/py.typed",
"turnstone/deploy/*.yaml",
]
[tool.pytest.ini_options]
+1 -28
View File
@@ -5,7 +5,6 @@
# scripts/update-vendored-js.sh katex 0.16.39
# scripts/update-vendored-js.sh hljs 11.12.0
# scripts/update-vendored-js.sh mermaid 11.14.0
# scripts/update-vendored-js.sh hls 1.6.15
#
# This script:
# 1. Downloads the new version from CDN
@@ -19,7 +18,7 @@ STATIC_DIR="turnstone/shared_static"
CDN="https://cdn.jsdelivr.net/npm"
usage() {
echo "Usage: $0 <katex|hljs|mermaid|hls> <version>"
echo "Usage: $0 <katex|hljs|mermaid> <version>"
echo "Example: $0 katex 0.16.39"
exit 1
}
@@ -148,32 +147,6 @@ case "$LIB" in
echo "Done. Old directory removed: ${OLD_DIR}"
;;
hls)
OLD_VERSION=$(detect_old_version "hls")
check_same_version "$OLD_VERSION" "$VERSION" "hls"
OLD_DIR="${STATIC_DIR}/hls-${OLD_VERSION}"
NEW_DIR="${STATIC_DIR}/hls-${VERSION}"
echo "Updating hls.js ${OLD_VERSION} -> ${VERSION}"
mkdir -p "${NEW_DIR}"
echo " Downloading hls.min.js..."
curl -sSfL "${CDN}/hls.js@${VERSION}/dist/hls.min.js" -o "${NEW_DIR}/hls.min.js"
echo " Downloading LICENSE..."
if ! curl -sSfL "${CDN}/hls.js@${VERSION}/LICENSE" -o "${NEW_DIR}/LICENSE" 2>/dev/null; then
if [[ -f "${OLD_DIR}/LICENSE" ]]; then
cp "${OLD_DIR}/LICENSE" "${NEW_DIR}/LICENSE"
else
echo " WARNING: Could not obtain LICENSE for hls.js ${VERSION}"
fi
fi
update_refs "hls-${OLD_VERSION}" "hls-${VERSION}"
rm -rf "${OLD_DIR}"
echo "Done. Old directory removed: ${OLD_DIR}"
;;
*)
echo "Unknown library: ${LIB}"
usage
+10 -10
View File
@@ -14,22 +14,22 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"@emnapi/wasi-threads": "1.2.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -39,9 +39,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"dev": true,
"license": "MIT",
"optional": true,
+1 -48
View File
@@ -19,7 +19,6 @@ from turnstone.bootstrap import (
_tool_generate_secret,
_tool_read_file,
_tool_validate_api_key,
_tool_write_compose,
_tool_write_file,
execute_tool,
)
@@ -104,52 +103,6 @@ class TestWriteFile:
assert (tmp_path / "changed.txt").read_text() == "new\n"
class TestWriteCompose:
def test_writes_compose_file(self, tmp_path: Path) -> None:
with patch("builtins.input", return_value="y"):
result = _tool_write_compose(tmp_path, {})
assert "written successfully" in result
assert "ghcr.io" in result
content = (tmp_path / "compose.yaml").read_text()
assert "ghcr.io/turnstonelabs/turnstone" in content
assert "TURNSTONE_IMAGE_TAG" in content
def test_user_declines(self, tmp_path: Path) -> None:
with patch("builtins.input", return_value="n"):
result = _tool_write_compose(tmp_path, {})
assert "declined" in result
assert not (tmp_path / "compose.yaml").exists()
def test_identical_content_skipped(self, tmp_path: Path) -> None:
# Write it once
with patch("builtins.input", return_value="y"):
_tool_write_compose(tmp_path, {})
# Second call should skip
result = _tool_write_compose(tmp_path, {})
assert "already exists" in result
def test_no_build_blocks(self, tmp_path: Path) -> None:
with patch("builtins.input", return_value="y"):
_tool_write_compose(tmp_path, {})
content = (tmp_path / "compose.yaml").read_text()
assert "build:" not in content
assert "dockerfile:" not in content.lower()
def test_overwrites_different_content(self, tmp_path: Path) -> None:
(tmp_path / "compose.yaml").write_text("old content\n")
with patch("builtins.input", return_value="y"):
result = _tool_write_compose(tmp_path, {})
assert "written successfully" in result
content = (tmp_path / "compose.yaml").read_text()
assert "ghcr.io" in content
def test_no_local_image_references(self, tmp_path: Path) -> None:
with patch("builtins.input", return_value="y"):
_tool_write_compose(tmp_path, {})
content = (tmp_path / "compose.yaml").read_text()
assert "turnstone:local" not in content
class TestGenerateSecret:
def test_default_length(self) -> None:
secret = _tool_generate_secret({})
@@ -667,7 +620,7 @@ class TestConstants:
assert func["parameters"]["type"] == "object"
def test_tool_count(self) -> None:
assert len(TOOLS) == 8
assert len(TOOLS) == 7
def test_all_tools_have_implementations(self) -> None:
from turnstone.bootstrap import TOOL_FUNCTIONS
-194
View File
@@ -8,13 +8,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# discord.utils.escape_markdown passes 'count' as positional to re.sub,
# which is deprecated in Python 3.13+. This is a discord.py bug (fixed
# in newer releases); suppress here to keep the test output clean.
pytestmark = pytest.mark.filterwarnings(
"ignore:.*'count' is passed as positional argument:DeprecationWarning"
)
discord = pytest.importorskip("discord")
@@ -893,192 +886,6 @@ class TestFormatToolResult:
assert result.count("```") == 2
# ---------------------------------------------------------------------------
# Media embed detection and rendering
# ---------------------------------------------------------------------------
class TestTryParseMedia:
"""Tests for try_parse_media in _formatter.py."""
def test_stream_url_detected(self):
import json
from turnstone.channels._formatter import try_parse_media
data = json.dumps({"stream_url": "http://jf:8096/Videos/abc/stream", "container": "mp4"})
result = try_parse_media(data)
assert result is not None
assert result["stream_url"] == "http://jf:8096/Videos/abc/stream"
def test_media_details_detected(self):
import json
from turnstone.channels._formatter import try_parse_media
data = json.dumps({"id": "abc", "name": "Test Movie", "type": "Movie", "year": 2024})
result = try_parse_media(data)
assert result is not None
assert result["name"] == "Test Movie"
def test_search_results_detected(self):
import json
from turnstone.channels._formatter import try_parse_media
data = json.dumps({"results": [{"id": "1", "name": "Hit"}], "total_count": 1})
result = try_parse_media(data)
assert result is not None
assert len(result["results"]) == 1
def test_sessions_detected(self):
import json
from turnstone.channels._formatter import try_parse_media
data = json.dumps({"sessions": [{"id": "s1", "user_name": "ptrck"}]})
result = try_parse_media(data)
assert result is not None
def test_empty_results_returns_none(self):
import json
from turnstone.channels._formatter import try_parse_media
assert try_parse_media(json.dumps({"results": []})) is None
def test_plain_text_returns_none(self):
from turnstone.channels._formatter import try_parse_media
assert try_parse_media("just a string") is None
def test_non_dict_json_returns_none(self):
from turnstone.channels._formatter import try_parse_media
assert try_parse_media("[1, 2, 3]") is None
def test_unrelated_dict_returns_none(self):
import json
from turnstone.channels._formatter import try_parse_media
assert try_parse_media(json.dumps({"foo": "bar"})) is None
class TestIsSafeImageUrl:
"""Tests for _is_safe_image_url in _formatter.py."""
def test_http_url(self):
from turnstone.channels._formatter import _is_safe_image_url
assert _is_safe_image_url("http://jellyfin:8096/Items/abc/Images/Primary") is True
def test_https_url(self):
from turnstone.channels._formatter import _is_safe_image_url
assert _is_safe_image_url("https://jellyfin.example.com/Items/abc/Images/Primary") is True
def test_ftp_rejected(self):
from turnstone.channels._formatter import _is_safe_image_url
assert _is_safe_image_url("ftp://evil.com/image.jpg") is False
def test_file_rejected(self):
from turnstone.channels._formatter import _is_safe_image_url
assert _is_safe_image_url("file:///etc/passwd") is False
def test_userinfo_rejected(self):
from turnstone.channels._formatter import _is_safe_image_url
assert _is_safe_image_url("http://user:pass@jellyfin:8096/image") is False
def test_empty_rejected(self):
from turnstone.channels._formatter import _is_safe_image_url
assert _is_safe_image_url("") is False
def test_private_ip_allowed(self):
from turnstone.channels._formatter import _is_safe_image_url
assert _is_safe_image_url("http://192.168.0.6:8096/Items/abc/Images/Primary") is True
class TestBuildMediaEmbed:
"""Tests for try_build_media_embed and embed builders."""
def test_single_item_embed_uses_web_url_not_stream_url(self):
import json
from turnstone.channels._formatter import try_parse_media
data = {
"name": "Test Movie",
"type": "Movie",
"year": 2024,
"stream_url": "http://jf:8096/Videos/abc/stream?api_key=SECRET",
"web_url": "http://jf:8096/web/#/details?id=abc",
"overview": "A test movie.",
}
parsed = try_parse_media(json.dumps(data))
assert parsed is not None
from turnstone.channels._formatter import _build_single_media_embed
embed = _build_single_media_embed(parsed, "mcp__mediamcp__get_stream_url")
# web_url should be the embed URL, never stream_url
assert embed.url == "http://jf:8096/web/#/details?id=abc"
assert "SECRET" not in str(embed.to_dict())
def test_search_results_embed_format(self):
import json
from turnstone.channels._formatter import try_parse_media
data = {
"results": [
{"name": "Movie A", "year": 2020, "type": "Movie", "runtime_minutes": 120},
{"name": "Movie B", "year": 2021, "type": "Movie"},
],
"total_count": 2,
}
parsed = try_parse_media(json.dumps(data))
from turnstone.channels._formatter import _build_search_results_embed
embed = _build_search_results_embed(parsed)
assert "Movie A" in embed.description
assert "Movie B" in embed.description
assert "2 of 2" in embed.footer.text
def test_build_media_embed_returns_none_for_plain_text(self):
from turnstone.channels._formatter import try_build_media_embed
http = MagicMock()
result = _run(try_build_media_embed("tool", "plain text", http=http))
assert result is None
def test_season_episode_string_values(self):
"""Season/episode numbers as strings should not raise."""
from turnstone.channels._formatter import _build_search_results_embed
data = {
"results": [
{
"name": "Pilot",
"type": "Episode",
"series_name": "Show",
"season_number": "1",
"episode_number": "1",
},
],
"total_count": 1,
}
embed = _build_search_results_embed(data)
assert "S01E01" in embed.description
# ---------------------------------------------------------------------------
# Thinking indicator lifecycle
# ---------------------------------------------------------------------------
@@ -1296,7 +1103,6 @@ class TestToolResultEvent:
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._http_client = MagicMock()
bot._should_auto_approve = MagicMock(return_value=False)
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
return bot
+1 -2
View File
@@ -105,8 +105,7 @@ class TestDelete:
assert store.get("tools.timeout") == defn.default
def test_returns_false_for_non_existent(self, store):
result = store.delete("tools.timeout")
assert result is False
assert store.delete("tools.timeout") is False
def test_rejects_unknown_key(self, store):
with pytest.raises(ValueError, match="Unknown setting"):
+1 -1
View File
@@ -39,7 +39,7 @@ class MockStorage:
self.services: list[dict[str, str]] = []
def list_services(self, service_type: str, max_age_seconds: int = 120) -> list[dict[str, str]]:
return list(self.services)
return [s for s in self.services if True] # all services match
# ---------------------------------------------------------------------------
+11 -11
View File
@@ -154,7 +154,7 @@ class TestSingleEdit:
)
assert result["needs_approval"]
_, msg = session._exec_edit_file(result)
call_id, msg = session._exec_edit_file(result)
assert "applied 1 edit" in msg
with open(path) as f:
assert f.read() == "foo\nbar\nbaz\n"
@@ -172,7 +172,7 @@ class TestSingleEdit:
assert result["needs_approval"]
assert "deletion" in result["preview"]
_, msg = session._exec_edit_file(result)
call_id, msg = session._exec_edit_file(result)
with open(sample_file) as f:
assert f.read() == "line1\nline2\nline4\nline5\n"
@@ -196,7 +196,7 @@ class TestBatchEdit:
assert result["needs_approval"]
assert "2 edits" in result["header"]
_, msg = session._exec_edit_file(result)
call_id, msg = session._exec_edit_file(result)
assert "applied 2 edits" in msg
with open(sample_file) as f:
assert f.read() == "first\nline2\nline3\nline4\nlast\n"
@@ -216,7 +216,7 @@ class TestBatchEdit:
)
assert result["needs_approval"]
_, msg = session._exec_edit_file(result)
call_id, msg = session._exec_edit_file(result)
assert "applied 3 edits" in msg
with open(sample_file) as f:
assert f.read() == "line1\nsecond\nthird\nfourth\nline5\n"
@@ -238,7 +238,7 @@ class TestBatchEdit:
)
assert result["needs_approval"]
_, msg = session._exec_edit_file(result)
call_id, msg = session._exec_edit_file(result)
assert "overlap" in msg.lower()
# File should be untouched
with open(path) as f:
@@ -305,7 +305,7 @@ class TestBatchEdit:
)
assert result["needs_approval"]
_, msg = session._exec_edit_file(result)
call_id, msg = session._exec_edit_file(result)
assert "applied 2 edits" in msg
with open(path) as f:
assert f.read() == "first_foo\nbar\nsecond_foo\nbaz\n"
@@ -324,7 +324,7 @@ class TestBatchEdit:
)
assert result["needs_approval"]
_, msg = session._exec_edit_file(result)
call_id, msg = session._exec_edit_file(result)
with open(sample_file) as f:
assert f.read() == "line1\nline3\nline5\n"
@@ -344,7 +344,7 @@ class TestBatchEdit:
# Single edit — no "(N edits)" count in header
assert "edits)" not in result["header"]
_, msg = session._exec_edit_file(result)
call_id, msg = session._exec_edit_file(result)
assert "applied 1 edit" in msg
with open(sample_file) as f:
assert f.read() == "line1\nline2\nmiddle\nline4\nline5\n"
@@ -414,7 +414,7 @@ class TestExecEdgeCases:
with open(sample_file, "w") as f:
f.write("completely different content\n")
_, msg = session._exec_edit_file(result)
call_id, msg = session._exec_edit_file(result)
assert "no longer found" in msg
def test_file_deleted_between_prepare_and_exec(self, session, sample_file):
@@ -431,7 +431,7 @@ class TestExecEdgeCases:
os.unlink(sample_file)
_, msg = session._exec_edit_file(result)
call_id, msg = session._exec_edit_file(result)
assert "Error" in msg
def test_batch_file_changed_partial_match(self, session, sample_file):
@@ -453,7 +453,7 @@ class TestExecEdgeCases:
with open(sample_file, "w") as f:
f.write("line1\nline2\nline3\nline4\n")
_, msg = session._exec_edit_file(result)
call_id, msg = session._exec_edit_file(result)
assert "no longer found" in msg
# line1 should NOT have been edited (atomic failure)
with open(sample_file) as f:
+3 -409
View File
@@ -3,9 +3,7 @@
from __future__ import annotations
import asyncio
import concurrent.futures
import json
import time
from contextlib import AsyncExitStack
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
@@ -143,7 +141,7 @@ class TestMcpToOpenai:
assert result["type"] == "function"
func = result["function"]
assert func["name"] == "mcp__github__search_repos"
assert func["description"] == "Search GitHub repos"
assert "[MCP: github]" in func["description"]
assert func["parameters"]["type"] == "object"
assert "query" in func["parameters"]["properties"]
@@ -166,7 +164,7 @@ class TestMcpToOpenai:
tool.description = ""
tool.inputSchema = {"type": "object", "properties": {}}
result = _mcp_to_openai("test", tool)
assert result["function"]["description"] == ""
assert result["function"]["description"] == "[MCP: test] "
# ---------------------------------------------------------------------------
@@ -306,7 +304,7 @@ class TestMCPClientManager:
def test_call_tool_sync_disconnected_server(self):
mgr = MCPClientManager({})
mgr._tool_map["mcp__dead__ping"] = ("dead", "ping")
# No session registered for "dead", no config/loop → reconnect fails
# No session registered for "dead"
with pytest.raises(RuntimeError, match="not connected"):
mgr.call_tool_sync("mcp__dead__ping", {})
@@ -1555,407 +1553,3 @@ class TestSafeCloseStack:
await MCPClientManager._safe_close_stack(stack)
asyncio.run(_run())
# ---------------------------------------------------------------------------
# Fix 1: Cancel orphaned futures on timeout
# ---------------------------------------------------------------------------
class TestFutureCancellation:
"""Verify future.cancel() is called when sync bridge methods time out."""
def _make_manager_with_session(self) -> MCPClientManager:
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
mock_session = MagicMock()
# Prevent auto-spec from creating async coroutines that trigger warnings
mock_session.call_tool = MagicMock(return_value="sentinel")
mock_session.read_resource = MagicMock(return_value="sentinel")
mock_session.get_prompt = MagicMock(return_value="sentinel")
mgr._sessions["test"] = mock_session
mgr._loop = MagicMock()
mgr._tool_map["mcp__test__search"] = ("test", "search")
mgr._resource_map["file:///a.txt"] = ("test", "file:///a.txt")
mgr._prompt_map["mcp__test__review"] = ("test", "review")
return mgr
def test_call_tool_sync_cancels_future_on_timeout(self):
mgr = self._make_manager_with_session()
mock_future = MagicMock()
mock_future.result.side_effect = concurrent.futures.TimeoutError()
with (
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
pytest.raises(TimeoutError, match="timed out"),
):
mgr.call_tool_sync("mcp__test__search", {"query": "x"}, timeout=1)
mock_future.cancel.assert_called_once()
def test_read_resource_sync_cancels_future_on_timeout(self):
mgr = self._make_manager_with_session()
mock_future = MagicMock()
mock_future.result.side_effect = concurrent.futures.TimeoutError()
with (
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
pytest.raises(TimeoutError, match="timed out"),
):
mgr.read_resource_sync("file:///a.txt", timeout=1)
mock_future.cancel.assert_called_once()
def test_get_prompt_sync_cancels_future_on_timeout(self):
mgr = self._make_manager_with_session()
mock_future = MagicMock()
mock_future.result.side_effect = concurrent.futures.TimeoutError()
with (
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
pytest.raises(TimeoutError, match="timed out"),
):
mgr.get_prompt_sync("mcp__test__review", timeout=1)
mock_future.cancel.assert_called_once()
def test_refresh_sync_cancels_future_on_timeout(self):
mgr = MCPClientManager({})
mgr._loop = MagicMock()
mock_future = MagicMock()
mock_future.result.side_effect = concurrent.futures.TimeoutError()
with (
patch.object(mgr, "_refresh_all", return_value=MagicMock()),
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
pytest.raises(TimeoutError, match="timed out"),
):
mgr.refresh_sync(timeout=1)
mock_future.cancel.assert_called_once()
# ---------------------------------------------------------------------------
# Fix 2: Per-server circuit breaker
# ---------------------------------------------------------------------------
class TestCircuitBreaker:
"""Verify per-server circuit breaker behavior."""
def test_circuit_stays_closed_below_threshold(self):
mgr = MCPClientManager({})
mgr._cb_record_failure("srv")
mgr._cb_record_failure("srv")
is_open, _ = mgr._cb_check("srv")
assert not is_open
def test_circuit_opens_at_threshold(self):
mgr = MCPClientManager({})
for _ in range(3):
mgr._cb_record_failure("srv")
is_open, cooldown_expired = mgr._cb_check("srv")
assert is_open
assert not cooldown_expired # just opened, cooldown not expired
def test_circuit_half_open_after_cooldown(self):
mgr = MCPClientManager({})
for _ in range(3):
mgr._cb_record_failure("srv")
# Simulate cooldown expiry
mgr._circuit_open_until["srv"] = time.monotonic() - 1
is_open, cooldown_expired = mgr._cb_check("srv")
assert is_open
assert cooldown_expired
def test_circuit_resets_on_success(self):
mgr = MCPClientManager({})
for _ in range(3):
mgr._cb_record_failure("srv")
assert "srv" in mgr._circuit_open_until
mgr._cb_record_success("srv")
is_open, _ = mgr._cb_check("srv")
assert not is_open
assert mgr._consecutive_failures.get("srv") is None
def test_success_decays_trip_count(self):
"""Success decays trip_count by 1 so flapping servers escalate backoff."""
mgr = MCPClientManager({})
mgr._circuit_trip_count["srv"] = 3
mgr._cb_record_success("srv")
assert mgr._circuit_trip_count["srv"] == 2
mgr._cb_record_success("srv")
assert mgr._circuit_trip_count["srv"] == 1
mgr._cb_record_success("srv")
assert "srv" not in mgr._circuit_trip_count
def test_cooldown_is_exponential(self):
mgr = MCPClientManager({})
# First trip (trip_count starts at 0)
for _ in range(3):
mgr._cb_record_failure("srv")
deadline1 = mgr._circuit_open_until["srv"]
base1 = deadline1 - time.monotonic()
# Reset circuit but keep trip_count at 1 (set by first trip)
mgr._cb_record_success("srv")
# trip_count decayed from 1 to 0 — manually set to 1 for test
mgr._circuit_trip_count["srv"] = 1
for _ in range(3):
mgr._cb_record_failure("srv")
deadline2 = mgr._circuit_open_until["srv"]
base2 = deadline2 - time.monotonic()
# Second trip should have longer cooldown (roughly 2x, within jitter)
assert base2 > base1 * 1.5
def test_cooldown_capped_at_max(self):
mgr = MCPClientManager({})
mgr._circuit_trip_count["srv"] = 100 # very high trip count
for _ in range(3):
mgr._cb_record_failure("srv")
deadline = mgr._circuit_open_until["srv"]
cooldown = deadline - time.monotonic()
# Should not exceed max (300s) + 10% jitter = 330s
assert cooldown <= mgr._CB_MAX_COOLDOWN * 1.11
def test_cb_gate_rejects_when_open(self):
mgr = MCPClientManager({})
for _ in range(3):
mgr._cb_record_failure("srv")
with pytest.raises(RuntimeError, match="circuit open"):
mgr._cb_gate("srv")
def test_cb_gate_allows_after_cooldown(self):
mgr = MCPClientManager({})
for _ in range(3):
mgr._cb_record_failure("srv")
mgr._circuit_open_until["srv"] = time.monotonic() - 1
# Should not raise
mgr._cb_gate("srv")
# Deadline should be removed (half-open probe allowed)
assert "srv" not in mgr._circuit_open_until
def test_cb_clear_removes_all_state(self):
mgr = MCPClientManager({})
for _ in range(3):
mgr._cb_record_failure("srv")
mgr._cb_clear("srv")
assert "srv" not in mgr._consecutive_failures
assert "srv" not in mgr._circuit_open_until
assert "srv" not in mgr._circuit_trip_count
@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.filterwarnings("ignore:coroutine.*was never awaited:RuntimeWarning")
def test_call_tool_sync_records_failure_on_timeout(self):
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
mock_session = MagicMock()
mock_session.call_tool = MagicMock(return_value="sentinel")
mgr._sessions["test"] = mock_session
mgr._loop = MagicMock()
mgr._tool_map["mcp__test__ping"] = ("test", "ping")
mock_future = MagicMock()
mock_future.result.side_effect = concurrent.futures.TimeoutError()
with (
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
pytest.raises(TimeoutError),
):
mgr.call_tool_sync("mcp__test__ping", {}, timeout=1)
assert mgr._consecutive_failures.get("test", 0) == 1
def test_call_tool_sync_records_success(self):
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
mock_session = MagicMock()
mock_session.call_tool = MagicMock(return_value="sentinel")
mgr._sessions["test"] = mock_session
mgr._loop = MagicMock()
mgr._tool_map["mcp__test__ping"] = ("test", "ping")
# Pre-set a failure
mgr._consecutive_failures["test"] = 2
mock_result = MagicMock()
mock_result.content = []
mock_result.isError = False
mock_future = MagicMock()
mock_future.result.return_value = mock_result
with patch("asyncio.run_coroutine_threadsafe", return_value=mock_future):
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
assert mgr._consecutive_failures.get("test") is None
def test_connection_error_evicts_session(self):
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
mock_session = MagicMock()
mock_session.call_tool = MagicMock(return_value="sentinel")
mgr._sessions["test"] = mock_session
mgr._loop = MagicMock()
mgr._tool_map["mcp__test__ping"] = ("test", "ping")
mock_future = MagicMock()
mock_future.result.side_effect = BrokenPipeError("dead")
with (
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
pytest.raises(BrokenPipeError),
):
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
assert "test" not in mgr._sessions
def test_independent_circuits_per_server(self):
mgr = MCPClientManager({})
for _ in range(3):
mgr._cb_record_failure("a")
is_open_a, _ = mgr._cb_check("a")
is_open_b, _ = mgr._cb_check("b")
assert is_open_a
assert not is_open_b
def test_mcp_error_does_not_trip_circuit(self):
"""Protocol errors (McpError) should not count as transport failures."""
from mcp import McpError
from mcp.types import ErrorData
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
mock_session = MagicMock()
mock_session.call_tool = MagicMock(return_value="sentinel")
mgr._sessions["test"] = mock_session
mgr._loop = MagicMock()
mgr._tool_map["mcp__test__ping"] = ("test", "ping")
mock_future = MagicMock()
mock_future.result.side_effect = McpError(ErrorData(code=-32601, message="tool not found"))
with (
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
pytest.raises(McpError),
):
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
# Circuit should NOT have recorded a failure
assert mgr._consecutive_failures.get("test", 0) == 0
# ---------------------------------------------------------------------------
# Fix 3: Safe transport stream pre-close
# ---------------------------------------------------------------------------
class TestSafeTransportStreams:
"""Verify stream references are stored and pre-closed."""
def test_pre_close_streams_closes_both(self):
mgr = MCPClientManager({})
stream_a = MagicMock()
stream_b = MagicMock()
mgr._server_streams["srv"] = (stream_a, stream_b)
async def _run():
await mgr._pre_close_streams("srv")
asyncio.run(_run())
stream_a.aclose.assert_called_once()
stream_b.aclose.assert_called_once()
assert "srv" not in mgr._server_streams
def test_pre_close_streams_ignores_missing(self):
mgr = MCPClientManager({})
async def _run():
await mgr._pre_close_streams("nonexistent")
asyncio.run(_run()) # should not raise
def test_pre_close_streams_suppresses_errors(self):
mgr = MCPClientManager({})
stream_a = MagicMock()
stream_a.aclose.side_effect = RuntimeError("boom")
stream_b = MagicMock()
mgr._server_streams["srv"] = (stream_a, stream_b)
async def _run():
await mgr._pre_close_streams("srv")
asyncio.run(_run()) # should not raise despite stream_a error
stream_b.aclose.assert_called_once()
def test_shutdown_clears_stream_refs(self):
mgr = MCPClientManager({})
mgr._server_streams["srv"] = (MagicMock(), MagicMock())
mgr.shutdown()
assert len(mgr._server_streams) == 0
# ---------------------------------------------------------------------------
# Fix 4: Notification debounce
# ---------------------------------------------------------------------------
class TestNotificationDebounce:
"""Verify notification-triggered refreshes are debounced."""
def test_debounce_within_window(self):
mgr = MCPClientManager({})
mgr._last_notification_refresh["srv"] = time.monotonic()
# We can't easily call _on_notification (it's a closure), so test
# the debounce logic directly via the timestamp check
now = time.monotonic()
last = mgr._last_notification_refresh.get("srv", 0.0)
assert now - last < mgr._NOTIFICATION_DEBOUNCE
def test_debounce_passes_after_window(self):
mgr = MCPClientManager({})
# Set timestamp well in the past
mgr._last_notification_refresh["srv"] = time.monotonic() - 10
now = time.monotonic()
last = mgr._last_notification_refresh.get("srv", 0.0)
assert now - last >= mgr._NOTIFICATION_DEBOUNCE
def test_debounce_is_per_server(self):
mgr = MCPClientManager({})
mgr._last_notification_refresh["srv_a"] = time.monotonic()
# srv_b has no timestamp — should pass debounce
now = time.monotonic()
last_b = mgr._last_notification_refresh.get("srv_b", 0.0)
assert now - last_b >= mgr._NOTIFICATION_DEBOUNCE
# ---------------------------------------------------------------------------
# Fix 5: Periodic refresh backoff
# ---------------------------------------------------------------------------
class TestPeriodicRefreshBackoff:
"""Verify periodic refresh backoff and auto-reconnect."""
def test_backoff_set_on_failure(self):
mgr = MCPClientManager({})
mgr._refresh_failures["srv"] = 1
# Simulate what _periodic_refresh does on failure
failures = mgr._refresh_failures.get("srv", 0) + 1
mgr._refresh_failures["srv"] = failures
backoff = min(mgr._REFRESH_BACKOFF_BASE * (2 ** (failures - 1)), mgr._REFRESH_BACKOFF_MAX)
mgr._refresh_backoff_until["srv"] = time.monotonic() + backoff
assert mgr._refresh_backoff_until["srv"] > time.monotonic()
assert failures == 2
def test_backoff_doubles(self):
mgr = MCPClientManager({})
b1 = min(mgr._REFRESH_BACKOFF_BASE * (2**0), mgr._REFRESH_BACKOFF_MAX)
b2 = min(mgr._REFRESH_BACKOFF_BASE * (2**1), mgr._REFRESH_BACKOFF_MAX)
b3 = min(mgr._REFRESH_BACKOFF_BASE * (2**2), mgr._REFRESH_BACKOFF_MAX)
assert b1 == 60
assert b2 == 120
assert b3 == 240
def test_backoff_capped(self):
mgr = MCPClientManager({})
b = min(mgr._REFRESH_BACKOFF_BASE * (2**20), mgr._REFRESH_BACKOFF_MAX)
assert b == mgr._REFRESH_BACKOFF_MAX
def test_backoff_clears_on_success(self):
mgr = MCPClientManager({})
mgr._refresh_failures["srv"] = 3
mgr._refresh_backoff_until["srv"] = time.monotonic() + 1000
# Simulate success
mgr._refresh_failures.pop("srv", None)
mgr._refresh_backoff_until.pop("srv", None)
assert "srv" not in mgr._refresh_failures
assert "srv" not in mgr._refresh_backoff_until
def test_server_status_includes_circuit_info(self):
mgr = MCPClientManager({"srv": {"type": "stdio", "command": "echo"}})
status = mgr.get_server_status("srv")
assert "circuit_open" in status
assert "consecutive_failures" in status
assert status["circuit_open"] is False
assert status["consecutive_failures"] == 0
def test_server_status_shows_open_circuit(self):
mgr = MCPClientManager({"srv": {"type": "stdio", "command": "echo"}})
for _ in range(3):
mgr._cb_record_failure("srv")
status = mgr.get_server_status("srv")
assert status["circuit_open"] is True
assert status["consecutive_failures"] == 3
+2 -6
View File
@@ -762,12 +762,8 @@ class TestSessionAgentModel:
def test_agent_model_resolved(self) -> None:
reg = ModelRegistry(
models={
"main": ModelConfig(
"main", "http://m/v1", "k", "main-model", provider="openai-compatible"
),
"agent": ModelConfig(
"agent", "http://a/v1", "k", "agent-model", provider="openai-compatible"
),
"main": ModelConfig("main", "http://m/v1", "k", "main-model"),
"agent": ModelConfig("agent", "http://a/v1", "k", "agent-model"),
},
default="main",
agent_model="agent",
+3 -2
View File
@@ -403,7 +403,7 @@ class TestMCPTemplates:
class TestResumeDeletedTemplate:
def test_resume_with_deleted_template_degrades_gracefully(self, tmp_db, caplog):
def test_resume_with_deleted_template_degrades_gracefully(self, tmp_db, capsys):
from turnstone.core.memory import save_message
from turnstone.core.storage import get_storage
@@ -430,7 +430,8 @@ class TestResumeDeletedTemplate:
content = _sys_content(session2)
assert "EPHEMERAL_CONTENT" not in content
# Warning should be logged via structlog
assert "not_found" in caplog.text
captured = capsys.readouterr()
assert "not_found" in captured.out or "not_found" in captured.err
# ---------------------------------------------------------------------------
+29 -582
View File
@@ -9,18 +9,9 @@ from unittest.mock import MagicMock, PropertyMock, patch
import pytest
from turnstone.core.providers._openai import OpenAIProvider
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
from turnstone.core.providers._openai_common import (
apply_cache_retention,
apply_temperature_and_effort,
apply_tool_search,
format_citations,
sanitize_messages,
)
from turnstone.core.providers._protocol import (
CompletionResult,
LLMProvider,
ModelCapabilities,
StreamChunk,
ToolCallDelta,
UsageInfo,
@@ -142,38 +133,38 @@ def _anthropic_event(
class TestOpenAIProvider:
"""Tests for the OpenAI Chat Completions provider adapter."""
"""Tests for the OpenAI-compatible provider adapter."""
def setup_method(self) -> None:
self.provider = OpenAIProvider()
def test_provider_name(self) -> None:
assert self.provider.provider_name == "openai-compatible"
assert self.provider.provider_name == "openai"
# -- _sanitize_messages ---------------------------------------------------
def test_sanitize_messages_none_content_no_tool_calls(self) -> None:
msgs = [{"role": "assistant", "content": None}]
assert sanitize_messages(msgs) == [{"role": "assistant", "content": ""}]
assert self.provider._sanitize_messages(msgs) == [{"role": "assistant", "content": ""}]
def test_sanitize_messages_none_content_with_tool_calls(self) -> None:
msgs = [{"role": "assistant", "content": None, "tool_calls": [{"id": "1"}]}]
result = sanitize_messages(msgs)
result = self.provider._sanitize_messages(msgs)
assert result[0]["content"] is None
assert result[0]["tool_calls"] == [{"id": "1"}]
def test_sanitize_messages_empty_string_passthrough(self) -> None:
msgs = [{"role": "assistant", "content": ""}]
assert sanitize_messages(msgs) == msgs
assert self.provider._sanitize_messages(msgs) == msgs
def test_sanitize_messages_non_assistant_unchanged(self) -> None:
msgs = [{"role": "user", "content": None}]
result = sanitize_messages(msgs)
result = self.provider._sanitize_messages(msgs)
assert result[0]["content"] is None
def test_sanitize_messages_does_not_mutate_original(self) -> None:
original = {"role": "assistant", "content": None}
sanitize_messages([original])
self.provider._sanitize_messages([original])
assert original["content"] is None
# -- convert_tools --------------------------------------------------------
@@ -1001,10 +992,10 @@ class TestProviderFactory:
"""Tests for create_provider and create_client factory functions."""
def test_create_provider_openai(self) -> None:
from turnstone.core.providers import OpenAIResponsesProvider, create_provider
from turnstone.core.providers import create_provider
provider = create_provider("openai")
assert isinstance(provider, OpenAIResponsesProvider)
assert isinstance(provider, OpenAIProvider)
assert provider.provider_name == "openai"
def test_create_provider_anthropic(self) -> None:
@@ -1049,24 +1040,6 @@ class TestProviderFactory:
assert not isinstance(NotAProvider(), LLMProvider)
def test_create_provider_openai_compatible(self) -> None:
from turnstone.core.providers import create_provider
provider = create_provider("openai-compatible")
assert isinstance(provider, OpenAIChatCompletionsProvider)
assert provider.provider_name == "openai-compatible"
def test_create_provider_openai_vs_compatible_distinct(self) -> None:
from turnstone.core.providers import OpenAIResponsesProvider, create_provider
openai_prov = create_provider("openai")
compat = create_provider("openai-compatible")
assert openai_prov is not compat
assert isinstance(openai_prov, OpenAIResponsesProvider)
assert isinstance(compat, OpenAIChatCompletionsProvider)
assert openai_prov.provider_name == "openai"
assert compat.provider_name == "openai-compatible"
def test_create_provider_returns_singleton(self) -> None:
from turnstone.core.providers import create_provider
@@ -1138,7 +1111,7 @@ class TestOpenAIParameterGating:
"""Unknown/local models should NOT receive top-level reasoning_effort."""
caps = self.provider.get_capabilities("my-local-model")
kwargs: dict[str, Any] = {}
apply_temperature_and_effort(kwargs, caps, temperature=0.7, reasoning_effort="medium")
self.provider._apply_model_params(kwargs, caps, temperature=0.7, reasoning_effort="medium")
assert "reasoning_effort" not in kwargs
assert kwargs["temperature"] == 0.7
@@ -1146,7 +1119,7 @@ class TestOpenAIParameterGating:
"""GPT-5 base: no temperature, reasoning_effort sent."""
caps = self.provider.get_capabilities("gpt-5")
kwargs: dict[str, Any] = {}
apply_temperature_and_effort(kwargs, caps, temperature=0.7, reasoning_effort="high")
self.provider._apply_model_params(kwargs, caps, temperature=0.7, reasoning_effort="high")
assert "temperature" not in kwargs
assert kwargs["reasoning_effort"] == "high"
@@ -1154,7 +1127,7 @@ class TestOpenAIParameterGating:
"""GPT-5.1: temperature only when reasoning_effort='none'."""
caps = self.provider.get_capabilities("gpt-5.1")
kwargs: dict[str, Any] = {}
apply_temperature_and_effort(kwargs, caps, temperature=0.7, reasoning_effort="none")
self.provider._apply_model_params(kwargs, caps, temperature=0.7, reasoning_effort="none")
assert kwargs["temperature"] == 0.7
assert "reasoning_effort" not in kwargs # "none" is skipped
@@ -1162,7 +1135,7 @@ class TestOpenAIParameterGating:
"""GPT-5.1: no temperature when reasoning is active."""
caps = self.provider.get_capabilities("gpt-5.1")
kwargs: dict[str, Any] = {}
apply_temperature_and_effort(kwargs, caps, temperature=0.7, reasoning_effort="high")
self.provider._apply_model_params(kwargs, caps, temperature=0.7, reasoning_effort="high")
assert "temperature" not in kwargs
assert kwargs["reasoning_effort"] == "high"
@@ -1170,7 +1143,7 @@ class TestOpenAIParameterGating:
"""O-series: no temperature, no reasoning_effort."""
caps = self.provider.get_capabilities("o3")
kwargs: dict[str, Any] = {}
apply_temperature_and_effort(kwargs, caps, temperature=0.7, reasoning_effort="medium")
self.provider._apply_model_params(kwargs, caps, temperature=0.7, reasoning_effort="medium")
assert "temperature" not in kwargs
assert "reasoning_effort" not in kwargs
@@ -1178,7 +1151,7 @@ class TestOpenAIParameterGating:
"""GPT-5 pro only supports 'high'; unsupported values fall back to default."""
caps = self.provider.get_capabilities("gpt-5-pro")
kwargs: dict[str, Any] = {}
apply_temperature_and_effort(kwargs, caps, temperature=0.7, reasoning_effort="medium")
self.provider._apply_model_params(kwargs, caps, temperature=0.7, reasoning_effort="medium")
assert "temperature" not in kwargs
assert kwargs["reasoning_effort"] == "high" # fell back to default
@@ -1186,7 +1159,7 @@ class TestOpenAIParameterGating:
"""GPT-5 pro accepts 'high' directly."""
caps = self.provider.get_capabilities("gpt-5-pro")
kwargs: dict[str, Any] = {}
apply_temperature_and_effort(kwargs, caps, temperature=0.7, reasoning_effort="high")
self.provider._apply_model_params(kwargs, caps, temperature=0.7, reasoning_effort="high")
assert kwargs["reasoning_effort"] == "high"
def test_gpt54_1m_context_and_effort(self) -> None:
@@ -1194,11 +1167,11 @@ class TestOpenAIParameterGating:
caps = self.provider.get_capabilities("gpt-5.4")
assert caps.context_window == 1050000
kwargs: dict[str, Any] = {}
apply_temperature_and_effort(kwargs, caps, temperature=0.7, reasoning_effort="none")
self.provider._apply_model_params(kwargs, caps, temperature=0.7, reasoning_effort="none")
assert kwargs["temperature"] == 0.7
assert "reasoning_effort" not in kwargs
kwargs2: dict[str, Any] = {}
apply_temperature_and_effort(kwargs2, caps, temperature=0.7, reasoning_effort="xhigh")
self.provider._apply_model_params(kwargs2, caps, temperature=0.7, reasoning_effort="xhigh")
assert "temperature" not in kwargs2
assert kwargs2["reasoning_effort"] == "xhigh"
@@ -1207,7 +1180,7 @@ class TestOpenAIParameterGating:
caps = self.provider.get_capabilities("gpt-5.4-pro")
assert caps.context_window == 1050000
kwargs: dict[str, Any] = {}
apply_temperature_and_effort(kwargs, caps, temperature=0.7, reasoning_effort="low")
self.provider._apply_model_params(kwargs, caps, temperature=0.7, reasoning_effort="low")
assert "temperature" not in kwargs
assert kwargs["reasoning_effort"] == "medium" # fell back from unsupported "low"
@@ -1854,7 +1827,7 @@ class TestOpenAIWebSearch:
ann.url_citation = citation
content = "Some search result text."
result = format_citations(content, [ann])
result = OpenAIProvider._format_citations(content, [ann])
assert "Sources:" in result
assert "[Example Page](https://example.com)" in result
@@ -1869,7 +1842,7 @@ class TestOpenAIWebSearch:
ann2.url_citation = MagicMock(title="Page Again", url="https://example.com")
content = "Text."
result = format_citations(content, [ann1, ann2])
result = OpenAIProvider._format_citations(content, [ann1, ann2])
assert result.count("example.com") == 1
def test_format_citations_skips_non_url_citation(self) -> None:
@@ -1878,7 +1851,7 @@ class TestOpenAIWebSearch:
ann.type = "something_else"
content = "Text."
result = format_citations(content, [ann])
result = OpenAIProvider._format_citations(content, [ann])
assert "Sources:" not in result
def test_format_citations_empty_title(self) -> None:
@@ -1887,7 +1860,7 @@ class TestOpenAIWebSearch:
ann.type = "url_citation"
ann.url_citation = MagicMock(title="", url="https://example.com")
result = format_citations("Text.", [ann])
result = OpenAIProvider._format_citations("Text.", [ann])
assert "https://example.com" in result
# Should not have markdown link format when title is empty
assert "[](https://example.com)" not in result
@@ -1898,7 +1871,7 @@ class TestOpenAIWebSearch:
ann.type = "url_citation"
ann.url_citation = None
result = format_citations("Text.", [ann])
result = OpenAIProvider._format_citations("Text.", [ann])
assert "Sources:" not in result
def test_apply_web_search_with_no_tools(self) -> None:
@@ -2372,7 +2345,7 @@ class TestOpenAIToolSearch:
},
]
deferred = frozenset(["mcp__slack__send"])
result = apply_tool_search(caps, tools, deferred)
result = provider._apply_tool_search(caps, tools, deferred)
assert result is not None
# bash not deferred
assert result[0].get("defer_loading") is None or result[0].get("defer_loading") is False
@@ -2384,7 +2357,7 @@ class TestOpenAIToolSearch:
tools = [
{"type": "function", "function": {"name": "bash", "description": "Run commands"}},
]
result = apply_tool_search(caps, tools, None)
result = provider._apply_tool_search(caps, tools, None)
assert result == tools
def test_apply_tool_search_no_op_on_unsupported_model(self, provider):
@@ -2393,7 +2366,7 @@ class TestOpenAIToolSearch:
{"type": "function", "function": {"name": "bash", "description": "Run commands"}},
]
deferred = frozenset(["some_tool"])
result = apply_tool_search(caps, tools, deferred)
result = provider._apply_tool_search(caps, tools, deferred)
assert result == tools
@@ -2726,14 +2699,14 @@ class TestOpenAIPromptCaching:
"""GPT-5.x models get prompt_cache_retention=24h."""
for model in ("gpt-5", "gpt-5.1", "gpt-5.2", "gpt-5.4", "gpt-5-mini", "gpt-5-pro"):
kwargs: dict[str, Any] = {}
apply_cache_retention(kwargs, model)
self.provider._apply_cache_retention(kwargs, model)
assert kwargs.get("prompt_cache_retention") == "24h", f"Failed for {model}"
def test_cache_retention_not_set_for_non_gpt5(self) -> None:
"""Non-GPT-5 models do not get cache retention."""
for model in ("o3", "o4-mini", "local-model", "gpt-4o"):
kwargs: dict[str, Any] = {}
apply_cache_retention(kwargs, model)
self.provider._apply_cache_retention(kwargs, model)
assert "prompt_cache_retention" not in kwargs, f"Unexpected retention for {model}"
def test_streaming_cached_tokens_from_usage(self) -> None:
@@ -2872,529 +2845,3 @@ class TestMetricsCacheTokens:
assert 'turnstone_tokens_total{type="cache_creation"} 800' in text
assert 'turnstone_tokens_total{type="cache_read"} 200' in text
assert 'turnstone_tokens_total{type="prompt"} 1000' in text
# ===========================================================================
# TestOpenAIResponsesProvider — Responses API provider
# ===========================================================================
class TestOpenAIResponsesProvider:
"""Tests for the OpenAI Responses API provider."""
def setup_method(self) -> None:
from turnstone.core.providers._openai_responses import OpenAIResponsesProvider
self.provider = OpenAIResponsesProvider()
def test_provider_name(self) -> None:
assert self.provider.provider_name == "openai"
def test_get_capabilities(self) -> None:
caps = self.provider.get_capabilities("gpt-5.4")
assert caps.context_window == 1050000
assert caps.supports_tool_search is True
class TestResponsesMessageConversion:
"""Tests for _convert_messages — Chat Completions format to Responses API."""
def setup_method(self) -> None:
from turnstone.core.providers._openai_responses import OpenAIResponsesProvider
self.provider = OpenAIResponsesProvider()
def test_system_message_to_instructions(self) -> None:
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
]
instructions, items = self.provider._convert_messages(messages)
assert instructions == "You are helpful."
assert len(items) == 1
assert items[0]["role"] == "user"
assert items[0]["content"] == "Hello"
def test_multiple_system_messages_concatenated(self) -> None:
messages = [
{"role": "system", "content": "Rule 1"},
{"role": "developer", "content": "Rule 2"},
{"role": "user", "content": "Hi"},
]
instructions, items = self.provider._convert_messages(messages)
assert instructions == "Rule 1\n\nRule 2"
assert len(items) == 1
def test_assistant_text_message(self) -> None:
messages = [
{"role": "assistant", "content": "Hello back"},
]
_, items = self.provider._convert_messages(messages)
assert len(items) == 1
assert items[0]["type"] == "message"
assert items[0]["role"] == "assistant"
assert items[0]["content"] == "Hello back"
def test_assistant_tool_calls(self) -> None:
messages = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"function": {"name": "read_file", "arguments": '{"path": "/tmp"}'},
}
],
},
]
_, items = self.provider._convert_messages(messages)
assert len(items) == 1
assert items[0]["type"] == "function_call"
assert items[0]["call_id"] == "call_1"
assert items[0]["name"] == "read_file"
assert items[0]["arguments"] == '{"path": "/tmp"}'
def test_tool_result(self) -> None:
messages = [
{"role": "tool", "tool_call_id": "call_1", "content": "file contents"},
]
_, items = self.provider._convert_messages(messages)
assert len(items) == 1
assert items[0]["type"] == "function_call_output"
assert items[0]["call_id"] == "call_1"
assert items[0]["output"] == "file contents"
def test_provider_content_ignored_with_store_false(self) -> None:
"""With store=False, provider_content is ignored — rebuild from content."""
provider_items = [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hi"}],
},
{"type": "function_call", "call_id": "c1", "name": "f", "arguments": "{}"},
]
messages = [
{"role": "assistant", "content": "Hi", "_provider_content": provider_items},
]
_, items = self.provider._convert_messages(messages)
# Should rebuild from content, not passthrough provider_content
assert len(items) == 1
assert items[0]["type"] == "message"
assert items[0]["content"] == "Hi"
def test_no_system_returns_none_instructions(self) -> None:
messages = [{"role": "user", "content": "Hello"}]
instructions, _ = self.provider._convert_messages(messages)
assert instructions is None
def test_assistant_with_content_and_tool_calls(self) -> None:
"""Assistant message with both text and tool calls emits separate items."""
messages = [
{
"role": "assistant",
"content": "I'll read that file",
"tool_calls": [
{
"id": "call_1",
"function": {"name": "read_file", "arguments": '{"path": "/tmp"}'},
}
],
},
]
_, items = self.provider._convert_messages(messages)
assert len(items) == 2
assert items[0]["type"] == "message"
assert items[0]["content"] == "I'll read that file"
assert items[1]["type"] == "function_call"
assert items[1]["name"] == "read_file"
class TestResponsesToolConversion:
"""Tests for _convert_tools — Chat Completions tool format to Responses API."""
def setup_method(self) -> None:
from turnstone.core.providers._openai_responses import OpenAIResponsesProvider
self.provider = OpenAIResponsesProvider()
def test_function_tool_conversion(self) -> None:
tools = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
},
}
]
caps = ModelCapabilities()
result = self.provider._convert_tools(tools, caps)
assert result is not None
assert len(result) == 1
assert result[0]["type"] == "function"
assert result[0]["name"] == "read_file"
assert result[0]["description"] == "Read a file"
assert result[0]["strict"] is False
def test_web_search_replaced_with_native(self) -> None:
tools = [
{"type": "function", "function": {"name": "web_search", "description": "Search"}},
{"type": "function", "function": {"name": "read_file", "description": "Read"}},
]
caps = ModelCapabilities(supports_web_search=True)
result = self.provider._convert_tools(tools, caps)
assert result is not None
names = [t.get("name", t.get("type")) for t in result]
assert "web_search" in names # native web_search tool
assert "read_file" in names
def test_none_tools_returns_none(self) -> None:
caps = ModelCapabilities()
assert self.provider._convert_tools(None, caps) is None
def test_defer_loading_preserved(self) -> None:
tools = [
{"type": "function", "function": {"name": "f"}, "defer_loading": True},
]
caps = ModelCapabilities()
result = self.provider._convert_tools(tools, caps)
assert result is not None
assert result[0].get("defer_loading") is True
class TestResponsesParamBuilding:
"""Tests for _build_kwargs — parameter construction for Responses API."""
def setup_method(self) -> None:
from turnstone.core.providers._openai_responses import OpenAIResponsesProvider
self.provider = OpenAIResponsesProvider()
def test_reasoning_effort_as_dict(self) -> None:
kwargs = self.provider._build_kwargs(
model="gpt-5.4",
messages=[{"role": "user", "content": "Hi"}],
tools=None,
max_tokens=4096,
temperature=0.5,
reasoning_effort="high",
deferred_names=None,
)
assert kwargs["reasoning"] == {"effort": "high"}
assert "reasoning_effort" not in kwargs
def test_no_reasoning_when_none_effort(self) -> None:
kwargs = self.provider._build_kwargs(
model="gpt-5.4",
messages=[{"role": "user", "content": "Hi"}],
tools=None,
max_tokens=4096,
temperature=0.5,
reasoning_effort="none",
deferred_names=None,
)
assert "reasoning" not in kwargs
def test_store_is_false(self) -> None:
kwargs = self.provider._build_kwargs(
model="gpt-5.4",
messages=[{"role": "user", "content": "Hi"}],
tools=None,
max_tokens=4096,
temperature=0.5,
reasoning_effort="medium",
deferred_names=None,
)
assert kwargs["store"] is False
def test_cache_retention_for_gpt5(self) -> None:
kwargs = self.provider._build_kwargs(
model="gpt-5.4",
messages=[{"role": "user", "content": "Hi"}],
tools=None,
max_tokens=4096,
temperature=0.5,
reasoning_effort="medium",
deferred_names=None,
)
assert kwargs["prompt_cache_retention"] == "24h"
def test_instructions_from_system_messages(self) -> None:
kwargs = self.provider._build_kwargs(
model="gpt-5.4",
messages=[
{"role": "system", "content": "Be helpful"},
{"role": "user", "content": "Hi"},
],
tools=None,
max_tokens=4096,
temperature=0.5,
reasoning_effort="none",
deferred_names=None,
)
assert kwargs["instructions"] == "Be helpful"
def test_web_search_injected_with_no_tools(self) -> None:
"""Search-capable models get web_search tool even when tools=None."""
kwargs = self.provider._build_kwargs(
model="gpt-5-search-api",
messages=[{"role": "user", "content": "Hi"}],
tools=None,
max_tokens=4096,
temperature=0.5,
reasoning_effort="none",
deferred_names=None,
)
assert "tools" in kwargs
tool_types = [t.get("type") for t in kwargs["tools"]]
assert "web_search" in tool_types
class TestResponsesCitationFormat:
"""Test format_citations handles Responses API flat annotation format."""
def test_responses_api_flat_annotation(self) -> None:
"""Responses API annotations have title/url directly on the object."""
class FlatAnnotation:
type = "url_citation"
url_citation = None # Not present in Responses API
title = "Example"
url = "https://example.com"
result = format_citations("Text.", [FlatAnnotation()])
assert "Sources:" in result
assert "[Example](https://example.com)" in result
class TestResponsesStreaming:
"""Tests for Responses API streaming event handling."""
def setup_method(self) -> None:
from turnstone.core.providers._openai_responses import OpenAIResponsesProvider
self.provider = OpenAIResponsesProvider()
def _make_event(self, event_type: str, **attrs: Any) -> MagicMock:
event = MagicMock()
event.type = event_type
for k, v in attrs.items():
setattr(event, k, v)
return event
def test_text_delta(self) -> None:
events = [
self._make_event("response.output_text.delta", delta="Hello"),
self._make_event("response.output_text.delta", delta=" world"),
self._make_event(
"response.completed",
response=MagicMock(
status="completed",
usage=None,
),
),
]
chunks = list(self.provider._iter_stream(iter(events)))
text_chunks = [c for c in chunks if c.content_delta]
assert len(text_chunks) == 2
assert text_chunks[0].content_delta == "Hello"
assert text_chunks[0].is_first is True
assert text_chunks[1].content_delta == " world"
def test_reasoning_delta(self) -> None:
events = [
self._make_event("response.reasoning_text.delta", delta="thinking..."),
self._make_event(
"response.completed",
response=MagicMock(
status="completed",
usage=None,
),
),
]
chunks = list(self.provider._iter_stream(iter(events)))
reasoning = [c for c in chunks if c.reasoning_delta]
assert len(reasoning) == 1
assert reasoning[0].reasoning_delta == "thinking..."
assert reasoning[0].is_first is True
def test_tool_call_streaming(self) -> None:
item = MagicMock()
item.type = "function_call"
item.id = "fc_abc123"
item.call_id = "call_1"
item.name = "read_file"
events = [
self._make_event("response.output_item.added", item=item),
self._make_event(
"response.function_call_arguments.delta",
item_id="fc_abc123",
delta='{"path":',
),
self._make_event(
"response.function_call_arguments.delta",
item_id="fc_abc123",
delta='"/tmp"}',
),
self._make_event(
"response.completed",
response=MagicMock(
status="completed",
usage=None,
),
),
]
chunks = list(self.provider._iter_stream(iter(events)))
tc_chunks = [c for c in chunks if c.tool_call_deltas]
assert len(tc_chunks) == 3
# First chunk: tool call added with name
assert tc_chunks[0].tool_call_deltas[0].name == "read_file"
assert tc_chunks[0].tool_call_deltas[0].id == "call_1"
# Argument deltas
assert tc_chunks[1].tool_call_deltas[0].arguments_delta == '{"path":'
assert tc_chunks[2].tool_call_deltas[0].arguments_delta == '"/tmp"}'
def test_completed_event_with_usage(self) -> None:
usage = MagicMock()
usage.input_tokens = 100
usage.output_tokens = 50
usage.total_tokens = 150
usage.input_tokens_details = MagicMock(cached_tokens=80)
# Ensure Chat Completions attributes are not present
del usage.prompt_tokens
del usage.completion_tokens
del usage.prompt_tokens_details
events = [
self._make_event(
"response.completed",
response=MagicMock(
status="completed",
usage=usage,
),
),
]
chunks = list(self.provider._iter_stream(iter(events)))
final = [c for c in chunks if c.finish_reason]
assert len(final) == 1
assert final[0].finish_reason == "stop"
assert final[0].usage is not None
assert final[0].usage.prompt_tokens == 100
assert final[0].usage.completion_tokens == 50
assert final[0].usage.cache_read_tokens == 80
def test_web_search_events(self) -> None:
events = [
self._make_event("response.web_search_call.searching"),
self._make_event("response.web_search_call.completed"),
self._make_event(
"response.completed",
response=MagicMock(
status="completed",
usage=None,
),
),
]
chunks = list(self.provider._iter_stream(iter(events)))
info = [c for c in chunks if c.info_delta]
assert len(info) == 2
assert "Searching" in info[0].info_delta
assert "complete" in info[1].info_delta
class TestResponsesCompletion:
"""Tests for non-streaming Responses API completion."""
def setup_method(self) -> None:
from turnstone.core.providers._openai_responses import OpenAIResponsesProvider
self.provider = OpenAIResponsesProvider()
def _make_response(
self,
text: str = "Hello",
tool_calls: list[dict[str, Any]] | None = None,
status: str = "completed",
) -> MagicMock:
resp = MagicMock()
resp.status = status
resp.usage = MagicMock()
resp.usage.input_tokens = 10
resp.usage.output_tokens = 5
resp.usage.total_tokens = 15
resp.usage.input_tokens_details = MagicMock(cached_tokens=0)
# Remove Chat Completions attributes
del resp.usage.prompt_tokens
del resp.usage.completion_tokens
del resp.usage.prompt_tokens_details
output: list[Any] = []
if text:
msg = MagicMock()
msg.type = "message"
text_part = MagicMock()
text_part.type = "output_text"
text_part.text = text
text_part.annotations = []
msg.content = [text_part]
msg.model_dump.return_value = {
"type": "message",
"content": [{"type": "output_text", "text": text}],
}
output.append(msg)
if tool_calls:
for tc in tool_calls:
item = MagicMock()
item.type = "function_call"
item.call_id = tc["id"]
item.name = tc["name"]
item.arguments = tc["arguments"]
item.model_dump.return_value = {
"type": "function_call",
"call_id": tc["id"],
"name": tc["name"],
"arguments": tc["arguments"],
}
output.append(item)
resp.output = output
return resp
def test_basic_text_completion(self) -> None:
resp = self._make_response(text="Hello world")
result = self.provider._parse_response(resp)
assert result.content == "Hello world"
assert result.tool_calls is None
assert result.finish_reason == "stop"
def test_completion_with_tool_calls(self) -> None:
resp = self._make_response(
text="",
tool_calls=[{"id": "call_1", "name": "read_file", "arguments": '{"path": "/tmp"}'}],
)
result = self.provider._parse_response(resp)
assert result.tool_calls is not None
assert len(result.tool_calls) == 1
assert result.tool_calls[0]["id"] == "call_1"
assert result.tool_calls[0]["function"]["name"] == "read_file"
def test_provider_blocks_captured(self) -> None:
resp = self._make_response(text="Hello")
result = self.provider._parse_response(resp)
assert len(result.provider_blocks) > 0
assert result.provider_blocks[0]["type"] == "message"
def test_incomplete_status_maps_to_length(self) -> None:
resp = self._make_response(text="Partial", status="incomplete")
result = self.provider._parse_response(resp)
assert result.finish_reason == "length"
def test_usage_extraction(self) -> None:
resp = self._make_response(text="Hi")
result = self.provider._parse_response(resp)
assert result.usage is not None
assert result.usage.prompt_tokens == 10
assert result.usage.completion_tokens == 5
-4
View File
@@ -138,8 +138,6 @@ def tmp_db():
def _make_session(client, model_id, tmp_db, **kwargs) -> tuple[ChatSession, RecordingUI]:
"""Create a ChatSession with RecordingUI and sensible test defaults."""
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
ui = RecordingUI()
defaults = dict(
client=client,
@@ -153,8 +151,6 @@ def _make_session(client, model_id, tmp_db, **kwargs) -> tuple[ChatSession, Reco
)
defaults.update(kwargs)
session = ChatSession(**defaults)
# Mock-based tests use Chat Completions format (client.chat.completions)
session._provider = OpenAIChatCompletionsProvider()
session.auto_approve = True
return session, ui
+20 -69
View File
@@ -640,11 +640,13 @@ class TestExecReadImage:
self._make_png(str(img))
session = _make_session()
# Mock provider to report vision support
mock_caps = MagicMock()
mock_caps.supports_vision = True
with patch.object(session._provider, "get_capabilities", return_value=mock_caps):
item = {"call_id": "c1", "path": str(img), "offset": None, "limit": None}
call_id, output = session._exec_read_file(item)
session._provider.get_capabilities = MagicMock(return_value=mock_caps)
item = {"call_id": "c1", "path": str(img), "offset": None, "limit": None}
call_id, output = session._exec_read_file(item)
assert call_id == "c1"
assert isinstance(output, list)
@@ -667,9 +669,10 @@ class TestExecReadImage:
session = _make_session()
mock_caps = MagicMock()
mock_caps.supports_vision = False
with patch.object(session._provider, "get_capabilities", return_value=mock_caps):
item = {"call_id": "c2", "path": str(img), "offset": None, "limit": None}
call_id, output = session._exec_read_file(item)
session._provider.get_capabilities = MagicMock(return_value=mock_caps)
item = {"call_id": "c2", "path": str(img), "offset": None, "limit": None}
call_id, output = session._exec_read_file(item)
assert call_id == "c2"
assert isinstance(output, str)
@@ -686,9 +689,10 @@ class TestExecReadImage:
session = _make_session()
mock_caps = MagicMock()
mock_caps.supports_vision = True
with patch.object(session._provider, "get_capabilities", return_value=mock_caps):
item = {"call_id": "c3", "path": str(img), "offset": None, "limit": None}
call_id, output = session._exec_read_file(item)
session._provider.get_capabilities = MagicMock(return_value=mock_caps)
item = {"call_id": "c3", "path": str(img), "offset": None, "limit": None}
call_id, output = session._exec_read_file(item)
assert call_id == "c3"
assert isinstance(output, str)
@@ -699,14 +703,10 @@ class TestExecReadImage:
session = _make_session()
mock_caps = MagicMock()
mock_caps.supports_vision = True
with patch.object(session._provider, "get_capabilities", return_value=mock_caps):
item = {
"call_id": "c4",
"path": str(tmp_path / "nope.png"),
"offset": None,
"limit": None,
}
call_id, output = session._exec_read_file(item)
session._provider.get_capabilities = MagicMock(return_value=mock_caps)
item = {"call_id": "c4", "path": str(tmp_path / "nope.png"), "offset": None, "limit": None}
call_id, output = session._exec_read_file(item)
assert isinstance(output, str)
assert "not found" in output
@@ -742,10 +742,9 @@ class TestGetCapabilitiesOverride:
default="qwen-vl",
)
session = _make_session(registry=registry, model_alias="qwen-vl")
# Ensure provider returns a real ModelCapabilities (not MagicMock).
# Use patch.object so the singleton provider is restored after the test.
with patch.object(session._provider, "get_capabilities", return_value=ModelCapabilities()):
caps = session._get_capabilities()
# Ensure provider returns a real ModelCapabilities (not MagicMock)
session._provider.get_capabilities = MagicMock(return_value=ModelCapabilities())
caps = session._get_capabilities()
assert caps.supports_vision is True
def test_no_override_uses_provider_default(self, tmp_db):
@@ -922,10 +921,8 @@ class TestAgentOutputGuard:
def test_agent_loop_calls_evaluate_output(self):
"""_run_agent passes tool output through _evaluate_output when output_guard is enabled."""
from turnstone.core.judge import JudgeConfig
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
session = _make_session(judge_config=JudgeConfig(output_guard=True))
session._provider = OpenAIChatCompletionsProvider()
with patch.object(session, "_evaluate_output", wraps=lambda cid, o, fn: o) as mock_eval:
# Simulate _run_agent getting a tool call response then a text response
@@ -983,10 +980,8 @@ class TestAgentOutputGuard:
def test_agent_loop_skips_guard_when_disabled(self):
"""_run_agent does not call _evaluate_output when output_guard is disabled."""
from turnstone.core.judge import JudgeConfig
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
session = _make_session(judge_config=JudgeConfig(output_guard=False))
session._provider = OpenAIChatCompletionsProvider()
with patch.object(session, "_evaluate_output") as mock_eval:
call_count = [0]
@@ -1032,47 +1027,3 @@ class TestAgentOutputGuard:
)
mock_eval.assert_not_called()
class TestProviderExtraParams:
"""Tests for _provider_extra_params — local-only chat_template_kwargs."""
def _session_with_provider(self, provider_name: str, tmp_db) -> ChatSession:
from turnstone.core.providers import create_provider
session = _make_session(reasoning_effort="medium")
session._provider = create_provider(provider_name)
return session
def test_openai_compatible_returns_chat_template_kwargs(self, tmp_db):
session = self._session_with_provider("openai-compatible", tmp_db)
result = session._provider_extra_params()
assert result is not None
assert "chat_template_kwargs" in result
assert result["chat_template_kwargs"]["reasoning_effort"] == "medium"
def test_openai_commercial_returns_none(self, tmp_db):
session = self._session_with_provider("openai", tmp_db)
result = session._provider_extra_params()
assert result is None
def test_anthropic_returns_none(self, tmp_db):
session = self._session_with_provider("anthropic", tmp_db)
result = session._provider_extra_params()
assert result is None
def test_reasoning_effort_override(self, tmp_db):
session = self._session_with_provider("openai-compatible", tmp_db)
result = session._provider_extra_params(reasoning_effort="high")
assert result is not None
assert result["chat_template_kwargs"]["reasoning_effort"] == "high"
def test_explicit_openai_provider_overrides_session(self, tmp_db):
"""Passing an explicit commercial OpenAI provider returns None even
when the session's own provider is openai-compatible."""
from turnstone.core.providers import create_provider
session = self._session_with_provider("openai-compatible", tmp_db)
openai_prov = create_provider("openai")
result = session._provider_extra_params(provider=openai_prov)
assert result is None
+2
View File
@@ -335,6 +335,8 @@ class TestSaveMessageUpdatesWorkstream:
def test_updated_timestamp_bumped(self, tmp_db):
register_workstream("s1")
save_message("s1", "user", "first")
rows = list_workstreams_with_history()
_original_updated = rows[0][4]
import time
+1
View File
@@ -57,6 +57,7 @@ class TestResetStorage:
s1 = get_storage()
reset_storage()
# After reset, get_storage() auto-inits a new instance
monkeypatch_not_needed = True # noqa: F841
init_storage("sqlite", path=str(tmp_path / "test2.db"), run_migrations=False)
s2 = get_storage()
assert s1 is not s2
-240
View File
@@ -1,240 +0,0 @@
"""Tests for capacity-aware tool output truncation and context overflow recovery."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from turnstone.core.session import ChatSession
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest.fixture
def session(tmp_db, mock_openai_client):
"""Create a ChatSession with defaults for truncation testing."""
return ChatSession(
client=mock_openai_client,
model="test-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
tool_timeout=10,
context_window=10_000,
max_tokens=1_000,
)
# ---------------------------------------------------------------------------
# _truncate_output
# ---------------------------------------------------------------------------
class TestTruncateOutput:
def test_no_truncation_when_under_limit(self, session):
result = session._truncate_output("short text")
assert result == "short text"
def test_truncates_to_tool_truncation_limit(self, session):
session.tool_truncation = 100
big = "x" * 500
result = session._truncate_output(big)
assert len(result) <= 200 # head + tail + marker
assert "chars truncated" in result
def test_budget_aware_truncation(self, session):
session.tool_truncation = 100_000
session._chars_per_token = 4.0
# Budget of 50 tokens = 200 chars
big = "x" * 1000
result = session._truncate_output(big, remaining_budget_tokens=50)
assert len(result) <= 400 # head + tail + marker
assert "chars truncated" in result
def test_budget_takes_precedence_when_smaller(self, session):
session.tool_truncation = 10_000
session._chars_per_token = 4.0
# Budget of 25 tokens = 100 chars, smaller than tool_truncation
big = "x" * 500
result = session._truncate_output(big, remaining_budget_tokens=25)
assert "chars truncated" in result
def test_zero_budget_returns_placeholder(self, session):
big = "x" * 1000
result = session._truncate_output(big, remaining_budget_tokens=0)
assert "exceeded context budget" in result
assert len(result) < 100
def test_negative_budget_returns_placeholder(self, session):
big = "x" * 1000
result = session._truncate_output(big, remaining_budget_tokens=-10)
assert "exceeded context budget" in result
def test_none_budget_uses_fixed_limit(self, session):
session.tool_truncation = 100
big = "x" * 500
result = session._truncate_output(big, remaining_budget_tokens=None)
assert "100 char limit" in result
# ---------------------------------------------------------------------------
# _remaining_token_budget
# ---------------------------------------------------------------------------
class TestRemainingTokenBudget:
def test_empty_session(self, session):
session._system_tokens = 500
session._msg_tokens = []
budget = session._remaining_token_budget()
# 10000 - 500 - 0 - 1000 - 500 (5%) = 8000
assert budget == 8000
def test_partially_full(self, session):
session._system_tokens = 500
session._msg_tokens = [2000, 3000]
budget = session._remaining_token_budget()
# 10000 - 500 - 5000 - 1000 - 500 = 3000
assert budget == 3000
def test_overfull_returns_zero(self, session):
session._system_tokens = 500
session._msg_tokens = [9000]
assert session._remaining_token_budget() == 0
def test_exactly_full_returns_zero(self, session):
session._system_tokens = 500
session._msg_tokens = [8000]
assert session._remaining_token_budget() == 0
def test_max_tokens_equals_context_window(self, tmp_db, mock_openai_client):
"""Regression: max_tokens >= context_window must not zero the budget."""
s = ChatSession(
client=mock_openai_client,
model="test-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
tool_timeout=10,
context_window=32_768,
max_tokens=32_768,
)
s._system_tokens = 500
s._msg_tokens = [1000]
budget = s._remaining_token_budget()
# response_reserve = min(32768, 32768//4) = 8192
# safety = 32768 * 0.05 = 1638
# budget = 32768 - 500 - 1000 - 8192 - 1638 = 21438
assert budget > 20_000
# Tool output should NOT be collapsed to a placeholder
big = "x" * 5000
result = s._truncate_output(big, remaining_budget_tokens=budget)
assert result == big # 5000 chars fits easily in 21K+ token budget
# ---------------------------------------------------------------------------
# Context overflow recovery
# ---------------------------------------------------------------------------
class TestContextOverflowRecovery:
"""Test that context-length errors trigger compact-and-retry."""
def test_openai_context_length_error_triggers_compact(self, session):
session.messages = [{"role": "user", "content": "hi"}]
session._msg_tokens = [1]
call_count = 0
def mock_create_stream(msgs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise Exception("maximum context length exceeded")
return iter([])
compact_mock = MagicMock()
with (
patch.object(session, "_create_stream_with_retry", side_effect=mock_create_stream),
patch.object(session, "_compact_messages", compact_mock),
patch.object(
session, "_stream_response", return_value={"role": "assistant", "content": "ok"}
),
patch.object(session, "_full_messages", return_value=[]),
patch.object(session, "_update_token_table"),
patch.object(session, "_print_status_line"),
patch.object(session, "_emit_state"),
patch("turnstone.core.session.save_message"),
):
session.send("hello")
compact_mock.assert_called_once_with(auto=True)
assert call_count == 2
def test_anthropic_prompt_too_long_triggers_compact(self, session):
session.messages = [{"role": "user", "content": "hi"}]
session._msg_tokens = [1]
call_count = 0
def mock_create_stream(msgs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise Exception("prompt is too long: 250000 tokens > 200000 maximum")
return iter([])
compact_mock = MagicMock()
with (
patch.object(session, "_create_stream_with_retry", side_effect=mock_create_stream),
patch.object(session, "_compact_messages", compact_mock),
patch.object(
session, "_stream_response", return_value={"role": "assistant", "content": "ok"}
),
patch.object(session, "_full_messages", return_value=[]),
patch.object(session, "_update_token_table"),
patch.object(session, "_print_status_line"),
patch.object(session, "_emit_state"),
patch("turnstone.core.session.save_message"),
):
session.send("hello")
compact_mock.assert_called_once_with(auto=True)
def test_non_context_error_propagates(self, session):
session.messages = [{"role": "user", "content": "hi"}]
session._msg_tokens = [1]
with (
patch.object(
session,
"_create_stream_with_retry",
side_effect=Exception("authentication failed"),
),
patch.object(session, "_full_messages", return_value=[]),
patch.object(session, "_emit_state"),
patch("turnstone.core.session.save_message"),
pytest.raises(Exception, match="authentication failed"),
):
session.send("hello")
def test_compact_failure_raises_original_error(self, session):
session.messages = [{"role": "user", "content": "hi"}]
session._msg_tokens = [1]
with (
patch.object(
session,
"_create_stream_with_retry",
side_effect=Exception("maximum context length exceeded"),
),
patch.object(session, "_compact_messages", side_effect=RuntimeError("compact failed")),
patch.object(session, "_full_messages", return_value=[]),
patch.object(session, "_emit_state"),
patch("turnstone.core.session.save_message"),
pytest.raises(Exception, match="maximum context length exceeded"),
):
session.send("hello")
+63 -66
View File
@@ -130,54 +130,54 @@ class TestWorkstream:
class TestManagerCreation:
def test_create_first_sets_active(self):
mgr = WorkstreamManager(_fake_factory)
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.active_id == ws.id
assert mgr.get_active() is ws
def test_create_second_does_not_change_active(self):
mgr = WorkstreamManager(_fake_factory)
ws1 = mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=FakeUI)
ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
_ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.active_id == ws1.id
def test_create_assigns_session(self):
mgr = WorkstreamManager(_fake_factory)
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert isinstance(ws.session, FakeSession)
def test_create_assigns_ui(self):
mgr = WorkstreamManager(_fake_factory)
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert isinstance(ws.ui, FakeUI)
assert ws.ui.ws_id == ws.id
def test_create_custom_name(self):
mgr = WorkstreamManager(_fake_factory)
ws = mgr.create(name="research", ui_factory=FakeUI)
ws = mgr.create(name="research", ui_factory=lambda wid: FakeUI(wid))
assert ws.name == "research"
def test_create_default_name(self):
mgr = WorkstreamManager(_fake_factory)
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert ws.name.startswith("ws-")
def test_create_max_workstreams_all_active(self):
mgr = WorkstreamManager(_fake_factory, max_workstreams=3)
ws1 = mgr.create(ui_factory=FakeUI)
ws2 = mgr.create(ui_factory=FakeUI)
ws3 = mgr.create(ui_factory=FakeUI)
ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
ws3 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
# Mark all as non-idle so eviction cannot help
mgr.set_state(ws1.id, WorkstreamState.THINKING)
mgr.set_state(ws2.id, WorkstreamState.RUNNING)
mgr.set_state(ws3.id, WorkstreamState.ATTENTION)
with pytest.raises(RuntimeError, match="All 3 workstreams are active"):
mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=lambda wid: FakeUI(wid))
class TestManagerLookup:
def test_get_existing(self):
mgr = WorkstreamManager(_fake_factory)
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.get(ws.id) is ws
def test_get_nonexistent(self):
@@ -186,16 +186,16 @@ class TestManagerLookup:
def test_list_all_creation_order(self):
mgr = WorkstreamManager(_fake_factory)
mgr.create(name="a", ui_factory=FakeUI)
mgr.create(name="b", ui_factory=FakeUI)
mgr.create(name="c", ui_factory=FakeUI)
_ws1 = mgr.create(name="a", ui_factory=lambda wid: FakeUI(wid))
_ws2 = mgr.create(name="b", ui_factory=lambda wid: FakeUI(wid))
_ws3 = mgr.create(name="c", ui_factory=lambda wid: FakeUI(wid))
result = mgr.list_all()
assert [w.name for w in result] == ["a", "b", "c"]
def test_index_of(self):
mgr = WorkstreamManager(_fake_factory)
ws1 = mgr.create(ui_factory=FakeUI)
ws2 = mgr.create(ui_factory=FakeUI)
ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.index_of(ws1.id) == 1
assert mgr.index_of(ws2.id) == 2
assert mgr.index_of("nonexistent") == 0
@@ -203,9 +203,9 @@ class TestManagerLookup:
def test_count(self):
mgr = WorkstreamManager(_fake_factory)
assert mgr.count == 0
mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.count == 1
mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.count == 2
@@ -217,8 +217,8 @@ class TestManagerLookup:
class TestManagerSwitching:
def test_switch_by_id(self):
mgr = WorkstreamManager(_fake_factory)
ws1 = mgr.create(ui_factory=FakeUI)
ws2 = mgr.create(ui_factory=FakeUI)
ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.active_id == ws1.id
result = mgr.switch(ws2.id)
@@ -227,13 +227,13 @@ class TestManagerSwitching:
def test_switch_nonexistent_returns_none(self):
mgr = WorkstreamManager(_fake_factory)
mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.switch("bad-id") is None
def test_switch_by_index(self):
mgr = WorkstreamManager(_fake_factory)
mgr.create(ui_factory=FakeUI)
ws2 = mgr.create(ui_factory=FakeUI)
_ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
result = mgr.switch_by_index(2)
assert result is ws2
@@ -241,7 +241,7 @@ class TestManagerSwitching:
def test_switch_by_index_out_of_range(self):
mgr = WorkstreamManager(_fake_factory)
mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.switch_by_index(0) is None
assert mgr.switch_by_index(5) is None
@@ -254,32 +254,29 @@ class TestManagerSwitching:
class TestManagerClose:
def test_close_removes_workstream(self):
mgr = WorkstreamManager(_fake_factory)
mgr.create(ui_factory=FakeUI)
ws2 = mgr.create(ui_factory=FakeUI)
_ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
closed = mgr.close(ws2.id)
assert closed is True
assert mgr.close(ws2.id) is True
assert mgr.count == 1
assert mgr.get(ws2.id) is None
def test_close_last_returns_false(self):
mgr = WorkstreamManager(_fake_factory)
ws = mgr.create(ui_factory=FakeUI)
closed = mgr.close(ws.id)
assert closed is False
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.close(ws.id) is False
assert mgr.count == 1
def test_close_nonexistent_returns_false(self):
mgr = WorkstreamManager(_fake_factory)
mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=FakeUI)
closed = mgr.close("nonexistent")
assert closed is False
mgr.create(ui_factory=lambda wid: FakeUI(wid))
mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.close("nonexistent") is False
def test_close_active_switches_to_first(self):
mgr = WorkstreamManager(_fake_factory)
ws1 = mgr.create(ui_factory=FakeUI)
ws2 = mgr.create(ui_factory=FakeUI)
ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
mgr.switch(ws2.id)
mgr.close(ws2.id)
@@ -287,9 +284,9 @@ class TestManagerClose:
def test_close_updates_order(self):
mgr = WorkstreamManager(_fake_factory)
mgr.create(name="a", ui_factory=FakeUI)
ws2 = mgr.create(name="b", ui_factory=FakeUI)
mgr.create(name="c", ui_factory=FakeUI)
_ws1 = mgr.create(name="a", ui_factory=lambda wid: FakeUI(wid))
ws2 = mgr.create(name="b", ui_factory=lambda wid: FakeUI(wid))
_ws3 = mgr.create(name="c", ui_factory=lambda wid: FakeUI(wid))
mgr.close(ws2.id)
names = [w.name for w in mgr.list_all()]
@@ -298,7 +295,7 @@ class TestManagerClose:
def test_close_unblocks_approval_event(self):
"""Closing a workstream whose UI has a pending approval should unblock it."""
mgr = WorkstreamManager(_fake_factory)
mgr.create(ui_factory=FakeUI)
_ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
# Create a workstream with a WebUI-like approval mechanism
from turnstone.server import WebUI
@@ -313,7 +310,7 @@ class TestManagerClose:
def test_close_unblocks_plan_event(self):
"""Closing a workstream with pending plan review should unblock it."""
mgr = WorkstreamManager(_fake_factory)
mgr.create(ui_factory=FakeUI)
_ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
from turnstone.server import WebUI
@@ -334,13 +331,13 @@ class TestManagerEviction:
def test_evict_oldest_idle_on_create(self):
"""At capacity with idle workstreams, create() succeeds by evicting the oldest idle."""
mgr = WorkstreamManager(_fake_factory, max_workstreams=3)
ws1 = mgr.create(name="oldest", ui_factory=FakeUI)
ws2 = mgr.create(name="middle", ui_factory=FakeUI)
mgr.create(name="newest", ui_factory=FakeUI)
ws1 = mgr.create(name="oldest", ui_factory=lambda wid: FakeUI(wid))
ws2 = mgr.create(name="middle", ui_factory=lambda wid: FakeUI(wid))
_ws3 = mgr.create(name="newest", ui_factory=lambda wid: FakeUI(wid))
# All three are IDLE. Mark ws2 as RUNNING so it won't be evicted.
mgr.set_state(ws2.id, WorkstreamState.RUNNING)
# ws1 is oldest idle, ws3 is newer idle. Creating should evict ws1.
ws4 = mgr.create(name="four", ui_factory=FakeUI)
ws4 = mgr.create(name="four", ui_factory=lambda wid: FakeUI(wid))
assert mgr.count == 3
assert mgr.get(ws1.id) is None, "oldest idle should have been evicted"
assert mgr.get(ws4.id) is ws4
@@ -352,35 +349,35 @@ class TestManagerEviction:
def test_create_fails_when_all_active(self):
"""At capacity with ALL non-idle workstreams, create() raises RuntimeError."""
mgr = WorkstreamManager(_fake_factory, max_workstreams=2)
ws1 = mgr.create(ui_factory=FakeUI)
ws2 = mgr.create(ui_factory=FakeUI)
ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
mgr.set_state(ws1.id, WorkstreamState.THINKING)
mgr.set_state(ws2.id, WorkstreamState.RUNNING)
with pytest.raises(RuntimeError, match="All 2 workstreams are active"):
mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=lambda wid: FakeUI(wid))
def test_configurable_max(self):
"""Constructor accepts max_workstreams param and respects it."""
mgr = WorkstreamManager(_fake_factory, max_workstreams=2)
ws1 = mgr.create(ui_factory=FakeUI)
ws2 = mgr.create(ui_factory=FakeUI)
ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid))
mgr.set_state(ws1.id, WorkstreamState.RUNNING)
mgr.set_state(ws2.id, WorkstreamState.RUNNING)
with pytest.raises(RuntimeError):
mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.count == 2
def test_eviction_counter(self):
"""eviction_count increments on each auto-eviction."""
mgr = WorkstreamManager(_fake_factory, max_workstreams=2)
assert mgr.eviction_count == 0
mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=lambda wid: FakeUI(wid))
mgr.create(ui_factory=lambda wid: FakeUI(wid))
# Both IDLE — create should evict the oldest
mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.eviction_count == 1
# Again — evict another idle one
mgr.create(ui_factory=FakeUI)
mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert mgr.eviction_count == 2
assert mgr.count == 2
@@ -393,7 +390,7 @@ class TestManagerEviction:
class TestManagerState:
def test_set_state(self):
mgr = WorkstreamManager(_fake_factory)
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
assert ws.state == WorkstreamState.IDLE
mgr.set_state(ws.id, WorkstreamState.THINKING)
@@ -401,7 +398,7 @@ class TestManagerState:
def test_set_state_with_error(self):
mgr = WorkstreamManager(_fake_factory)
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
mgr.set_state(ws.id, WorkstreamState.ERROR, error_msg="API timeout")
assert ws.state == WorkstreamState.ERROR
@@ -413,7 +410,7 @@ class TestManagerState:
def test_on_state_change_callback(self):
mgr = WorkstreamManager(_fake_factory)
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
changes = []
mgr._on_state_change = lambda wid, state: changes.append((wid, state))
@@ -436,7 +433,7 @@ class TestManagerThreadSafety:
def do_create():
try:
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
# Mark as non-idle immediately so auto-eviction cannot reclaim it
mgr.set_state(ws.id, WorkstreamState.RUNNING)
created.append(ws.id)
@@ -459,7 +456,7 @@ class TestManagerThreadSafety:
mgr = WorkstreamManager(_fake_factory)
ids = []
for _ in range(5):
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
ids.append(ws.id)
def do_switch(wid):
@@ -479,10 +476,10 @@ class TestManagerThreadSafety:
"""close() and list_all() running concurrently should not crash."""
mgr = WorkstreamManager(_fake_factory)
# Keep one alive to prevent closing the last
anchor = mgr.create(ui_factory=FakeUI)
anchor = mgr.create(ui_factory=lambda wid: FakeUI(wid))
targets = []
for _ in range(5):
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
targets.append(ws.id)
def do_close():
@@ -881,7 +878,7 @@ class TestStateTransitions:
def test_full_lifecycle(self):
"""Verify the expected state transition sequence."""
mgr = WorkstreamManager(_fake_factory)
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
# Simulate the state transitions that ChatSession.send() would emit
mgr.set_state(ws.id, WorkstreamState.THINKING)
@@ -902,7 +899,7 @@ class TestStateTransitions:
def test_error_recovery(self):
"""After an error, sending again should transition back to thinking."""
mgr = WorkstreamManager(_fake_factory)
ws = mgr.create(ui_factory=FakeUI)
ws = mgr.create(ui_factory=lambda wid: FakeUI(wid))
mgr.set_state(ws.id, WorkstreamState.ERROR, "API failed")
assert ws.state == WorkstreamState.ERROR
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "1.1.0"
__version__ = "1.0.2"
+38 -97
View File
@@ -2,15 +2,14 @@
Entry point: turnstone-bootstrap
Walks users through configuring a Turnstone deployment via a conversational
AI assistant. Generates compose.yaml, .env files, and post-start setup
scripts.
Walks users through configuring a single-node or multi-node Turnstone
deployment via a conversational AI assistant. Generates .env files,
docker-compose overrides, and post-start setup scripts.
"""
from __future__ import annotations
import getpass
import importlib.resources
import json
import os
import secrets
@@ -61,6 +60,8 @@ Turnstone is a multi-node AI orchestration platform. A deployment consists of:
## Deployment Profiles (compose.yaml)
- **Default** (no flag): console only (infrastructure, good for running external servers)
- **Production** (`--profile production`): 1 server + console + PostgreSQL + channel (single node)
- **Cluster** (`--profile cluster`): 10-node server fleet + PostgreSQL + channel + console (multi-node)
- **ddgCluster** (`--profile ddgCluster`): Cluster + DuckDuckGo Search MCP sidecar (web search via MCP, no API key needed)
## Environment Variables (.env)
The compose.yaml reads these from a `.env` file:
@@ -77,9 +78,9 @@ For commercial providers (OpenAI, Anthropic-via-proxy), use the real key.
### Database
- `DB_BACKEND` `sqlite` (default) or `postgresql`
- `DATABASE_URL` PostgreSQL connection string (production only)
- `DATABASE_URL` PostgreSQL connection string (production/cluster only)
- `POSTGRES_USER` PostgreSQL username (default: turnstone)
- `POSTGRES_PASSWORD` PostgreSQL password (required for production)
- `POSTGRES_PASSWORD` PostgreSQL password (required for production/cluster)
### Authentication (always enabled)
- `TURNSTONE_JWT_SECRET` JWT signing secret (required). All services must share the same secret. \
@@ -103,15 +104,16 @@ Generate with: `python -c "import secrets; print(secrets.token_hex(32))"`
- `TURNSTONE_DISCORD_TOKEN` Discord bot token
- `TURNSTONE_DISCORD_GUILD` Restrict to single guild ID
### Docker Image
- `TURNSTONE_IMAGE_TAG` Docker image tag (default: `latest`). \
Set this to pin the image version (e.g., `1.1.0`, `stable`, `experimental`).
### MCP Integration (optional)
- `MCP_CONFIG` Path to MCP server config inside the container. \
When set, servers connect to configured MCP servers on startup.
- `MCP_CONFIG` Path to MCP server config inside the container \
(e.g., `/etc/turnstone/mcp-ddg.json`). When set, servers connect to configured MCP servers on startup.
- The `ddgCluster` profile runs a DuckDuckGo Search MCP sidecar (Python) that provides \
`duckduckgo_web_search` and `duckduckgo_fetch_content` tools to every node. No API key required. \
The sidecar uses MCP streamable-http transport with DNS rebinding protection disabled \
(required for Docker internal networking) and binds to 0.0.0.0:3000 via FastMCP settings. \
Safe search is disabled by default.
### Other
### Cluster
- `APPROVAL_TIMEOUT` Tool approval timeout in seconds (default: 3600)
## Auth Setup Flow
@@ -152,28 +154,28 @@ Categories like "engineering", "analysis", etc.
## Your Task
Walk the user through setting up their deployment step by step:
1. **First**: Call `check_docker`, `read_file` on `.env`, and `read_file` on `compose.yaml` \
to detect existing state. If `compose.yaml` does not exist, call `write_compose` to \
extract the bundled production compose file. This is essential without it, \
`docker compose` will fail.
2. **LLM provider for the deployment**: Which LLM backend their Turnstone will use \
1. **First**: Call `check_docker` and `read_file` on `.env` to detect existing state.
2. **Deployment mode**: Ask if they want single-node (`--profile production`) or multi-node \
(`--profile cluster`). Explain trade-offs.
3. **LLM provider for the deployment**: Which LLM backend their Turnstone will use \
(may differ from this wizard's model). Ask for base URL, API key, model name.
3. **Database**: SQLite (dev/simple) vs PostgreSQL (production). \
PostgreSQL is recommended for production use.
4. **Security**: Auth is always enabled and requires `TURNSTONE_JWT_SECRET`. \
4. **Database**: SQLite (dev/simple) vs PostgreSQL (production/cluster). \
PostgreSQL is required for cluster mode.
5. **Security**: Auth is always enabled and requires `TURNSTONE_JWT_SECRET`. \
Use `generate_secret` for JWT secret and Postgres password. \
Always set `TURNSTONE_JWT_SECRET` in the .env. \
Ask for initial admin username and password. \
If the user's deployment will use an external identity provider (Okta, Azure AD, Google, etc.), \
offer to configure OIDC SSO. Ask for the issuer URL, client ID, and client secret. \
Optionally configure role mapping and OIDC-only mode.
5. **Ports**: Check defaults with `check_port`, suggest alternatives if conflicts.
6. **Optional features**: Discord integration, web search (Tavily key).
7. **Generate .env**: Call `write_file` with the complete `.env` content. \
Include `TURNSTONE_IMAGE_TAG` set to the version matching the installed package.
8. **Generate setup.sh**: Call `write_file` with a post-start script that creates the admin \
6. **Ports**: Check defaults with `check_port`, suggest alternatives if conflicts.
7. **Optional features**: Discord integration, web search (Tavily key), \
DuckDuckGo Search MCP (for cluster uses `ddgCluster` profile with \
`MCP_CONFIG=/etc/turnstone/mcp-ddg.json`, no API key needed).
8. **Generate .env**: Call `write_file` with the complete `.env` content.
9. **Generate setup.sh**: Call `write_file` with a post-start script that creates the admin \
user and any roles/policies/skills the user wants.
9. **Finish**: Call the `finish` tool with a summary of what was configured and the \
10. **Finish**: Call the `finish` tool with a summary of what was configured and the \
exact commands to run next (e.g., `docker compose --profile production up -d` then `./setup.sh`).
## Rules
@@ -181,9 +183,14 @@ exact commands to run next (e.g., `docker compose --profile production up -d` th
- NEVER echo API keys or passwords back to the user in your text responses.
- ALWAYS use `generate_secret` for passwords and secrets never invent them.
- When writing files, use `write_file` the user will see a preview and confirm.
- If `compose.yaml` is missing, call `write_compose` before anything else. \
The compose file uses pre-built images from ghcr.io no local Docker build is needed.
- If an existing .env is detected, summarize what's configured and ask what to change.
- For cluster mode, the compose.yaml has a fixed 10-node fleet no override needed.
- For cluster + DuckDuckGo Search, use `--profile ddgCluster` instead of `--profile cluster`. \
Set `MCP_CONFIG=/etc/turnstone/mcp-ddg.json` in `.env`. No API key needed. \
The DuckDuckGo MCP sidecar starts automatically and all cluster nodes connect to it. \
Note: the MCP SDK's DNS rebinding protection must be disabled for Docker-internal networking \
(the compose.yaml handles this), and the server must bind to 0.0.0.0 (not 127.0.0.1) to be \
reachable from other containers.
- The `DATABASE_URL` for docker compose internal networking uses the hostname `postgres` \
(e.g., `postgresql+psycopg://turnstone:<password>@postgres:5432/turnstone`).
- For local LLM backends (vLLM, llama.cpp, Ollama, etc.), set `OPENAI_API_KEY=dummy` in the \
@@ -335,23 +342,6 @@ TOOLS: list[dict[str, Any]] = [
},
},
},
{
"type": "function",
"function": {
"name": "write_compose",
"description": (
"Write the production Docker Compose file to the project directory. "
"This extracts the compose.yaml bundled with Turnstone, which uses "
"pre-built images from ghcr.io (no local Docker build required). "
"The user will be shown a preview and asked to confirm."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
@@ -437,7 +427,7 @@ def _tool_write_file(project_dir: Path, args: dict[str, Any]) -> str:
if existing == content:
return f"File already exists with identical content: {args['path']}"
except (OSError, UnicodeDecodeError):
pass # best-effort duplicate check
pass
line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
@@ -571,54 +561,6 @@ def _tool_check_docker(args: dict[str, Any]) -> str:
return "\n".join(results)
def _tool_write_compose(project_dir: Path, args: dict[str, Any]) -> str:
"""Extract the bundled production compose.yaml to the project directory."""
dest = project_dir / "compose.yaml"
# Read the bundled template
try:
ref = importlib.resources.files("turnstone.deploy").joinpath("compose.yaml")
content = ref.read_text(encoding="utf-8")
except Exception as exc:
return f"Error: could not read bundled compose template: {exc}"
# Skip if identical
if dest.exists():
try:
existing = dest.read_text(encoding="utf-8")
if existing == content:
return "compose.yaml already exists with identical content."
except (OSError, UnicodeDecodeError):
pass # best-effort duplicate check
line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
# Show preview
print(f"\n{YELLOW} Writing compose.yaml ({line_count} lines){RESET}")
print(f"{DIM}{'' * 50}{RESET}")
for line in content.split("\n")[:30]:
print(f" {DIM}{line}{RESET}")
if line_count > 30:
print(f" {DIM}... ({line_count - 30} more lines){RESET}")
print(f"{DIM}{'' * 50}{RESET}")
try:
choice = input(f"{BOLD}Write this file? [Y/n]{RESET} ").strip().lower()
except (EOFError, KeyboardInterrupt):
return "User cancelled the write."
if choice in ("n", "no"):
return "User declined to write compose.yaml."
dest.write_text(content, encoding="utf-8")
return (
f"compose.yaml written successfully. "
f"It uses ghcr.io/turnstonelabs/turnstone images. "
f"Add TURNSTONE_IMAGE_TAG={__version__} to .env to pin the image "
f"to the currently installed version, or omit it to use 'latest'."
)
class _FinishError(Exception):
"""Raised by the finish tool to signal the wizard is done."""
@@ -639,12 +581,11 @@ TOOL_FUNCTIONS: dict[str, Any] = {
"check_port": _tool_check_port,
"validate_api_key": _tool_validate_api_key,
"check_docker": _tool_check_docker,
"write_compose": _tool_write_compose,
"finish": _tool_finish,
}
# Tools that need the project_dir argument
_PROJECT_DIR_TOOLS = frozenset({"read_file", "write_file", "write_compose"})
_PROJECT_DIR_TOOLS = frozenset({"read_file", "write_file"})
def execute_tool(name: str, args: dict[str, Any], project_dir: Path) -> str:
+2 -302
View File
@@ -1,17 +1,12 @@
"""Message formatting utilities for channel adapters.
Handles chunking long messages for platforms with character limits, formatting
tool-approval requests, plan-review prompts, and rich media embeds for
platforms that support them (e.g. Discord).
tool-approval requests, and plan-review prompts.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import httpx
from typing import Any
def chunk_message(text: str, max_length: int = 2000) -> list[str]:
@@ -169,298 +164,3 @@ def truncate(text: str, max_length: int = 200) -> str:
if len(text) <= max_length:
return text
return text[: max_length - 1] + "\u2026"
# ---------------------------------------------------------------------------
# Rich media embed helpers (Discord)
# ---------------------------------------------------------------------------
def try_parse_media(output: str) -> dict[str, Any] | None:
"""Attempt to parse tool output as a media result.
Returns the parsed dict when the output looks like structured media
(single item, search results, or session list), otherwise ``None``.
"""
try:
data = json.loads(output)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(data, dict):
return None
# Single item with stream URL or detailed metadata.
if "stream_url" in data or ("name" in data and "type" in data and "id" in data):
return data
# Search results.
if "results" in data and isinstance(data["results"], list) and data["results"]:
return data
# Active sessions.
if "sessions" in data and isinstance(data["sessions"], list):
return data
return None
_BLOCKED_HOSTNAMES = frozenset({"localhost", "metadata.google.internal"})
def _is_safe_image_url(url: str) -> bool:
"""Validate that *url* uses http(s), has no embedded credentials, and does
not target loopback or cloud metadata endpoints.
Private/LAN IPs are intentionally allowed (media servers are typically
on the local network).
"""
import ipaddress
from urllib.parse import urlparse
try:
parsed = urlparse(url)
except Exception: # noqa: BLE001
return False
if parsed.scheme not in ("http", "https"):
return False
if parsed.username or parsed.password:
return False
hostname = parsed.hostname
if not hostname:
return False
if hostname in _BLOCKED_HOSTNAMES:
return False
try:
ip = ipaddress.ip_address(hostname)
if ip.is_loopback or ip.is_link_local:
return False
except ValueError:
pass # Not an IP literal — hostname is fine
return True
async def _fetch_thumbnail(
http: httpx.AsyncClient,
url: str,
*,
timeout: float = 5.0,
max_bytes: int = 2 * 1024 * 1024,
) -> tuple[bytes, str] | None:
"""Fetch a thumbnail image, returning ``(bytes, filename)`` or ``None``.
Never raises a failed image fetch must not break tool result
rendering. Private/LAN URLs are intentionally allowed (media servers
are typically on the local network), but scheme is restricted to
http(s) and userinfo is rejected.
"""
if not _is_safe_image_url(url):
return None
try:
async with http.stream("GET", url, timeout=timeout) as resp:
if resp.status_code != 200:
return None
cl = resp.headers.get("content-length")
if cl and cl.isdigit() and int(cl) > max_bytes:
return None
content_type = resp.headers.get("content-type", "image/jpeg").lower()
if not content_type.startswith("image/"):
return None
ext = "jpg"
if "png" in content_type:
ext = "png"
elif "webp" in content_type:
ext = "webp"
data = bytearray()
async for chunk in resp.aiter_bytes():
data.extend(chunk)
if len(data) > max_bytes:
return None
return bytes(data), f"poster.{ext}"
except Exception: # noqa: BLE001
return None
async def try_build_media_embed(
tool_name: str,
output: str,
*,
http: httpx.AsyncClient,
) -> tuple[Any, Any | None] | None:
"""Attempt to build a rich Discord embed from media tool output.
Returns ``(embed, optional_file)`` if the output is parseable as media,
or ``None`` to fall through to the default code-block formatter.
The ``discord`` library is imported lazily since this module is shared
across adapters and ``discord.py`` is an optional dependency.
"""
data = try_parse_media(output)
if data is None:
return None
import io
import discord
# Dispatch on result shape.
if "results" in data and isinstance(data["results"], list):
embed = _build_search_results_embed(data)
elif "sessions" in data and isinstance(data["sessions"], list):
embed = _build_sessions_embed(data)
else:
embed = _build_single_media_embed(data, tool_name)
# Proxy thumbnail image.
thumbnail_url = data.get("thumbnail_url") or data.get("image_url")
if not thumbnail_url and data.get("results"):
first = data["results"][0]
thumbnail_url = first.get("thumbnail_url") or first.get("image_url")
file: discord.File | None = None
if thumbnail_url:
fetched = await _fetch_thumbnail(http, thumbnail_url)
if fetched:
image_bytes, filename = fetched
file = discord.File(io.BytesIO(image_bytes), filename=filename)
embed.set_thumbnail(url=f"attachment://{filename}")
return embed, file
# -- Private embed builders ------------------------------------------------
def _build_single_media_embed(data: dict[str, Any], tool_name: str) -> Any:
"""Build a Discord embed for a single media item."""
import discord
title = data.get("name", "Unknown")
if data.get("year"):
title += f" ({data['year']})"
embed = discord.Embed(
title=title,
url=data.get("web_url"), # safe link — NOT stream_url
description=truncate(data.get("overview", ""), 200),
color=discord.Color.teal(),
)
# Metadata fields (inline).
meta_parts: list[str] = []
if data.get("type"):
meta_parts.append(data["type"])
if data.get("official_rating"):
meta_parts.append(data["official_rating"])
if data.get("runtime_minutes"):
hours = int(data["runtime_minutes"] // 60)
mins = int(data["runtime_minutes"] % 60)
meta_parts.append(f"{hours}h {mins}m" if hours else f"{mins}m")
if meta_parts:
embed.add_field(name="Info", value=" \u00b7 ".join(meta_parts), inline=True)
if data.get("genres"):
embed.add_field(name="Genres", value=", ".join(data["genres"][:5]), inline=True)
if data.get("community_rating"):
embed.add_field(
name="Rating",
value=f"{data['community_rating']:.1f}/10",
inline=True,
)
# Extract server name from tool_name (mcp__servername__toolname).
parts = tool_name.split("__")
if len(parts) >= 3:
embed.set_footer(text=parts[1])
return embed
def _build_search_results_embed(data: dict[str, Any]) -> Any:
"""Build a Discord embed for a list of search results."""
import discord
results = data.get("results", [])
total = data.get("total_count", len(results))
lines: list[str] = []
char_count = 0
for i, r in enumerate(results[:10], 1):
line = f"**{i}.** {r.get('name', '?')}"
if r.get("year"):
line += f" ({r['year']})"
meta: list[str] = []
if r.get("type"):
meta.append(r["type"])
if r.get("series_name"):
meta.append(r["series_name"])
if r.get("season_number") is not None and r.get("episode_number") is not None:
meta.append(f"S{int(r['season_number']):02d}E{int(r['episode_number']):02d}")
if r.get("runtime_minutes"):
mins = r["runtime_minutes"]
meta.append(f"{int(mins // 60)}h {int(mins % 60)}m" if mins >= 60 else f"{int(mins)}m")
if meta:
line += " \u00b7 " + " \u00b7 ".join(meta)
if char_count + len(line) + 1 > 4000:
break
lines.append(line)
char_count += len(line) + 1
embed = discord.Embed(
title="Search results",
description="\n".join(lines),
color=discord.Color.teal(),
)
embed.set_footer(text=f"showing {len(lines)} of {total}")
return embed
def _build_sessions_embed(data: dict[str, Any]) -> Any:
"""Build a Discord embed for active playback sessions."""
import discord
sessions = data.get("sessions", [])
if not sessions:
embed = discord.Embed(
title="Now Playing",
description="No active sessions.",
color=discord.Color.light_grey(),
)
return embed
lines: list[str] = []
has_active = False
for s in sessions:
np = s.get("now_playing")
device = s.get("device_name", "Unknown device")
user = s.get("user_name", "")
if np:
has_active = True
title = np.get("name", "Unknown")
if np.get("year"):
title += f" ({np['year']})"
ps = s.get("play_state", {}) or {}
pos = ps.get("position_seconds")
runtime_min = np.get("runtime_minutes")
time_str = ""
if pos is not None and runtime_min:
total_sec = int(runtime_min * 60)
pos_i = int(pos)
time_str = (
f" {pos_i // 3600}:{pos_i % 3600 // 60:02d}:{pos_i % 60:02d}"
f" / {total_sec // 3600}:{total_sec % 3600 // 60:02d}:{total_sec % 60:02d}"
)
paused = ps.get("is_paused", False)
icon = "\u23f8" if paused else "\u25b6"
line = f"**{title}** on {device}\n{icon}{time_str}"
if user:
line += f" \u00b7 {user}"
lines.append(line)
else:
line = f"*{device}* \u2014 idle"
if user:
line += f" ({user})"
lines.append(line)
embed = discord.Embed(
title="Now Playing",
description="\n\n".join(lines),
color=discord.Color.green() if has_active else discord.Color.light_grey(),
)
return embed
+1 -1
View File
@@ -244,7 +244,7 @@ class ChannelRouter:
self._node_urls[ws_id] = node_url.rstrip("/")
return self._node_urls[ws_id]
except Exception:
log.debug("Console route lookup failed for ws %s", ws_id, exc_info=True)
pass
return self._server_url
# -- user resolution -----------------------------------------------------
+15 -43
View File
@@ -16,7 +16,7 @@ import contextlib
import json
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
import httpx
@@ -558,16 +558,15 @@ class TurnstoneBot:
# authorize this?" while the running embed says "this tool is
# executing." Both can coexist in the thread.
for it in event.items:
raw_name = it.get("func_name") or it.get("approval_label") or "tool"
display_name = discord.utils.escape_markdown(raw_name)
name = it.get("func_name") or it.get("approval_label") or "tool"
raw_preview = it.get("preview", "")
# Escape backticks to prevent markdown breakout and
# strip @-mentions.
# Sanitize preview: escape backticks to prevent markdown
# breakout and strip @-mentions.
raw_preview = raw_preview.replace("`", "\\`")
raw_preview = discord.utils.escape_mentions(raw_preview)
preview = truncate(raw_preview, max_length=120) or None
embed = discord.Embed(
title=display_name,
title=name,
description=preview,
color=discord.Color.light_grey(),
)
@@ -582,9 +581,8 @@ class TurnstoneBot:
else:
msg = await thread.send(embed=embed)
call_id = it.get("call_id", "")
# Store raw (unescaped) name for matching against ToolResultEvent.name
self._tool_info_msgs.setdefault(ws_id, []).append(
(call_id, raw_name, preview or "", msg)
(call_id, name, preview or "", msg)
)
# If no items consumed the thinking message (empty event), clean up.
@@ -616,7 +614,7 @@ class TurnstoneBot:
status = "Error" if event.is_error else "Done"
status_color = discord.Color.red() if event.is_error else discord.Color.dark_grey()
status_embed = discord.Embed(
title=f"{discord.utils.escape_markdown(event.name)} \u2014 {status}",
title=f"{event.name} \u2014 {status}",
description=matched_preview or None,
color=status_color,
)
@@ -626,40 +624,14 @@ class TurnstoneBot:
log.debug("discord.tool_info_status_edit_failed", ws_id=ws_id)
# Send the result as a separate message.
if not event.is_error:
from turnstone.channels._formatter import try_build_media_embed
media_result = None
try:
media_result = await try_build_media_embed(
event.name,
event.output,
http=self._http_client,
)
except Exception:
log.debug("discord.media_embed_failed", ws_id=ws_id, tool=event.name)
if media_result is not None:
embed, file = media_result
kwargs: dict[str, Any] = {"embed": embed}
if file is not None:
kwargs["file"] = file
await thread.send(**kwargs)
else:
desc = format_tool_result(event.output)
result_embed = discord.Embed(
title=event.name,
description=desc,
color=discord.Color.dark_grey(),
)
await thread.send(embed=result_embed)
else:
desc = format_tool_result(event.output)
result_embed = discord.Embed(
title=event.name,
description=desc,
color=discord.Color.red(),
)
await thread.send(embed=result_embed)
desc = format_tool_result(event.output)
color = discord.Color.red() if event.is_error else discord.Color.dark_grey()
result_embed = discord.Embed(
title=event.name,
description=desc,
color=color,
)
await thread.send(embed=result_embed)
elif isinstance(event, ApproveRequestEvent):
# Evaluate admin tool policies before auto-approve.
+2 -4
View File
@@ -7,7 +7,6 @@ model auto-detection, workstream management, and the main() REPL entry point.
from __future__ import annotations
import argparse
import logging
import os
import readline
import sys
@@ -166,7 +165,7 @@ class TerminalUI(SessionUI):
it for it in items if it.get("needs_approval") and not it.get("error")
]
except Exception:
logging.getLogger(__name__).debug("Policy evaluation unavailable", exc_info=True)
pass # Best-effort — no policy enforcement on error
with self._print_lock:
# Print all headers, previews, and heuristic verdicts
@@ -177,8 +176,7 @@ class TerminalUI(SessionUI):
else:
sys.stdout.write(f" {yellow(item['header'])}\n")
if item.get("preview"):
styled = dim(item["preview"]) if not item.get("error") else red(item["preview"])
sys.stdout.write(styled + "\n")
sys.stdout.write(item["preview"] + "\n")
verdict = item.get("_heuristic_verdict")
if verdict:
risk = verdict.get("risk_level", "medium")
+7 -13
View File
@@ -287,7 +287,7 @@ async def cluster_events_sse(request: Request) -> Response:
)
yield {"data": json.dumps(event)}
except queue.Empty:
pass # poll timeout, retry
pass
if await request.is_disconnected():
break
finally:
@@ -5485,14 +5485,8 @@ async def admin_detect_model(request: Request) -> JSONResponse:
base_url = row.get("base_url", "")
# For commercial endpoints an api_key is required
_normalized = (base_url if "://" in base_url else f"https://{base_url}") if base_url else ""
_hostname = (urllib.parse.urlparse(_normalized).hostname or "") if _normalized else ""
if not api_key and (
not base_url
or _hostname == "api.openai.com"
or _hostname.endswith(".openai.com")
or _hostname == "api.anthropic.com"
or _hostname.endswith(".anthropic.com")
not base_url or "api.openai.com" in base_url or "api.anthropic.com" in base_url
):
return JSONResponse({"error": "api_key is required"}, status_code=400)
@@ -5582,7 +5576,7 @@ async def admin_list_prompt_policies(request: Request) -> JSONResponse:
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.prompt_policies")
err = require_permission(request, "admin.policies")
if err:
return err
@@ -5601,7 +5595,7 @@ async def admin_create_prompt_policy(request: Request) -> JSONResponse:
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.prompt_policies")
err = require_permission(request, "admin.policies")
if err:
return err
@@ -5658,7 +5652,7 @@ async def admin_get_prompt_policy(request: Request) -> JSONResponse:
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.prompt_policies")
err = require_permission(request, "admin.policies")
if err:
return err
@@ -5678,7 +5672,7 @@ async def admin_update_prompt_policy(request: Request) -> JSONResponse:
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.prompt_policies")
err = require_permission(request, "admin.policies")
if err:
return err
@@ -5731,7 +5725,7 @@ async def admin_delete_prompt_policy(request: Request) -> JSONResponse:
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.prompt_policies")
err = require_permission(request, "admin.policies")
if err:
return err
+11 -64
View File
@@ -94,29 +94,6 @@ function showAdmin() {
// Mobile: ensure sidebar starts hidden + inert; desktop: ensure it's accessible
var sidebar = document.getElementById("admin-sidebar");
// Inject close header for mobile drawer (once)
if (!document.getElementById("admin-sidebar-close")) {
var closeHeader = document.createElement("div");
closeHeader.id = "admin-sidebar-close";
closeHeader.className = "admin-sidebar-close";
var label = document.createElement("span");
label.textContent = "Navigation";
var closeBtn = document.createElement("button");
closeBtn.setAttribute("aria-label", "Close navigation");
closeBtn.textContent = "\u00d7";
closeBtn.addEventListener("click", function () {
if (_mobileSidebarOpen) {
_toggleMobileSidebar();
var mt = document.getElementById("admin-mobile-toggle");
if (mt) mt.focus();
}
});
closeHeader.appendChild(label);
closeHeader.appendChild(closeBtn);
sidebar.insertBefore(closeHeader, sidebar.firstChild);
}
if (window.innerWidth <= 700) {
_mobileSidebarOpen = false;
sidebar.classList.add("collapsed");
@@ -170,20 +147,15 @@ function _injectMobileToggle(tab) {
toggle.id = "admin-mobile-toggle";
toggle.className = "admin-mobile-toggle";
toggle.setAttribute("aria-label", "Open navigation");
toggle.setAttribute("aria-expanded", "false");
toggle.onclick = function () {
_mobileSidebarOpen = false;
_toggleMobileSidebar();
};
}
var panel = document.getElementById("admin-" + tab);
if (!panel) return;
var toolbar = panel.querySelector(".admin-toolbar");
if (toolbar) {
if (!toolbar.contains(toggle))
toolbar.insertBefore(toggle, toolbar.firstChild);
} else {
// Panel has no toolbar — prepend toggle directly so it remains accessible
if (!panel.contains(toggle)) panel.insertBefore(toggle, panel.firstChild);
if (panel) {
var toolbar = panel.querySelector(".admin-toolbar");
if (toolbar) toolbar.insertBefore(toggle, toolbar.firstChild);
}
}
@@ -197,20 +169,6 @@ function _toggleMobileSidebar() {
else sidebar.setAttribute("inert", "");
var backdrop = document.getElementById("admin-sidebar-backdrop");
if (backdrop) backdrop.classList.toggle("visible", _mobileSidebarOpen);
// Update hamburger aria-label to reflect current state
var mt = document.getElementById("admin-mobile-toggle");
if (mt) {
mt.setAttribute(
"aria-label",
_mobileSidebarOpen ? "Close navigation" : "Open navigation",
);
mt.setAttribute("aria-expanded", _mobileSidebarOpen ? "true" : "false");
}
// Move focus into drawer on open; callers handle focus-return on close
if (_mobileSidebarOpen) {
var closeBtn = sidebar.querySelector(".admin-sidebar-close button");
if (closeBtn) closeBtn.focus();
}
}
function switchAdminTab(tab) {
@@ -280,15 +238,6 @@ function switchAdminTab(tab) {
// On mobile, auto-close sidebar after tab selection
if (window.innerWidth <= 700 && _mobileSidebarOpen) {
_toggleMobileSidebar();
// Move focus to the newly active panel instead of leaving it in the inert sidebar
var panel = document.getElementById("admin-" + tab);
var focusTarget =
panel &&
panel.querySelector("h2, .section-header, button:not([disabled])");
if (focusTarget) {
focusTarget.setAttribute("tabindex", "-1");
focusTarget.focus();
}
}
}
@@ -2053,20 +2002,18 @@ document.addEventListener("keydown", function (e) {
if (!sidebar) return;
var isMobile = window.innerWidth <= 700;
var backdrop = document.getElementById("admin-sidebar-backdrop");
if (!isMobile) {
// Crossed into desktop: close drawer cleanly if it was open
if (_mobileSidebarOpen) _toggleMobileSidebar();
sidebar.removeAttribute("aria-hidden");
sidebar.removeAttribute("inert");
sidebar.classList.remove("collapsed", "open");
if (backdrop) backdrop.classList.remove("visible");
} else if (!_mobileSidebarOpen) {
// Mobile with drawer closed: ensure collapsed state
if (isMobile && !_mobileSidebarOpen) {
sidebar.setAttribute("aria-hidden", "true");
sidebar.setAttribute("inert", "");
sidebar.classList.add("collapsed");
sidebar.classList.remove("open");
if (backdrop) backdrop.classList.remove("visible");
} else if (!isMobile) {
sidebar.removeAttribute("aria-hidden");
sidebar.removeAttribute("inert");
sidebar.classList.remove("collapsed", "open");
if (backdrop) backdrop.classList.remove("visible");
_mobileSidebarOpen = false;
}
}, 150);
});
+1 -3
View File
@@ -720,9 +720,7 @@ function buildNodeRow(node) {
function toggleGroup(prefix) {
expandedGroups[prefix] = !expandedGroups[prefix];
var body = document.querySelector(
'.node-group-body[data-prefix="' +
prefix.replace(/\\/g, "\\\\").replace(/"/g, '\\"') +
'"]',
'.node-group-body[data-prefix="' + prefix.replace(/"/g, '\\"') + '"]',
);
if (!body) return;
var isExpanded = expandedGroups[prefix];
+2 -1
View File
@@ -2185,7 +2185,8 @@ function searchSkillDiscover() {
var searchBtn = document.getElementById("skill-discover-search-btn");
if (searchBtn) searchBtn.disabled = true;
var url = "/v1/api/admin/skills/discover?limit=20&q=" + encodeURIComponent(q);
var url = "/v1/api/admin/skills/discover?limit=20";
if (q) url += "&q=" + encodeURIComponent(q);
authFetch(url)
.then(function (r) {
+8 -60
View File
@@ -768,8 +768,7 @@
color: var(--fg-dim);
padding: 12px 16px 4px;
}
.admin-sidebar-group:first-child .admin-sidebar-group-label,
.admin-sidebar-close + .admin-sidebar-group .admin-sidebar-group-label {
.admin-sidebar-group:first-child .admin-sidebar-group-label {
padding-top: 4px;
}
@@ -819,16 +818,13 @@
z-index: 499;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1);
transition: opacity 0.25s ease;
}
.admin-sidebar-backdrop.visible {
opacity: 1;
pointer-events: auto;
}
/* Close header — hidden on desktop, shown via mobile media query */
.admin-sidebar-close { display: none; }
/* Mobile menu toggle — visible only on mobile, lives in toolbars */
.admin-mobile-toggle {
display: none;
@@ -836,8 +832,8 @@
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--fg-dim);
min-width: 44px;
min-height: 44px;
width: 28px;
height: 28px;
cursor: pointer;
align-items: center;
justify-content: center;
@@ -854,10 +850,6 @@
box-shadow: 0 4px 0 currentColor, 0 8px 0 currentColor;
}
.admin-mobile-toggle:hover { color: var(--fg); }
.admin-mobile-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (max-width: 700px) {
.admin-mobile-toggle { display: flex; }
}
@@ -1472,62 +1464,18 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
right: 0;
bottom: 0;
left: auto;
width: 260px;
max-width: 80vw;
width: 220px;
z-index: 500;
background: var(--bg-surface);
border-left: 1px solid var(--border-strong);
border-right: none;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.35);
transform: translateX(100%);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
padding-top: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
transition: transform 0.25s ease;
padding-top: 48px;
}
.admin-sidebar.open { transform: translateX(0); }
.admin-sidebar.collapsed { transform: translateX(100%); }
.admin-sidebar.collapsed { transform: translateX(100%); width: 220px; }
.admin-content { padding-right: 0; }
/* Close button at top of mobile drawer */
.admin-sidebar-close {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-family: var(--font-display);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-dim);
}
.admin-sidebar-close button {
background: none;
border: none;
color: var(--fg-dim);
font-size: 20px;
line-height: 1;
cursor: pointer;
padding: 10px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
}
.admin-sidebar-close button:hover { color: var(--fg); }
.admin-sidebar-close button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Flip active indicator to left border on mobile (drawer is on right edge) */
.admin-nav { border-right: none; border-left: 2px solid transparent; }
.admin-nav:hover { border-right-color: transparent; border-left-color: var(--border-strong); }
.admin-nav.active { border-right-color: transparent; border-left-color: var(--accent); }
}
/* ==========================================================================
+1 -1
View File
@@ -1192,7 +1192,7 @@ async def handle_oidc_callback(request: Request, audience: str) -> Response:
jwks_data = await fetch_jwks(oidc_config.jwks_uri)
request.app.state.jwks_data = jwks_data
except OIDCError:
log.warning("JWKS fetch failed from %s", oidc_config.jwks_uri, exc_info=True)
pass
if jwks_data is None:
return RedirectResponse("/?oidc_error=OIDC+temporarily+unavailable", status_code=302)
+4 -4
View File
@@ -1382,7 +1382,7 @@ class IntentJudge:
confidence = float(data.get("confidence", 0.5))
confidence = max(0.0, min(1.0, confidence))
except (ValueError, TypeError):
pass # keeps default 0.5
pass
evidence = data.get("evidence", [])
if isinstance(evidence, str):
@@ -1415,7 +1415,7 @@ class IntentJudge:
if isinstance(data, dict):
return data
except (json.JSONDecodeError, ValueError):
pass # falls through to strategy 2
pass
# Strategy 2: Markdown code block
md_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
@@ -1425,7 +1425,7 @@ class IntentJudge:
if isinstance(data, dict):
return data
except (json.JSONDecodeError, ValueError):
pass # falls through to strategy 3
pass
# Strategy 3: Find first { and matching }
start = text.find("{")
@@ -1442,7 +1442,7 @@ class IntentJudge:
if isinstance(data, dict):
return data
except (json.JSONDecodeError, ValueError):
pass # falls through to regex extraction
pass
break
# Strategy 4: Regex field extraction (last resort)
+11 -331
View File
@@ -35,7 +35,7 @@ if TYPE_CHECKING:
from collections.abc import Callable
import mcp.types as mcp_types
from mcp import ClientSession, McpError, StdioServerParameters
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamablehttp_client
@@ -67,7 +67,7 @@ def _mcp_to_openai(server_name: str, tool: Any) -> dict[str, Any]:
"type": "function",
"function": {
"name": f"mcp__{server_name}__{tool.name}",
"description": description,
"description": f"[MCP: {server_name}] {description}",
"parameters": input_schema,
},
}
@@ -151,22 +151,6 @@ class MCPClientManager:
self._refresh_interval = refresh_interval
self._refresh_task: asyncio.Task[None] | None = None
# Circuit breaker (per-server) — prevents repeated calls to broken servers
self._consecutive_failures: dict[str, int] = {}
self._circuit_open_until: dict[str, float] = {} # monotonic timestamp
self._circuit_trip_count: dict[str, int] = {} # backoff exponent
# Safe transport stream refs (pre-close before stack teardown to avoid
# the anyio cancel-scope CPU busy-loop — MCP SDK #2147)
self._server_streams: dict[str, tuple[Any, Any]] = {}
# Notification debounce (per-server)
self._last_notification_refresh: dict[str, float] = {}
# Periodic refresh backoff (per-server)
self._refresh_failures: dict[str, int] = {}
self._refresh_backoff_until: dict[str, float] = {} # monotonic timestamp
# -- lifecycle -----------------------------------------------------------
def start(self) -> None:
@@ -197,7 +181,6 @@ class MCPClientManager:
except Exception as exc:
log.warning("Failed to connect MCP server '%s'", name, exc_info=True)
self._set_error(name, f"{type(exc).__name__}: {exc}")
self._cb_record_failure(name)
self._connected.set()
@@ -220,94 +203,6 @@ class MCPClientManager:
_CONNECT_TIMEOUT = 30 # seconds — prevents hung connections on broken remotes
_TCP_PROBE_TIMEOUT = 5 # seconds — fast TCP pre-flight for HTTP transports
# Circuit breaker constants
_CB_FAILURE_THRESHOLD = 3
_CB_BASE_COOLDOWN = 30.0 # seconds
_CB_MAX_COOLDOWN = 300.0 # 5 minutes
# Notification debounce
_NOTIFICATION_DEBOUNCE = 5.0 # seconds between refreshes per server
# Periodic refresh backoff
_REFRESH_BACKOFF_BASE = 60.0 # seconds
_REFRESH_BACKOFF_MAX = 3600.0 # 1 hour
# -- circuit breaker (per-server) -----------------------------------------
def _cb_check(self, name: str) -> tuple[bool, bool]:
"""Check circuit breaker state for *name*.
Returns ``(is_open, cooldown_expired)``. When the circuit is closed
both values are False. When open, *cooldown_expired* indicates
whether a probe attempt is allowed.
"""
deadline = self._circuit_open_until.get(name)
if deadline is None:
return False, False
now = time.monotonic()
if now >= deadline:
return True, True # half-open: allow one probe
return True, False # still in cooldown
def _cb_record_failure(self, name: str) -> None:
"""Record a failure against *name*, potentially opening the circuit."""
count = self._consecutive_failures.get(name, 0) + 1
self._consecutive_failures[name] = count
# Guard: don't extend an already-open deadline. Additional failures
# while open still accumulate in _consecutive_failures, so the circuit
# re-opens immediately after the next half-open probe fails (count is
# already >= threshold).
if count >= self._CB_FAILURE_THRESHOLD and name not in self._circuit_open_until:
trips = self._circuit_trip_count.get(name, 0)
cooldown = min(self._CB_BASE_COOLDOWN * (2**trips), self._CB_MAX_COOLDOWN)
# Per-server jitter seeded from server name (varies across process
# restarts via PYTHONHASHSEED, which is desirable — each cluster
# node gets different jitter to avoid thundering herd).
jitter = random.Random(hash(name)).random() * cooldown * 0.1
self._circuit_open_until[name] = time.monotonic() + cooldown + jitter
self._circuit_trip_count[name] = trips + 1
log.warning(
"MCP circuit open for '%s': %d consecutive failures, cooldown %.0fs",
name,
count,
cooldown + jitter,
)
def _cb_record_success(self, name: str) -> None:
"""Record a successful operation for *name*, decaying circuit state.
Decays trip count by 1 rather than resetting to 0, so a chronically
flapping server escalates its backoff over time instead of always
restarting at the minimum cooldown.
"""
self._consecutive_failures.pop(name, None)
self._circuit_open_until.pop(name, None)
trips = self._circuit_trip_count.get(name, 0)
if trips > 1:
self._circuit_trip_count[name] = trips - 1
else:
self._circuit_trip_count.pop(name, None)
def _cb_clear(self, name: str) -> None:
"""Remove all circuit breaker state for *name*."""
self._consecutive_failures.pop(name, None)
self._circuit_open_until.pop(name, None)
self._circuit_trip_count.pop(name, None)
# -- safe transport helpers ------------------------------------------------
async def _pre_close_streams(self, name: str) -> None:
"""Close MCP transport streams before stack teardown.
Pre-closing unblocks anyio transport tasks stuck on zero-buffer
``send()`` calls, preventing the CPU busy-loop from SDK #2147.
"""
streams = self._server_streams.pop(name, None)
if streams:
for s in streams:
with contextlib.suppress(Exception):
await s.aclose()
async def _tcp_probe(self, name: str, url: str) -> None:
"""Fast TCP connect check before entering the MCP transport context.
@@ -356,16 +251,6 @@ class MCPClientManager:
log.error("MCP server name '%s' contains '__' (reserved delimiter), skipping", name)
return
# Guard: tear down stale session/stack so we don't leak. Checks both
# _sessions and _per_server_stacks because transport errors in the sync
# dispatch methods evict the session but leave the stack behind.
if name in self._sessions or name in self._per_server_stacks:
self._sessions.pop(name, None)
await self._pre_close_streams(name)
old_stack = self._per_server_stacks.pop(name, None)
if old_stack:
await self._safe_close_stack(old_stack)
# Per-server exit stack for clean per-server lifecycle management
stack = AsyncExitStack()
await stack.__aenter__()
@@ -386,9 +271,6 @@ class MCPClientManager:
),
timeout=self._CONNECT_TIMEOUT,
)
# Stash stream refs so _pre_close_streams can unblock anyio
# transport tasks before the cancel scope fires (SDK #2147).
self._server_streams[name] = (read, write)
else:
# Default: stdio transport
command = cfg.get("command", "")
@@ -405,29 +287,24 @@ class MCPClientManager:
env=env,
)
read, write = await stack.enter_async_context(stdio_client(params))
self._server_streams[name] = (read, write)
except asyncio.CancelledError:
# Stray CancelledError from broken anyio cancel scope -- treat as
# Stray CancelledError from broken anyio cancel scope treat as
# connection failure. But if the task is genuinely being cancelled
# (shutdown), re-raise so we don't block teardown.
task = asyncio.current_task()
if task is not None and task.cancelling():
await self._pre_close_streams(name)
await self._safe_close_stack(stack)
raise
log.warning("MCP server '%s' connection failed (anyio cancel)", name)
await self._pre_close_streams(name)
await self._safe_close_stack(stack)
raise TimeoutError(f"Connection failed for '{name}'") from None
except TimeoutError:
log.warning(
"MCP server '%s' connection timed out after %ds", name, self._CONNECT_TIMEOUT
)
await self._pre_close_streams(name)
await self._safe_close_stack(stack)
raise TimeoutError(f"Connection timed out after {self._CONNECT_TIMEOUT}s") from None
except Exception:
await self._pre_close_streams(name)
await self._safe_close_stack(stack)
raise
@@ -439,30 +316,15 @@ class MCPClientManager:
if not isinstance(msg, mcp_types.ServerNotification):
return
root = msg.root
# Debounce: skip if we refreshed this server very recently
now = time.monotonic()
last = self._last_notification_refresh.get(name, 0.0)
if now - last < self._NOTIFICATION_DEBOUNCE:
log.debug(
"Debouncing notification from '%s' (%.1fs since last refresh)",
name,
now - last,
)
return
try:
if isinstance(root, mcp_types.ToolListChangedNotification):
log.info("Received tools/list_changed from '%s'", name)
self._last_notification_refresh[name] = now
await self._refresh_server_tools(name)
elif isinstance(root, mcp_types.ResourceListChangedNotification):
log.info("Received resources/list_changed from '%s'", name)
self._last_notification_refresh[name] = now
await self._refresh_server_resources(name)
elif isinstance(root, mcp_types.PromptListChangedNotification):
log.info("Received prompts/list_changed from '%s'", name)
self._last_notification_refresh[name] = now
await self._refresh_server_prompts(name)
self._last_error.pop(name, None)
except Exception as exc:
@@ -474,7 +336,6 @@ class MCPClientManager:
ClientSession(read, write, message_handler=_on_notification) # type: ignore[arg-type]
)
except Exception:
await self._pre_close_streams(name)
await self._safe_close_stack(stack)
raise
@@ -485,20 +346,16 @@ class MCPClientManager:
self._per_server_stacks.pop(name, None)
task = asyncio.current_task()
if task is not None and task.cancelling():
await self._pre_close_streams(name)
await self._safe_close_stack(stack)
raise
await self._pre_close_streams(name)
await self._safe_close_stack(stack)
raise TimeoutError(f"MCP handshake failed for '{name}'") from None
except TimeoutError:
self._per_server_stacks.pop(name, None)
await self._pre_close_streams(name)
await self._safe_close_stack(stack)
raise TimeoutError(f"MCP handshake timed out after {self._CONNECT_TIMEOUT}s") from None
except Exception:
self._per_server_stacks.pop(name, None)
await self._pre_close_streams(name)
await self._safe_close_stack(stack)
raise
self._sessions[name] = session
@@ -691,14 +548,12 @@ class MCPClientManager:
if cfg:
log.info("Reconnecting MCP server '%s'", name)
await self._connect_one(name, cfg)
self._cb_record_success(name)
new_names = [
t["function"]["name"] for t in self._per_server_tools.get(name, [])
]
results[name] = (new_names, [])
continue
added, removed = await self._refresh_server(name)
self._cb_record_success(name)
results[name] = (added, removed)
except Exception as exc:
log.warning("Refresh failed for MCP server '%s'", name, exc_info=True)
@@ -722,18 +577,10 @@ class MCPClientManager:
"""
assert self._loop is not None
future = asyncio.run_coroutine_threadsafe(self._refresh_all(server_name), self._loop)
try:
return future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
future.cancel()
raise TimeoutError(f"MCP refresh timed out after {timeout}s") from None
return future.result(timeout=timeout)
async def _periodic_refresh(self) -> None:
"""Periodically refresh servers that lack push notifications.
Applies per-server exponential backoff on failure and attempts
reconnection for disconnected servers.
"""
"""Periodically refresh servers that lack push notifications."""
# Stagger start using a launch-time seed so cluster nodes don't
# all hit MCP servers simultaneously.
seed = random.Random(time.monotonic_ns() ^ os.getpid()).random()
@@ -741,42 +588,8 @@ class MCPClientManager:
await asyncio.sleep(initial_delay)
while True:
for name in list(self._server_configs):
now = time.monotonic()
# Check per-server backoff
backoff_until = self._refresh_backoff_until.get(name, 0.0)
if now < backoff_until:
continue # still in backoff
if name not in self._sessions:
# Attempt reconnection for disconnected servers
cfg = self._server_configs.get(name)
if cfg:
try:
log.info("Periodic reconnect attempt for '%s'", name)
await self._connect_one(name, cfg)
self._refresh_failures.pop(name, None)
self._refresh_backoff_until.pop(name, None)
self._cb_record_success(name)
except asyncio.CancelledError:
raise
except Exception as exc:
failures = self._refresh_failures.get(name, 0) + 1
self._refresh_failures[name] = failures
backoff = min(
self._REFRESH_BACKOFF_BASE * (2 ** (failures - 1)),
self._REFRESH_BACKOFF_MAX,
)
self._refresh_backoff_until[name] = time.monotonic() + backoff
log.warning(
"Periodic reconnect failed for '%s' (attempt %d, backoff %.0fs)",
name,
failures,
backoff,
)
self._set_error(name, f"Reconnect failed: {exc}")
continue
continue # not connected — skip (reconnect on manual refresh)
try:
if not self._supports_list_changed.get(name, False):
await self._refresh_server_tools(name)
@@ -785,26 +598,9 @@ class MCPClientManager:
if not self._supports_prompt_list_changed.get(name, False):
await self._refresh_server_prompts(name)
self._last_error.pop(name, None)
self._refresh_failures.pop(name, None)
self._refresh_backoff_until.pop(name, None)
except Exception as exc:
failures = self._refresh_failures.get(name, 0) + 1
self._refresh_failures[name] = failures
backoff = min(
self._REFRESH_BACKOFF_BASE * (2 ** (failures - 1)),
self._REFRESH_BACKOFF_MAX,
)
self._refresh_backoff_until[name] = time.monotonic() + backoff
log.warning(
"Periodic refresh failed for '%s' (attempt %d, backoff %.0fs)",
name,
failures,
backoff,
)
log.warning("Periodic refresh failed for '%s'", name, exc_info=True)
self._set_error(name, f"Periodic refresh failed: {exc}")
# Note: per-server backoff (max 1h) is only meaningful when
# refresh_interval is shorter than _REFRESH_BACKOFF_MAX. With
# the default 4h interval this sleep already bounds retry frequency.
await asyncio.sleep(self._refresh_interval)
# -- resource refresh ----------------------------------------------------
@@ -1144,9 +940,6 @@ class MCPClientManager:
if self._loop and self._per_server_stacks:
async def _close_all_stacks() -> None:
# Pre-close streams to prevent anyio CPU busy-loop during teardown
for srv_name in list(self._server_streams):
await self._pre_close_streams(srv_name)
for stack in self._per_server_stacks.values():
await self._safe_close_stack(stack)
@@ -1192,14 +985,6 @@ class MCPClientManager:
self._listeners.clear()
self._resource_listeners.clear()
self._prompt_listeners.clear()
# Clear resilience state
self._consecutive_failures.clear()
self._circuit_open_until.clear()
self._circuit_trip_count.clear()
self._server_streams.clear()
self._last_notification_refresh.clear()
self._refresh_failures.clear()
self._refresh_backoff_until.clear()
log.info("MCP client shut down")
@@ -1264,7 +1049,6 @@ class MCPClientManager:
async def _remove() -> None:
# Close session + transport via per-server stack
self._sessions.pop(name, None)
await self._pre_close_streams(name)
stack = self._per_server_stacks.pop(name, None)
if stack is not None:
await self._safe_close_stack(stack)
@@ -1278,10 +1062,6 @@ class MCPClientManager:
self._supports_prompts.pop(name, None)
self._supports_prompt_list_changed.pop(name, None)
self._last_error.pop(name, None)
self._last_notification_refresh.pop(name, None)
self._refresh_failures.pop(name, None)
self._refresh_backoff_until.pop(name, None)
self._cb_clear(name)
# Rebuild merged state (serialized with notification handlers)
self._rebuild_tools()
self._rebuild_resources()
@@ -1295,7 +1075,6 @@ class MCPClientManager:
else:
# No event loop (tests / pre-start) — mutate directly
self._sessions.pop(name, None)
self._server_streams.pop(name, None)
self._per_server_tools.pop(name, None)
self._per_server_resources.pop(name, None)
self._per_server_prompts.pop(name, None)
@@ -1305,10 +1084,6 @@ class MCPClientManager:
self._supports_prompts.pop(name, None)
self._supports_prompt_list_changed.pop(name, None)
self._last_error.pop(name, None)
self._last_notification_refresh.pop(name, None)
self._refresh_failures.pop(name, None)
self._refresh_backoff_until.pop(name, None)
self._cb_clear(name)
self._rebuild_tools()
self._rebuild_resources()
self._rebuild_prompts()
@@ -1332,8 +1107,6 @@ class MCPClientManager:
connected = name in self._sessions
cfg = self._server_configs.get(name, {})
transport = cfg.get("type", "stdio")
cb_deadline = self._circuit_open_until.get(name)
cb_open = cb_deadline is not None and time.monotonic() < cb_deadline
return {
"connected": connected,
"tools": len(self._per_server_tools.get(name, [])) if connected else 0,
@@ -1343,8 +1116,6 @@ class MCPClientManager:
"transport": transport,
"command": cfg.get("command", "") if transport == "stdio" else "",
"url": cfg.get("url", "") if transport != "stdio" else "",
"circuit_open": cb_open,
"consecutive_failures": self._consecutive_failures.get(name, 0),
}
def get_all_server_status(self) -> dict[str, dict[str, Any]]:
@@ -1474,55 +1245,6 @@ class MCPClientManager:
# -- tool invocation -----------------------------------------------------
def _cb_gate(self, server_name: str) -> None:
"""Check circuit breaker before dispatching to *server_name*.
Raises ``RuntimeError`` if the circuit is open and cooldown has not
expired. When the cooldown has expired (half-open), clears the
deadline so the probe attempt is allowed through.
"""
is_open, cooldown_expired = self._cb_check(server_name)
if is_open and not cooldown_expired:
remaining = self._circuit_open_until.get(server_name, 0) - time.monotonic()
raise RuntimeError(
f"MCP server '{server_name}' circuit open "
f"(cooldown {remaining:.0f}s remaining). "
f"Use '/mcp refresh {server_name}' to retry manually."
)
if cooldown_expired:
# Remove deadline so concurrent callers aren't rejected while the
# probe is in-flight. This intentionally allows multiple callers
# through rather than a single probe: reconnects serialize on the
# event loop via _connect_one's guard, and if the server is truly
# broken the first failure re-trips the circuit immediately.
self._circuit_open_until.pop(server_name, None)
def _cb_auto_reconnect(self, server_name: str) -> Any:
"""Attempt reconnection for a disconnected server during half-open probe.
Returns the new session on success, or raises on failure.
"""
cfg = self._server_configs.get(server_name)
if not cfg or self._loop is None:
raise RuntimeError(f"MCP server '{server_name}' is not connected")
reconnect_future = asyncio.run_coroutine_threadsafe(
self._connect_one(server_name, cfg), self._loop
)
try:
reconnect_future.result(timeout=self._CONNECT_TIMEOUT)
except concurrent.futures.TimeoutError:
reconnect_future.cancel()
self._cb_record_failure(server_name)
raise RuntimeError(f"MCP server '{server_name}' reconnect timed out") from None
except Exception as exc:
self._cb_record_failure(server_name)
raise RuntimeError(f"MCP server '{server_name}' reconnect failed: {exc}") from None
session = self._sessions.get(server_name)
if session is None:
self._cb_record_failure(server_name)
raise RuntimeError(f"MCP server '{server_name}' reconnect produced no session")
return session
def call_tool_sync(
self,
func_name: str,
@@ -1532,19 +1254,15 @@ class MCPClientManager:
"""Execute an MCP tool call synchronously (blocks the calling thread).
Dispatches an async ``tools/call`` to the background event loop and
waits for the result. Includes circuit-breaker gating and automatic
reconnection for servers recovering from failure.
waits for the result.
"""
mapping = self._tool_map.get(func_name)
if mapping is None:
raise ValueError(f"Unknown MCP tool: {func_name}")
server_name, original_name = mapping
self._cb_gate(server_name)
session = self._sessions.get(server_name)
if session is None:
session = self._cb_auto_reconnect(server_name)
raise RuntimeError(f"MCP server '{server_name}' is not connected")
assert self._loop is not None
future = asyncio.run_coroutine_threadsafe(
@@ -1553,19 +1271,7 @@ class MCPClientManager:
try:
result = future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
future.cancel()
self._cb_record_failure(server_name)
raise TimeoutError(f"MCP tool call timed out after {timeout}s") from None
except Exception as exc:
# Protocol errors (McpError) come from a healthy connection that
# rejected the request — only transport errors trip the breaker.
if not isinstance(exc, McpError):
self._cb_record_failure(server_name)
if isinstance(exc, (BrokenPipeError, ConnectionResetError, EOFError)):
self._sessions.pop(server_name, None)
raise
self._cb_record_success(server_name)
# Extract text from the content array
texts: list[str] = []
@@ -1614,29 +1320,16 @@ class MCPClientManager:
if mapping is None:
raise ValueError(f"Unknown MCP resource: {uri}")
server_name, _ = mapping
self._cb_gate(server_name)
session = self._sessions.get(server_name)
if session is None:
session = self._cb_auto_reconnect(server_name)
raise RuntimeError(f"MCP server '{server_name}' is not connected")
assert self._loop is not None
future = asyncio.run_coroutine_threadsafe(session.read_resource(uri), self._loop)
try:
result = future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
future.cancel()
self._cb_record_failure(server_name)
raise TimeoutError(f"MCP resource read timed out after {timeout}s") from None
except Exception as exc:
if not isinstance(exc, McpError):
self._cb_record_failure(server_name)
if isinstance(exc, (BrokenPipeError, ConnectionResetError, EOFError)):
self._sessions.pop(server_name, None)
raise
self._cb_record_success(server_name)
parts: list[str] = []
for item in result.contents:
@@ -1664,12 +1357,9 @@ class MCPClientManager:
if mapping is None:
raise ValueError(f"Unknown MCP prompt: {prefixed_name}")
server_name, original_name = mapping
self._cb_gate(server_name)
session = self._sessions.get(server_name)
if session is None:
session = self._cb_auto_reconnect(server_name)
raise RuntimeError(f"MCP server '{server_name}' is not connected")
assert self._loop is not None
future = asyncio.run_coroutine_threadsafe(
@@ -1678,17 +1368,7 @@ class MCPClientManager:
try:
result = future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
future.cancel()
self._cb_record_failure(server_name)
raise TimeoutError(f"MCP prompt retrieval timed out after {timeout}s") from None
except Exception as exc:
if not isinstance(exc, McpError):
self._cb_record_failure(server_name)
if isinstance(exc, (BrokenPipeError, ConnectionResetError, EOFError)):
self._sessions.pop(server_name, None)
raise
self._cb_record_success(server_name)
messages: list[dict[str, Any]] = []
for msg in result.messages:
+8 -26
View File
@@ -199,18 +199,6 @@ def _resolve_env_vars(value: str) -> str:
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", _replace, value)
def _resolve_openai_provider(provider: str, base_url: str) -> str:
"""Distinguish commercial OpenAI from local OpenAI-compatible servers.
When ``provider`` is ``"openai"`` but the ``base_url`` does not point to
``api.openai.com``, the model is on a local server (vLLM, llama.cpp, etc.)
and should use the Chat Completions provider (``"openai-compatible"``).
"""
if provider == "openai" and base_url and "api.openai.com" not in base_url:
return "openai-compatible"
return provider
def load_model_registry(
base_url: str,
api_key: str,
@@ -253,16 +241,15 @@ def load_model_registry(
if isinstance(parsed, dict):
caps = parsed
except (_json.JSONDecodeError, TypeError):
pass # falls back to empty capabilities
row_base_url = _resolve_env_vars(row.get("base_url", ""))
row_provider = _resolve_openai_provider(row.get("provider", "openai"), row_base_url)
pass
row_provider = row.get("provider", "openai")
row_model = row["model"]
# 0 = auto-detect: inherit CLI-detected context_window,
# same fallback chain as config.toml models
row_ctx = row.get("context_window", 0) or context_window
configs[alias] = ModelConfig(
alias=alias,
base_url=row_base_url,
base_url=_resolve_env_vars(row.get("base_url", "")),
api_key=_resolve_env_vars(row.get("api_key", "")),
model=row_model,
context_window=row_ctx,
@@ -281,14 +268,13 @@ def load_model_registry(
if not model_name:
log.warning("Model entry '%s' has no model name, skipping", alias)
continue
entry_base_url = _resolve_env_vars(entry.get("base_url", base_url))
configs[alias] = ModelConfig(
alias=alias,
base_url=entry_base_url,
api_key=_resolve_env_vars(entry.get("api_key", api_key)),
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),
provider=_resolve_openai_provider(entry.get("provider", "openai"), entry_base_url),
provider=entry.get("provider", "openai"),
capabilities=entry.get("capabilities", {})
if isinstance(entry.get("capabilities"), dict)
else {},
@@ -304,7 +290,7 @@ def load_model_registry(
api_key=api_key,
model=model,
context_window=context_window,
provider=_resolve_openai_provider(provider, base_url),
provider=provider,
)
# Determine default alias
@@ -560,11 +546,7 @@ def _detect_openai_compat(
result["context_window"] = known["context_window"]
# Server type heuristics
from urllib.parse import urlparse
_normalized = (base_url if "://" in base_url else f"https://{base_url}") if base_url else ""
_hostname = urlparse(_normalized).hostname or "" if _normalized else ""
if base_url and (_hostname == "api.openai.com" or _hostname.endswith(".openai.com")):
if base_url and "api.openai.com" in base_url:
result["server_type"] = "openai"
elif meta is not None and "n_ctx_train" in meta:
result["server_type"] = "llama.cpp"
+4 -11
View File
@@ -6,8 +6,6 @@ import threading
from typing import Any
from turnstone.core.providers._openai import OpenAIProvider
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
from turnstone.core.providers._openai_responses import OpenAIResponsesProvider
from turnstone.core.providers._protocol import (
CompletionResult,
LLMProvider,
@@ -21,9 +19,7 @@ __all__ = [
"CompletionResult",
"LLMProvider",
"ModelCapabilities",
"OpenAIChatCompletionsProvider",
"OpenAIProvider",
"OpenAIResponsesProvider",
"StreamChunk",
"ToolCallDelta",
"UsageInfo",
@@ -35,18 +31,15 @@ __all__ = [
# Singleton instances (stateless, safe to share)
_provider_lock = threading.Lock()
_openai_provider = OpenAIResponsesProvider()
_openai_compat_provider = OpenAIChatCompletionsProvider()
_openai_provider = OpenAIProvider()
_anthropic_provider: LLMProvider | None = None
def create_provider(provider_name: str) -> LLMProvider:
"""Return a provider adapter for the given provider name. Thread-safe."""
global _anthropic_provider # noqa: PLW0603
if provider_name == "openai":
if provider_name in ("openai", "openai-compatible"):
return _openai_provider
if provider_name == "openai-compatible":
return _openai_compat_provider
if provider_name == "anthropic":
with _provider_lock:
if _anthropic_provider is None:
@@ -106,9 +99,9 @@ def lookup_model_capabilities(provider: str, model: str) -> dict[str, Any] | Non
def list_known_models(provider: str) -> list[str]:
"""Return the model name prefixes in the static capability table."""
if provider == "openai":
from turnstone.core.providers._openai_common import OPENAI_CAPABILITIES
from turnstone.core.providers._openai import _OPENAI_CAPABILITIES
return sorted(OPENAI_CAPABILITIES.keys())
return sorted(_OPENAI_CAPABILITIES.keys())
if provider == "anthropic":
from turnstone.core.providers._anthropic import _ANTHROPIC_CAPABILITIES
+1 -1
View File
@@ -708,7 +708,7 @@ class AnthropicProvider:
parsed = json.loads(info["input_json"])
query = parsed.get("query", "")
except (json.JSONDecodeError, TypeError):
pass # best-effort query extraction for status
pass
sc.info_delta = f"[Searching: {query}]" if query else "[Searching...]"
elif event_type == "message_delta":
+571 -24
View File
@@ -1,30 +1,577 @@
"""Re-export shim for backwards compatibility.
"""OpenAI-compatible provider — wraps current behavior with zero semantic change.
The OpenAI provider family is split into:
- ``_openai_chat.py`` Chat Completions API (local model servers)
- ``_openai_responses.py`` Responses API (commercial OpenAI)
- ``_openai_common.py`` shared capability table, helpers
``OpenAIProvider`` is preserved as an alias for ``OpenAIChatCompletionsProvider``
so existing code that imports it directly continues to work.
Handles OpenAI, vLLM, llama.cpp, and any server that speaks the
OpenAI Chat Completions API.
"""
from turnstone.core.providers._openai_chat import (
OpenAIChatCompletionsProvider,
)
from turnstone.core.providers._openai_chat import (
OpenAIChatCompletionsProvider as OpenAIProvider,
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
import structlog
from turnstone.core.providers._protocol import (
CompletionResult,
ModelCapabilities,
StreamChunk,
ToolCallDelta,
UsageInfo,
_lookup_capabilities,
)
# Backwards-compatible aliases for the capability tables
from turnstone.core.providers._openai_common import (
OPENAI_CAPABILITIES as _OPENAI_CAPABILITIES, # noqa: F401
)
from turnstone.core.providers._openai_common import OPENAI_DEFAULT as _OPENAI_DEFAULT # noqa: F401
from turnstone.core.providers._openai_responses import OpenAIResponsesProvider
log = structlog.get_logger(__name__)
__all__ = [
"OpenAIChatCompletionsProvider",
"OpenAIProvider",
"OpenAIResponsesProvider",
]
# -- model capabilities -------------------------------------------------------
_OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
# GPT-5 base — NO temperature support
"gpt-5": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("minimal", "low", "medium", "high"),
default_reasoning_effort="medium",
supports_vision=True,
),
"gpt-5-mini": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("minimal", "low", "medium", "high"),
default_reasoning_effort="medium",
supports_vision=True,
),
"gpt-5-nano": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("minimal", "low", "medium", "high"),
default_reasoning_effort="medium",
supports_vision=True,
),
# GPT-5 pro — high reasoning only, extended output
"gpt-5-pro": ModelCapabilities(
context_window=400000,
max_output_tokens=272000,
supports_temperature=False,
reasoning_effort_values=("high",),
default_reasoning_effort="high",
supports_vision=True,
),
# GPT-5.1 — temperature OK when reasoning_effort=none (default)
"gpt-5.1": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
reasoning_effort_values=("none", "low", "medium", "high"),
default_reasoning_effort="none",
supports_vision=True,
),
# GPT-5.2 — adds xhigh
"gpt-5.2": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
reasoning_effort_values=("none", "low", "medium", "high", "xhigh"),
default_reasoning_effort="none",
supports_vision=True,
),
# GPT-5.2 pro — always-reasoning variant
"gpt-5.2-pro": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("medium", "high", "xhigh"),
default_reasoning_effort="medium",
supports_vision=True,
),
# GPT-5.3 — same capabilities as 5.2 (matches gpt-5.3-chat-latest, codex)
"gpt-5.3": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
reasoning_effort_values=("none", "low", "medium", "high", "xhigh"),
default_reasoning_effort="none",
supports_vision=True,
),
# GPT-5.4 — 1M context window, native tool search
"gpt-5.4": ModelCapabilities(
context_window=1050000,
max_output_tokens=128000,
reasoning_effort_values=("none", "low", "medium", "high", "xhigh"),
default_reasoning_effort="none",
supports_tool_search=True,
supports_vision=True,
),
# GPT-5.4 pro — always-reasoning, 1M context, native tool search
"gpt-5.4-pro": ModelCapabilities(
context_window=1050000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("medium", "high", "xhigh"),
default_reasoning_effort="medium",
supports_tool_search=True,
supports_vision=True,
),
# O-series reasoning models
"o1": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
supports_streaming=False,
supports_vision=True,
),
"o1-mini": ModelCapabilities(
context_window=128000,
max_output_tokens=65536,
supports_temperature=False,
supports_streaming=False,
supports_vision=True,
),
"o3": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
supports_vision=True,
),
"o3-mini": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
supports_vision=True,
),
"o3-pro": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
supports_streaming=False,
supports_vision=True,
),
"o4-mini": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
supports_vision=True,
),
# Search models — always search on every request, no reasoning_effort
"gpt-5-search-api": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
supports_web_search=True,
reasoning_effort_values=(),
supports_vision=True,
),
}
# Default for unknown models (local servers: vLLM, llama.cpp, etc.)
_OPENAI_DEFAULT = ModelCapabilities()
class OpenAIProvider:
"""Provider for OpenAI-compatible APIs (OpenAI, vLLM, llama.cpp, etc.)."""
@property
def provider_name(self) -> str:
return "openai"
def get_capabilities(self, model: str) -> ModelCapabilities:
return _lookup_capabilities(model, _OPENAI_CAPABILITIES, _OPENAI_DEFAULT)
# -- shared param logic --------------------------------------------------
def _apply_model_params(
self,
kwargs: dict[str, Any],
caps: ModelCapabilities,
temperature: float,
reasoning_effort: str,
) -> None:
"""Conditionally add temperature and reasoning_effort to *kwargs*.
- Models with ``supports_temperature=False`` (GPT-5 base, O-series)
never receive temperature.
- Models that list ``"none"`` in their effort values (GPT-5.1/5.2)
only receive temperature when reasoning is inactive.
- ``reasoning_effort`` is forwarded as a first-class API parameter
only for models that declare supported effort values.
"""
if caps.supports_temperature:
# GPT-5.1/5.2: temperature only valid when reasoning_effort is "none"
if "none" in caps.reasoning_effort_values and reasoning_effort not in (
"none",
"",
):
pass # Skip temperature when reasoning is active
else:
kwargs["temperature"] = temperature
if caps.reasoning_effort_values and reasoning_effort and reasoning_effort != "none":
# Validate against supported values; fall back to model default
if reasoning_effort in caps.reasoning_effort_values:
kwargs["reasoning_effort"] = reasoning_effort
elif caps.default_reasoning_effort and caps.default_reasoning_effort != "none":
kwargs["reasoning_effort"] = caps.default_reasoning_effort
# -- web search ----------------------------------------------------------
def _apply_web_search(
self,
kwargs: dict[str, Any],
caps: ModelCapabilities,
tools: list[dict[str, Any]] | None,
) -> list[dict[str, Any]] | None:
"""Inject ``web_search_options`` for search models.
For models with ``supports_web_search``, the web search function tool
is removed (the model searches automatically) and ``web_search_options``
is added to the request kwargs.
Returns the (possibly filtered) tools list.
"""
if not caps.supports_web_search:
return tools
# Remove web_search function tool — model has built-in search
if tools:
tools = [t for t in tools if t.get("function", {}).get("name") != "web_search"]
if not tools:
tools = None
kwargs["web_search_options"] = {}
return tools
# -- prompt cache retention -----------------------------------------------
@staticmethod
def _apply_cache_retention(kwargs: dict[str, Any], model: str) -> None:
"""Enable 24-hour extended prompt cache retention for GPT-5.x models.
OpenAI caching is automatic (no code changes for basic caching), but
the default TTL is only 5-10 minutes. Extended retention keeps cached
KV tensors for up to 24 hours at no additional cost, which is valuable
for workstreams with bursty activity patterns.
"""
# GPT-5, GPT-5.1, GPT-5.2, GPT-5.3, GPT-5.4 and variants
if model.startswith("gpt-5"):
kwargs["prompt_cache_retention"] = "24h"
# -- tool search ---------------------------------------------------------
def _apply_tool_search(
self,
caps: ModelCapabilities,
tools: list[dict[str, Any]] | None,
deferred_names: frozenset[str] | None = None,
) -> list[dict[str, Any]] | None:
"""Mark deferred tools with ``defer_loading: true`` for native search.
For GPT-5.4+ models that support tool search, OpenAI's API handles
discovery automatically no explicit search tool is needed.
"""
if not caps.supports_tool_search or not deferred_names or not tools:
return tools
result = []
for tool in tools:
name = tool.get("function", {}).get("name", "")
if name in deferred_names:
result.append({**tool, "defer_loading": True})
else:
result.append(tool)
return result
# -- message sanitisation ------------------------------------------------
@staticmethod
def _sanitize_messages(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Ensure assistant messages always have ``content`` or ``tool_calls``.
OpenAI-compatible APIs reject assistant messages that have neither.
This is a defensive catch-all; the upstream layers should already
guarantee well-formed messages.
"""
out: list[dict[str, Any]] = []
for msg in messages:
if (
msg.get("role") == "assistant"
and msg.get("content") is None
and not msg.get("tool_calls")
):
msg = {**msg, "content": ""}
out.append(msg)
return out
# -- streaming -----------------------------------------------------------
def create_streaming(
self,
*,
client: Any,
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
max_tokens: int = 4096,
temperature: float = 0.5,
reasoning_effort: str = "medium",
extra_params: dict[str, Any] | None = None,
deferred_names: frozenset[str] | None = None,
cancel_ref: list[Any] | None = None,
) -> Iterator[StreamChunk]:
caps = self.get_capabilities(model)
messages = self._sanitize_messages(messages)
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
caps.token_param: max_tokens,
"stream": True,
"stream_options": {"include_usage": True},
}
self._apply_model_params(kwargs, caps, temperature, reasoning_effort)
self._apply_cache_retention(kwargs, model)
tools = self._apply_web_search(kwargs, caps, tools)
tools = self._apply_tool_search(caps, tools, deferred_names)
if tools:
kwargs["tools"] = tools
if extra_params:
kwargs["extra_body"] = extra_params
log.debug(
"openai.request",
model=model,
stream=True,
max_tokens=max_tokens,
message_count=len(messages),
tool_count=len(tools) if tools else 0,
)
stream = client.chat.completions.create(**kwargs)
if cancel_ref is not None:
cancel_ref.append(stream)
return self._iter_stream(stream)
def _iter_stream(self, stream: Any) -> Iterator[StreamChunk]:
"""Convert OpenAI stream chunks to normalized StreamChunks."""
first = True
annotations: list[Any] = []
content_len = 0
tool_call_count = 0
last_finish_reason: str | None = None
completion_tokens: int | None = None
for chunk in stream:
sc = StreamChunk()
# Finish reason
if chunk.choices and chunk.choices[0].finish_reason:
sc.finish_reason = chunk.choices[0].finish_reason
last_finish_reason = sc.finish_reason
# Usage from final chunk
if hasattr(chunk, "usage") and chunk.usage is not None:
u = chunk.usage
pt = getattr(u, "prompt_tokens", None)
ct = getattr(u, "completion_tokens", None)
tt = getattr(u, "total_tokens", None)
completion_tokens = ct
if pt is not None and ct is not None:
# Extract cached_tokens from prompt_tokens_details.
# OpenAI caching is automatic with no write premium, so
# cache_creation_tokens is always 0 (only Anthropic reports it).
ptd = getattr(u, "prompt_tokens_details", None)
cached = getattr(ptd, "cached_tokens", 0) if ptd else 0
sc.usage = UsageInfo(
prompt_tokens=pt,
completion_tokens=ct,
total_tokens=tt or (pt + ct),
cache_read_tokens=cached or 0,
)
if not chunk.choices:
if sc.usage:
yield sc
continue
delta = chunk.choices[0].delta
# Reasoning field (vLLM --reasoning-parser, llama.cpp)
rc = getattr(delta, "reasoning", None) or getattr(delta, "reasoning_content", None)
if rc:
sc.reasoning_delta = rc
# Content
if delta.content:
sc.content_delta = delta.content
content_len += len(delta.content)
# Tool calls
if delta.tool_calls:
for tc_delta in delta.tool_calls:
tcd = ToolCallDelta(index=tc_delta.index)
if tc_delta.id:
tcd.id = tc_delta.id
if tc_delta.function:
if tc_delta.function.name:
tcd.name = tc_delta.function.name
if tc_delta.function.arguments:
tcd.arguments_delta = tc_delta.function.arguments
sc.tool_call_deltas.append(tcd)
tool_call_count += 1
# Accumulate url_citation annotations from search models
delta_anns = getattr(delta, "annotations", None)
if delta_anns:
annotations.extend(delta_anns)
has_content = sc.content_delta or sc.reasoning_delta or sc.tool_call_deltas
if has_content and first:
sc.is_first = True
first = False
if has_content or sc.finish_reason or sc.usage:
yield sc
log.debug(
"openai.response",
stream=True,
finish_reason=last_finish_reason,
content_length=content_len,
tool_call_deltas=tool_call_count,
completion_tokens=completion_tokens,
)
# Emit accumulated citations as a final info chunk
if annotations:
citation_text = self._format_citations("", annotations).strip()
if citation_text:
yield StreamChunk(info_delta=citation_text)
# -- non-streaming -------------------------------------------------------
def create_completion(
self,
*,
client: Any,
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
max_tokens: int = 4096,
temperature: float = 0.5,
reasoning_effort: str = "medium",
extra_params: dict[str, Any] | None = None,
deferred_names: frozenset[str] | None = None,
) -> CompletionResult:
caps = self.get_capabilities(model)
messages = self._sanitize_messages(messages)
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
caps.token_param: max_tokens,
"stream": False,
}
self._apply_model_params(kwargs, caps, temperature, reasoning_effort)
self._apply_cache_retention(kwargs, model)
tools = self._apply_web_search(kwargs, caps, tools)
tools = self._apply_tool_search(caps, tools, deferred_names)
if tools:
kwargs["tools"] = tools
if extra_params:
kwargs["extra_body"] = extra_params
log.debug(
"openai.request",
model=model,
stream=False,
max_tokens=max_tokens,
message_count=len(messages),
tool_count=len(tools) if tools else 0,
)
response = client.chat.completions.create(**kwargs)
choice = response.choices[0]
msg = choice.message
tool_calls = None
if msg.tool_calls:
tool_calls = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in msg.tool_calls
]
# Extract url_citation annotations from web search models
content = msg.content or ""
annotations = getattr(msg, "annotations", None)
if annotations:
content = self._format_citations(content, annotations)
usage = None
if hasattr(response, "usage") and response.usage:
u = response.usage
ptd = getattr(u, "prompt_tokens_details", None)
cached = getattr(ptd, "cached_tokens", 0) if ptd else 0
usage = UsageInfo(
prompt_tokens=u.prompt_tokens,
completion_tokens=u.completion_tokens,
total_tokens=getattr(u, "total_tokens", None)
or (u.prompt_tokens + u.completion_tokens),
cache_read_tokens=cached or 0,
)
result = CompletionResult(
content=content,
tool_calls=tool_calls,
finish_reason=choice.finish_reason or "stop",
usage=usage,
)
log.debug(
"openai.response",
stream=False,
finish_reason=result.finish_reason,
content_length=len(content),
tool_call_count=len(tool_calls) if tool_calls else 0,
completion_tokens=usage.completion_tokens if usage else None,
)
return result
@staticmethod
def _format_citations(content: str, annotations: list[Any]) -> str:
"""Append url_citation sources as footnotes at the end of the content."""
seen_urls: set[str] = set()
sources: list[str] = []
for ann in annotations:
ann_type = getattr(ann, "type", None)
if ann_type == "url_citation":
citation = getattr(ann, "url_citation", None)
if citation:
title = getattr(citation, "title", "")
url = getattr(citation, "url", "")
if url and url not in seen_urls:
seen_urls.add(url)
sources.append(f"[{title}]({url})" if title else url)
if sources:
content += "\n\nSources:\n" + "\n".join(f"- {s}" for s in sources)
return content
# -- tool conversion -----------------------------------------------------
def convert_tools(
self,
tools: list[dict[str, Any]],
) -> list[dict[str, Any]]:
return tools # Already in OpenAI format
# -- retryable errors ----------------------------------------------------
@property
def retryable_error_names(self) -> frozenset[str]:
return frozenset(
{
"APIError",
"APIConnectionError",
"RateLimitError",
"Timeout",
"APITimeoutError",
}
)
-296
View File
@@ -1,296 +0,0 @@
"""Chat Completions provider — for local model servers (vLLM, llama.cpp, SGLang).
Wraps the OpenAI Chat Completions API (``/v1/chat/completions``).
Commercial OpenAI models should use ``OpenAIResponsesProvider`` instead.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
import structlog
from turnstone.core.providers._openai_common import (
RETRYABLE_ERROR_NAMES,
apply_cache_retention,
apply_temperature_and_effort,
apply_tool_search,
extract_usage,
format_citations,
lookup_openai_capabilities,
sanitize_messages,
)
from turnstone.core.providers._protocol import (
CompletionResult,
ModelCapabilities,
StreamChunk,
ToolCallDelta,
)
log = structlog.get_logger(__name__)
class OpenAIChatCompletionsProvider:
"""Provider for local OpenAI-compatible servers (vLLM, llama.cpp, SGLang).
Uses the Chat Completions API (``/v1/chat/completions``).
"""
@property
def provider_name(self) -> str:
return "openai-compatible"
def get_capabilities(self, model: str) -> ModelCapabilities:
return lookup_openai_capabilities(model)
# -- web search ----------------------------------------------------------
@staticmethod
def _apply_web_search(
kwargs: dict[str, Any],
caps: ModelCapabilities,
tools: list[dict[str, Any]] | None,
) -> list[dict[str, Any]] | None:
"""Inject ``web_search_options`` for search models.
For models with ``supports_web_search``, the web search function tool
is removed (the model searches automatically) and ``web_search_options``
is added to the request kwargs.
Returns the (possibly filtered) tools list.
"""
if not caps.supports_web_search:
return tools
if tools:
tools = [t for t in tools if t.get("function", {}).get("name") != "web_search"]
if not tools:
tools = None
kwargs["web_search_options"] = {}
return tools
# -- streaming -----------------------------------------------------------
def create_streaming(
self,
*,
client: Any,
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
max_tokens: int = 4096,
temperature: float = 0.5,
reasoning_effort: str = "medium",
extra_params: dict[str, Any] | None = None,
deferred_names: frozenset[str] | None = None,
cancel_ref: list[Any] | None = None,
) -> Iterator[StreamChunk]:
caps = self.get_capabilities(model)
messages = sanitize_messages(messages)
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
caps.token_param: max_tokens,
"stream": True,
"stream_options": {"include_usage": True},
}
apply_temperature_and_effort(kwargs, caps, temperature, reasoning_effort)
apply_cache_retention(kwargs, model)
tools = self._apply_web_search(kwargs, caps, tools)
tools = apply_tool_search(caps, tools, deferred_names)
if tools:
kwargs["tools"] = tools
if extra_params:
kwargs["extra_body"] = extra_params
log.debug(
"openai.chat.request",
model=model,
stream=True,
max_tokens=max_tokens,
message_count=len(messages),
tool_count=len(tools) if tools else 0,
)
stream = client.chat.completions.create(**kwargs)
if cancel_ref is not None:
cancel_ref.append(stream)
return self._iter_stream(stream)
def _iter_stream(self, stream: Any) -> Iterator[StreamChunk]:
"""Convert OpenAI Chat Completions stream chunks to StreamChunks."""
first = True
annotations: list[Any] = []
content_len = 0
tool_call_count = 0
last_finish_reason: str | None = None
completion_tokens: int | None = None
for chunk in stream:
sc = StreamChunk()
# Finish reason
if chunk.choices and chunk.choices[0].finish_reason:
sc.finish_reason = chunk.choices[0].finish_reason
last_finish_reason = sc.finish_reason
# Usage from final chunk
if hasattr(chunk, "usage") and chunk.usage is not None:
sc.usage = extract_usage(chunk.usage)
if sc.usage:
completion_tokens = sc.usage.completion_tokens
if not chunk.choices:
if sc.usage:
yield sc
continue
delta = chunk.choices[0].delta
# Reasoning field (vLLM --reasoning-parser, llama.cpp)
rc = getattr(delta, "reasoning", None) or getattr(delta, "reasoning_content", None)
if rc:
sc.reasoning_delta = rc
# Content
if delta.content:
sc.content_delta = delta.content
content_len += len(delta.content)
# Tool calls
if delta.tool_calls:
for tc_delta in delta.tool_calls:
tcd = ToolCallDelta(index=tc_delta.index)
if tc_delta.id:
tcd.id = tc_delta.id
if tc_delta.function:
if tc_delta.function.name:
tcd.name = tc_delta.function.name
if tc_delta.function.arguments:
tcd.arguments_delta = tc_delta.function.arguments
sc.tool_call_deltas.append(tcd)
tool_call_count += 1
# Accumulate url_citation annotations from search models
delta_anns = getattr(delta, "annotations", None)
if delta_anns:
annotations.extend(delta_anns)
has_content = sc.content_delta or sc.reasoning_delta or sc.tool_call_deltas
if has_content and first:
sc.is_first = True
first = False
if has_content or sc.finish_reason or sc.usage:
yield sc
log.debug(
"openai.chat.response",
stream=True,
finish_reason=last_finish_reason,
content_length=content_len,
tool_call_deltas=tool_call_count,
completion_tokens=completion_tokens,
)
# Emit accumulated citations as a final info chunk
if annotations:
citation_text = format_citations("", annotations).strip()
if citation_text:
yield StreamChunk(info_delta=citation_text)
# -- non-streaming -------------------------------------------------------
def create_completion(
self,
*,
client: Any,
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
max_tokens: int = 4096,
temperature: float = 0.5,
reasoning_effort: str = "medium",
extra_params: dict[str, Any] | None = None,
deferred_names: frozenset[str] | None = None,
) -> CompletionResult:
caps = self.get_capabilities(model)
messages = sanitize_messages(messages)
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
caps.token_param: max_tokens,
"stream": False,
}
apply_temperature_and_effort(kwargs, caps, temperature, reasoning_effort)
apply_cache_retention(kwargs, model)
tools = self._apply_web_search(kwargs, caps, tools)
tools = apply_tool_search(caps, tools, deferred_names)
if tools:
kwargs["tools"] = tools
if extra_params:
kwargs["extra_body"] = extra_params
log.debug(
"openai.chat.request",
model=model,
stream=False,
max_tokens=max_tokens,
message_count=len(messages),
tool_count=len(tools) if tools else 0,
)
response = client.chat.completions.create(**kwargs)
choice = response.choices[0]
msg = choice.message
tool_calls = None
if msg.tool_calls:
tool_calls = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in msg.tool_calls
]
# Extract url_citation annotations from web search models
content = msg.content or ""
annotations = getattr(msg, "annotations", None)
if annotations:
content = format_citations(content, annotations)
usage = extract_usage(getattr(response, "usage", None))
result = CompletionResult(
content=content,
tool_calls=tool_calls,
finish_reason=choice.finish_reason or "stop",
usage=usage,
)
log.debug(
"openai.chat.response",
stream=False,
finish_reason=result.finish_reason,
content_length=len(content),
tool_call_count=len(tool_calls) if tool_calls else 0,
completion_tokens=usage.completion_tokens if usage else None,
)
return result
# -- tool conversion -----------------------------------------------------
def convert_tools(
self,
tools: list[dict[str, Any]],
) -> list[dict[str, Any]]:
return tools # Already in OpenAI Chat Completions format
# -- retryable errors ----------------------------------------------------
@property
def retryable_error_names(self) -> frozenset[str]:
return RETRYABLE_ERROR_NAMES
-378
View File
@@ -1,378 +0,0 @@
"""Shared helpers for OpenAI-family providers (Chat Completions & Responses).
Capability table, temperature/reasoning gating, cache retention, citation
formatting, and message sanitisation live here so both
``OpenAIChatCompletionsProvider`` and ``OpenAIResponsesProvider`` stay DRY.
"""
from __future__ import annotations
from typing import Any
from turnstone.core.providers._protocol import (
ModelCapabilities,
UsageInfo,
_lookup_capabilities,
)
# ---------------------------------------------------------------------------
# Model capability table
# ---------------------------------------------------------------------------
OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
# GPT-5 base — NO temperature support
"gpt-5": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("minimal", "low", "medium", "high"),
default_reasoning_effort="medium",
supports_vision=True,
),
"gpt-5-mini": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("minimal", "low", "medium", "high"),
default_reasoning_effort="medium",
supports_vision=True,
),
"gpt-5-nano": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("minimal", "low", "medium", "high"),
default_reasoning_effort="medium",
supports_vision=True,
),
# GPT-5 pro — high reasoning only, extended output
"gpt-5-pro": ModelCapabilities(
context_window=400000,
max_output_tokens=272000,
supports_temperature=False,
reasoning_effort_values=("high",),
default_reasoning_effort="high",
supports_vision=True,
),
# GPT-5.1 — temperature OK when reasoning_effort=none (default)
"gpt-5.1": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
reasoning_effort_values=("none", "low", "medium", "high"),
default_reasoning_effort="none",
supports_vision=True,
),
# GPT-5.2 — adds xhigh
"gpt-5.2": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
reasoning_effort_values=("none", "low", "medium", "high", "xhigh"),
default_reasoning_effort="none",
supports_vision=True,
),
# GPT-5.2 pro — always-reasoning variant
"gpt-5.2-pro": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("medium", "high", "xhigh"),
default_reasoning_effort="medium",
supports_vision=True,
),
# GPT-5.3 — same capabilities as 5.2 (matches gpt-5.3-chat-latest, codex)
"gpt-5.3": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
reasoning_effort_values=("none", "low", "medium", "high", "xhigh"),
default_reasoning_effort="none",
supports_vision=True,
),
# GPT-5.4 — 1M context window, native tool search
"gpt-5.4": ModelCapabilities(
context_window=1050000,
max_output_tokens=128000,
reasoning_effort_values=("none", "low", "medium", "high", "xhigh"),
default_reasoning_effort="none",
supports_tool_search=True,
supports_vision=True,
),
# GPT-5.4 pro — always-reasoning, 1M context, native tool search
"gpt-5.4-pro": ModelCapabilities(
context_window=1050000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("medium", "high", "xhigh"),
default_reasoning_effort="medium",
supports_tool_search=True,
supports_vision=True,
),
# O-series reasoning models
"o1": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
supports_streaming=False,
supports_vision=True,
),
"o1-mini": ModelCapabilities(
context_window=128000,
max_output_tokens=65536,
supports_temperature=False,
supports_streaming=False,
supports_vision=True,
),
"o3": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
supports_vision=True,
),
"o3-mini": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
supports_vision=True,
),
"o3-pro": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
supports_streaming=False,
supports_vision=True,
),
"o4-mini": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
supports_vision=True,
),
# Search models — always search on every request, no reasoning_effort
"gpt-5-search-api": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
supports_web_search=True,
reasoning_effort_values=(),
supports_vision=True,
),
}
# Default for unknown models (local servers: vLLM, llama.cpp, etc.)
OPENAI_DEFAULT = ModelCapabilities()
def lookup_openai_capabilities(model: str) -> ModelCapabilities:
"""Find capabilities for *model* by longest prefix match."""
return _lookup_capabilities(model, OPENAI_CAPABILITIES, OPENAI_DEFAULT)
# ---------------------------------------------------------------------------
# Temperature and reasoning effort gating
# ---------------------------------------------------------------------------
def apply_temperature(
kwargs: dict[str, Any],
caps: ModelCapabilities,
temperature: float,
reasoning_effort: str,
) -> None:
"""Conditionally add temperature to *kwargs*.
- Models with ``supports_temperature=False`` (GPT-5 base, O-series)
never receive temperature.
- Models that list ``"none"`` in their effort values (GPT-5.1/5.2)
only receive temperature when reasoning is inactive.
"""
if not caps.supports_temperature:
return
if "none" in caps.reasoning_effort_values and reasoning_effort not in ("none", ""):
return # Skip temperature when reasoning is active
kwargs["temperature"] = temperature
def resolve_reasoning_effort(caps: ModelCapabilities, reasoning_effort: str) -> str | None:
"""Return the validated reasoning effort value, or ``None`` to omit.
Validates against supported values and falls back to model default.
"""
if not caps.reasoning_effort_values or not reasoning_effort or reasoning_effort == "none":
return None
if reasoning_effort in caps.reasoning_effort_values:
return reasoning_effort
if caps.default_reasoning_effort and caps.default_reasoning_effort != "none":
return caps.default_reasoning_effort
return None
def apply_temperature_and_effort(
kwargs: dict[str, Any],
caps: ModelCapabilities,
temperature: float,
reasoning_effort: str,
) -> None:
"""Conditionally add temperature and reasoning_effort to *kwargs*.
Chat Completions API version reasoning effort is a flat parameter.
"""
apply_temperature(kwargs, caps, temperature, reasoning_effort)
effort = resolve_reasoning_effort(caps, reasoning_effort)
if effort:
kwargs["reasoning_effort"] = effort
# ---------------------------------------------------------------------------
# Cache retention
# ---------------------------------------------------------------------------
def apply_cache_retention(kwargs: dict[str, Any], model: str) -> None:
"""Enable 24-hour extended prompt cache retention for GPT-5.x models.
OpenAI caching is automatic (no code changes for basic caching), but
the default TTL is only 5-10 minutes. Extended retention keeps cached
KV tensors for up to 24 hours at no additional cost, which is valuable
for workstreams with bursty activity patterns.
"""
if model.startswith("gpt-5"):
kwargs["prompt_cache_retention"] = "24h"
# ---------------------------------------------------------------------------
# Tool search (native deferred loading)
# ---------------------------------------------------------------------------
def apply_tool_search(
caps: ModelCapabilities,
tools: list[dict[str, Any]] | None,
deferred_names: frozenset[str] | None = None,
) -> list[dict[str, Any]] | None:
"""Mark deferred tools with ``defer_loading: true`` for native search.
For GPT-5.4+ models that support tool search, OpenAI's API handles
discovery automatically no explicit search tool is needed.
"""
if not caps.supports_tool_search or not deferred_names or not tools:
return tools
result = []
for tool in tools:
name = tool.get("function", {}).get("name", "")
if name in deferred_names:
result.append({**tool, "defer_loading": True})
else:
result.append(tool)
return result
# ---------------------------------------------------------------------------
# Citation formatting
# ---------------------------------------------------------------------------
def format_citations(content: str, annotations: list[Any]) -> str:
"""Append url_citation sources as footnotes at the end of the content."""
seen_urls: set[str] = set()
sources: list[str] = []
for ann in annotations:
ann_type = getattr(ann, "type", None)
if ann_type == "url_citation":
title: str = ""
url: str = ""
citation = getattr(ann, "url_citation", None)
if citation is not None:
# Chat Completions API: nested url_citation object
title = getattr(citation, "title", "") or ""
url = getattr(citation, "url", "") or ""
elif hasattr(ann, "url") and isinstance(getattr(ann, "url", None), str):
# Responses API: attributes directly on the annotation
title = getattr(ann, "title", "") or ""
url = getattr(ann, "url", "") or ""
if url and url not in seen_urls:
seen_urls.add(url)
sources.append(f"[{title}]({url})" if title else url)
if sources:
content += "\n\nSources:\n" + "\n".join(f"- {s}" for s in sources)
return content
# ---------------------------------------------------------------------------
# Message sanitisation (Chat Completions specific but shared for compat)
# ---------------------------------------------------------------------------
def sanitize_messages(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Ensure assistant messages always have ``content`` or ``tool_calls``.
OpenAI-compatible APIs reject assistant messages that have neither.
This is a defensive catch-all; the upstream layers should already
guarantee well-formed messages.
"""
out: list[dict[str, Any]] = []
for msg in messages:
if (
msg.get("role") == "assistant"
and msg.get("content") is None
and not msg.get("tool_calls")
):
msg = {**msg, "content": ""}
out.append(msg)
return out
# ---------------------------------------------------------------------------
# Usage extraction
# ---------------------------------------------------------------------------
def extract_usage(usage_obj: Any) -> UsageInfo | None:
"""Normalize usage from either Chat Completions or Responses API.
Chat Completions uses ``prompt_tokens`` / ``completion_tokens``.
Responses API uses ``input_tokens`` / ``output_tokens``.
We check for each in order, preferring the real SDK attribute names.
"""
if usage_obj is None:
return None
# Token counts — prefer Chat Completions names, fall back to Responses API
pt = getattr(usage_obj, "prompt_tokens", None)
if not isinstance(pt, int):
pt = getattr(usage_obj, "input_tokens", None)
ct = getattr(usage_obj, "completion_tokens", None)
if not isinstance(ct, int):
ct = getattr(usage_obj, "output_tokens", None)
tt = getattr(usage_obj, "total_tokens", None)
if not isinstance(pt, int) or not isinstance(ct, int):
return None
# Cache tokens — Chat Completions: prompt_tokens_details.cached_tokens,
# Responses API: input_tokens_details.cached_tokens
ptd = getattr(usage_obj, "prompt_tokens_details", None)
if ptd is None:
ptd = getattr(usage_obj, "input_tokens_details", None)
cached = getattr(ptd, "cached_tokens", 0) if ptd is not None else 0
return UsageInfo(
prompt_tokens=pt,
completion_tokens=ct,
total_tokens=tt if isinstance(tt, int) else (pt + ct),
cache_read_tokens=cached if isinstance(cached, int) else 0,
)
# ---------------------------------------------------------------------------
# Retryable error names (shared across both OpenAI providers)
# ---------------------------------------------------------------------------
RETRYABLE_ERROR_NAMES: frozenset[str] = frozenset(
{
"APIError",
"APIConnectionError",
"RateLimitError",
"Timeout",
"APITimeoutError",
}
)
@@ -1,556 +0,0 @@
"""Responses API provider — for commercial OpenAI models (GPT-5.x, O-series).
Uses the OpenAI Responses API (``/v1/responses``) which natively supports
reasoning, tool use, web search, and tool search without the limitations
of the Chat Completions endpoint.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
import structlog
from turnstone.core.providers._openai_common import (
RETRYABLE_ERROR_NAMES,
apply_cache_retention,
apply_temperature,
apply_tool_search,
extract_usage,
format_citations,
lookup_openai_capabilities,
resolve_reasoning_effort,
)
from turnstone.core.providers._protocol import (
CompletionResult,
ModelCapabilities,
StreamChunk,
ToolCallDelta,
)
log = structlog.get_logger(__name__)
def _convert_content_parts(parts: list[Any]) -> list[dict[str, Any]]:
"""Convert Chat Completions content parts to Responses API format.
Handles text and image_url parts. The Responses API uses
``input_image`` instead of ``image_url``.
"""
converted: list[dict[str, Any]] = []
for part in parts:
if not isinstance(part, dict):
continue
ptype = part.get("type", "")
if ptype == "text":
converted.append({"type": "input_text", "text": part.get("text", "")})
elif ptype == "image_url":
url_data = part.get("image_url", {})
url = url_data.get("url", "") if isinstance(url_data, dict) else ""
converted.append({"type": "input_image", "image_url": url})
else:
converted.append(part)
return converted
class OpenAIResponsesProvider:
"""Provider for commercial OpenAI models via the Responses API.
Translates between turnstone's internal OpenAI Chat Completions-like
message format and the Responses API input/output format.
"""
@property
def provider_name(self) -> str:
return "openai"
def get_capabilities(self, model: str) -> ModelCapabilities:
return lookup_openai_capabilities(model)
# -- message conversion --------------------------------------------------
@staticmethod
def _convert_messages(
messages: list[dict[str, Any]],
) -> tuple[str | None, list[dict[str, Any]]]:
"""Convert Chat Completions messages to Responses API input items.
Returns ``(instructions, input_items)`` where *instructions* is the
concatenated system/developer messages (or ``None``) and *input_items*
is the Responses API ``input`` array.
"""
instructions_parts: list[str] = []
items: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role", "")
content = msg.get("content")
if role in ("system", "developer"):
if isinstance(content, str) and content:
instructions_parts.append(content)
elif isinstance(content, list):
# Content parts — extract text
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
instructions_parts.append(part["text"])
continue
if role == "user":
item: dict[str, Any] = {"type": "message", "role": "user"}
if isinstance(content, str):
item["content"] = content
elif isinstance(content, list):
# Vision: content parts (text + image_url)
item["content"] = _convert_content_parts(content)
else:
item["content"] = content or ""
items.append(item)
elif role == "assistant":
# With store=False, provider_blocks cannot be replayed as input
# (output format != input format, and IDs aren't persisted).
# Rebuild from the normalized content/tool_calls instead.
# Text content → assistant message (plain string for input)
if content:
items.append(
{
"type": "message",
"role": "assistant",
"content": content,
}
)
# Tool calls → function_call items
for tc in msg.get("tool_calls") or []:
func = tc.get("function", {})
items.append(
{
"type": "function_call",
"call_id": tc.get("id", ""),
"name": func.get("name", ""),
"arguments": func.get("arguments", ""),
}
)
elif role == "tool":
# Tool result → function_call_output
output = content
if isinstance(content, list):
# Structured content (e.g. vision) — serialize to string
output = json.dumps(content)
items.append(
{
"type": "function_call_output",
"call_id": msg.get("tool_call_id", ""),
"output": output or "",
}
)
instructions = "\n\n".join(instructions_parts) if instructions_parts else None
return instructions, items
# -- tool conversion -----------------------------------------------------
@staticmethod
def _convert_tools(
tools: list[dict[str, Any]] | None,
caps: ModelCapabilities,
) -> list[dict[str, Any]] | None:
"""Convert Chat Completions tool format to Responses API format.
Chat Completions: ``{"type": "function", "function": {"name", "description", "parameters"}}``
Responses API: ``{"type": "function", "name", "description", "parameters", "strict": false}``
Also handles web_search injection for models that support it.
"""
if not tools:
return None
converted: list[dict[str, Any]] = []
has_web_search_func = False
for tool in tools:
func = tool.get("function")
if not func:
converted.append(tool)
continue
name = func.get("name", "")
# web_search function tool → native web_search_tool
if name == "web_search" and caps.supports_web_search:
has_web_search_func = True
continue
item: dict[str, Any] = {
"type": "function",
"name": name,
"description": func.get("description", ""),
"parameters": func.get("parameters", {}),
"strict": False,
}
# Preserve defer_loading for tool search
if tool.get("defer_loading"):
item["defer_loading"] = True
converted.append(item)
# Inject native web search tool
if has_web_search_func or caps.supports_web_search:
converted.append({"type": "web_search"})
# Responses API requires a tool_search tool when defer_loading is used
if any(t.get("defer_loading") for t in converted):
converted.append({"type": "tool_search"})
return converted if converted else None
# -- parameter building --------------------------------------------------
def _build_kwargs(
self,
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None,
max_tokens: int,
temperature: float,
reasoning_effort: str,
deferred_names: frozenset[str] | None,
) -> dict[str, Any]:
"""Build the kwargs dict for ``client.responses.create/stream``."""
caps = self.get_capabilities(model)
instructions, input_items = self._convert_messages(messages)
tools = apply_tool_search(caps, tools, deferred_names)
converted_tools = self._convert_tools(tools, caps)
# Ensure web search is always injected for search-capable models,
# even when no function tools are registered (e.g. creative mode).
if caps.supports_web_search:
converted_tools = converted_tools or []
if not any(t.get("type") == "web_search" for t in converted_tools):
converted_tools.append({"type": "web_search"})
kwargs: dict[str, Any] = {
"model": model,
"input": input_items,
"max_output_tokens": max_tokens,
"store": False,
}
if instructions:
kwargs["instructions"] = instructions
if converted_tools:
kwargs["tools"] = converted_tools
apply_temperature(kwargs, caps, temperature, reasoning_effort)
# Reasoning effort → {"effort": value} dict (Responses API format)
effort = resolve_reasoning_effort(caps, reasoning_effort)
if effort:
kwargs["reasoning"] = {"effort": effort}
apply_cache_retention(kwargs, model)
return kwargs
# -- streaming -----------------------------------------------------------
def create_streaming(
self,
*,
client: Any,
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
max_tokens: int = 4096,
temperature: float = 0.5,
reasoning_effort: str = "medium",
extra_params: dict[str, Any] | None = None,
deferred_names: frozenset[str] | None = None,
cancel_ref: list[Any] | None = None,
) -> Iterator[StreamChunk]:
if extra_params:
log.debug("openai.responses: extra_params ignored (not supported by Responses API)")
kwargs = self._build_kwargs(
model,
messages,
tools,
max_tokens,
temperature,
reasoning_effort,
deferred_names,
)
kwargs["stream"] = True
log.debug(
"openai.responses.request",
model=model,
stream=True,
max_tokens=max_tokens,
input_items=len(kwargs.get("input", [])),
tool_count=len(kwargs.get("tools", [])),
)
stream = client.responses.create(**kwargs)
if cancel_ref is not None:
cancel_ref.append(stream)
return self._iter_stream(stream)
def _iter_stream(self, stream: Any) -> Iterator[StreamChunk]:
"""Convert Responses API stream events to StreamChunks."""
first = True
content_len = 0
tool_call_count = 0
last_finish: str | None = None
completion_tokens: int | None = None
# Track tool call indices by call_id for consistent ToolCallDelta.index
tool_call_indices: dict[str, int] = {}
# Collect output items for provider_blocks
provider_blocks: list[dict[str, Any]] = []
# Collect annotations across text parts
annotations: list[Any] = []
for event in stream:
event_type = getattr(event, "type", "")
# -- text content deltas --
if event_type == "response.output_text.delta":
delta_text = getattr(event, "delta", "")
if delta_text:
sc = StreamChunk(content_delta=delta_text)
content_len += len(delta_text)
if first:
sc.is_first = True
first = False
yield sc
continue
# -- reasoning deltas --
if event_type in (
"response.reasoning_text.delta",
"response.reasoning_summary_text.delta",
):
delta_text = getattr(event, "delta", "")
if delta_text:
sc = StreamChunk(reasoning_delta=delta_text)
if first:
sc.is_first = True
first = False
yield sc
continue
# -- new tool call (function_call output item added) --
if event_type == "response.output_item.added":
item = getattr(event, "item", None)
if item and getattr(item, "type", "") == "function_call":
call_id = getattr(item, "call_id", "")
item_id = getattr(item, "id", "")
name = getattr(item, "name", "")
idx = len(tool_call_indices)
# Index by item_id — argument deltas reference this, not call_id
tool_call_indices[item_id] = idx
sc = StreamChunk(
tool_call_deltas=[ToolCallDelta(index=idx, id=call_id, name=name)]
)
tool_call_count += 1
if first:
sc.is_first = True
first = False
yield sc
continue
# -- tool call argument deltas --
if event_type == "response.function_call_arguments.delta":
item_id = getattr(event, "item_id", "")
delta_args = getattr(event, "delta", "")
if delta_args:
idx = tool_call_indices.get(item_id, 0)
yield StreamChunk(
tool_call_deltas=[ToolCallDelta(index=idx, arguments_delta=delta_args)]
)
continue
# -- web search status --
if event_type == "response.web_search_call.searching":
yield StreamChunk(info_delta="[Searching…]")
continue
if event_type == "response.web_search_call.completed":
yield StreamChunk(info_delta="[Search complete]")
continue
# -- output item done (capture for provider_blocks) --
if event_type == "response.output_item.done":
item = getattr(event, "item", None)
if item:
item_dict = item.model_dump() if hasattr(item, "model_dump") else {}
if item_dict:
provider_blocks.append(item_dict)
# Collect annotations from completed text parts
if getattr(item, "type", "") == "message":
for content_part in getattr(item, "content", []):
part_anns = getattr(content_part, "annotations", None)
if part_anns:
annotations.extend(part_anns)
continue
# -- response completed --
if event_type == "response.completed":
response = getattr(event, "response", None)
if response:
status = getattr(response, "status", "completed")
last_finish = "stop" if status == "completed" else "length"
usage = extract_usage(getattr(response, "usage", None))
if usage:
completion_tokens = usage.completion_tokens
sc = StreamChunk(
finish_reason=last_finish,
usage=usage,
)
if provider_blocks:
sc.provider_blocks = provider_blocks
yield sc
continue
# -- error --
if event_type == "response.failed":
response = getattr(event, "response", None)
error = getattr(response, "error", None) if response else None
error_msg = getattr(error, "message", "Unknown error") if error else "Unknown error"
raise RuntimeError(f"Responses API error: {error_msg}")
log.debug(
"openai.responses.response",
stream=True,
finish_reason=last_finish,
content_length=content_len,
tool_call_count=tool_call_count,
completion_tokens=completion_tokens,
)
# Emit accumulated citations as a final info chunk
if annotations:
citation_text = format_citations("", annotations).strip()
if citation_text:
yield StreamChunk(info_delta=citation_text)
# -- non-streaming -------------------------------------------------------
def create_completion(
self,
*,
client: Any,
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
max_tokens: int = 4096,
temperature: float = 0.5,
reasoning_effort: str = "medium",
extra_params: dict[str, Any] | None = None,
deferred_names: frozenset[str] | None = None,
) -> CompletionResult:
if extra_params:
log.debug("openai.responses: extra_params ignored (not supported by Responses API)")
kwargs = self._build_kwargs(
model,
messages,
tools,
max_tokens,
temperature,
reasoning_effort,
deferred_names,
)
log.debug(
"openai.responses.request",
model=model,
stream=False,
max_tokens=max_tokens,
input_items=len(kwargs.get("input", [])),
tool_count=len(kwargs.get("tools", [])),
)
response = client.responses.create(**kwargs)
return self._parse_response(response)
def _parse_response(self, response: Any) -> CompletionResult:
"""Convert a Responses API ``Response`` object to ``CompletionResult``."""
content_parts: list[str] = []
tool_calls: list[dict[str, Any]] = []
provider_blocks: list[dict[str, Any]] = []
all_annotations: list[Any] = []
for item in getattr(response, "output", []):
item_type = getattr(item, "type", "")
if item_type == "message":
for content_part in getattr(item, "content", []):
part_type = getattr(content_part, "type", "")
if part_type == "output_text":
content_parts.append(getattr(content_part, "text", ""))
anns = getattr(content_part, "annotations", None)
if anns:
all_annotations.extend(anns)
elif part_type == "refusal":
content_parts.append(f"[Refused: {getattr(content_part, 'refusal', '')}]")
elif item_type == "function_call":
tool_calls.append(
{
"id": getattr(item, "call_id", ""),
"type": "function",
"function": {
"name": getattr(item, "name", ""),
"arguments": getattr(item, "arguments", ""),
},
}
)
# Capture all output items for provider_blocks (multi-turn)
item_dict = item.model_dump() if hasattr(item, "model_dump") else {}
if item_dict:
provider_blocks.append(item_dict)
content = "".join(content_parts)
if all_annotations:
content = format_citations(content, all_annotations)
status = getattr(response, "status", "completed")
finish_reason = "stop" if status == "completed" else "length"
usage = extract_usage(getattr(response, "usage", None))
result = CompletionResult(
content=content,
tool_calls=tool_calls if tool_calls else None,
finish_reason=finish_reason,
usage=usage,
provider_blocks=provider_blocks,
)
log.debug(
"openai.responses.response",
stream=False,
finish_reason=finish_reason,
content_length=len(content),
tool_call_count=len(tool_calls),
completion_tokens=usage.completion_tokens if usage else None,
)
return result
# -- tool conversion (public interface) ----------------------------------
def convert_tools(
self,
tools: list[dict[str, Any]],
) -> list[dict[str, Any]]:
return tools # Conversion happens internally in _build_kwargs
# -- retryable errors ----------------------------------------------------
@property
def retryable_error_names(self) -> frozenset[str]:
return RETRYABLE_ERROR_NAMES
+3 -3
View File
@@ -236,14 +236,14 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue[tuple[s
):
ns[name] = getattr(sympy, name)
except ImportError:
pass # optional dependency
pass
try:
import numpy as _np
ns["np"] = ns["numpy"] = _np
except ImportError:
pass # optional dependency
pass
try:
import scipy # type: ignore[import-untyped]
@@ -260,7 +260,7 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue[tuple[s
ns["gamma"] = scipy.special.gamma
ns["beta"] = scipy.special.beta
except ImportError:
pass # optional dependency
pass
# Strip __builtins__ from all pre-imported modules so
# module.__builtins__['__import__'] can't bypass _safe_import.
+45 -134
View File
@@ -312,7 +312,7 @@ class ChatSession:
self._provider: LLMProvider = (
registry.get_provider(model_alias)
if registry and model_alias
else create_provider("openai-compatible")
else create_provider("openai")
)
self._cached_capabilities: ModelCapabilities | None = None
self.ui = ui
@@ -886,34 +886,9 @@ class ChatSession:
self._tool_error_flags[call_id] = True
self.ui.on_tool_result(call_id, name, output, is_error=is_error)
def _remaining_token_budget(self) -> int:
"""Estimate how many tokens are available for new content.
Reserves a response budget (capped at 25% of context window, since
``max_tokens`` is an upper bound, not guaranteed consumption) plus
a 5% safety margin. Returns at least 0.
"""
used = self._system_tokens + sum(self._msg_tokens)
response_reserve = min(self.max_tokens, self.context_window // 4)
safety_margin = int(self.context_window * 0.05)
return max(0, self.context_window - used - response_reserve - safety_margin)
def _truncate_output(self, output: str, remaining_budget_tokens: int | None = None) -> str:
"""Truncate tool output, keeping head + tail.
The effective limit is the *minimum* of:
- ``self.tool_truncation`` (fixed cap, defaults to 50% of context)
- ``remaining_budget_tokens`` converted to chars (if provided)
This ensures a single tool result cannot overflow the context window
even when the conversation is already partially full.
"""
def _truncate_output(self, output: str) -> str:
"""Truncate tool output to self.tool_truncation chars, keeping head + tail."""
limit = self.tool_truncation
if remaining_budget_tokens is not None:
budget_chars = int(remaining_budget_tokens * self._chars_per_token)
limit = min(limit, budget_chars)
if limit <= 0:
return f"[Output truncated — {len(output)} chars exceeded context budget]"
if len(output) <= limit:
return output
half = limit // 2
@@ -1096,38 +1071,29 @@ class ChatSession:
self._chat_template_kwargs_base: dict[str, Any] = {
"reasoning_effort": self.reasoning_effort,
}
self._chat_template_kwargs: dict[str, Any] = dict(self._chat_template_kwargs_base)
# -- Developer message --
if self.creative_mode:
dev_parts = [
"# Instructions",
"",
(
"You are a creative writing partner. Use the analysis channel to "
"think through structure, voice, and intent before drafting."
),
"You are a creative writing partner. Use the analysis channel to "
"think through structure, voice, and intent before drafting.",
"",
"Craft principles:",
"- Ground scenes in concrete sensory detail — what is seen, heard, felt.",
(
"- Vary rhythm. Short sentences hit hard. Longer ones carry the reader "
"through texture and nuance, building toward something."
),
(
"- Dialogue should do at least two things: reveal character AND advance "
"plot or tension. Cut anything that's just exchanging information."
),
(
"- Earn your abstractions. Don't say 'she felt sad' — show the thing "
"that makes the reader feel it."
),
"- Vary rhythm. Short sentences hit hard. Longer ones carry the reader "
"through texture and nuance, building toward something.",
"- Dialogue should do at least two things: reveal character AND advance "
"plot or tension. Cut anything that's just exchanging information.",
"- Earn your abstractions. Don't say 'she felt sad' — show the thing "
"that makes the reader feel it.",
"- Trust subtext. Leave room for the reader.",
"",
(
"Match the user's genre and tone. If they want literary fiction, write "
"literary fiction. If they want pulp, write pulp with conviction. "
"Never condescend to the form."
),
"Match the user's genre and tone. If they want literary fiction, write "
"literary fiction. If they want pulp, write pulp with conviction. "
"Never condescend to the form.",
]
else:
# Compose system message from modular components
@@ -1139,7 +1105,7 @@ class ChatSession:
if storage:
db_policies = storage.list_prompt_policies()
except Exception:
log.debug("Failed to load prompt policies from storage", exc_info=True)
pass
now = datetime.now().astimezone()
ctx = SessionContext(
current_datetime=now.strftime("%Y-%m-%dT%H:%M"),
@@ -1301,14 +1267,9 @@ class ChatSession:
reasoning_effort: str | None = None,
provider: LLMProvider | None = None,
) -> dict[str, Any] | None:
"""Build provider-specific extra parameters.
``chat_template_kwargs`` is only meaningful for local model servers
(``openai-compatible``). Commercial OpenAI rejects it as an unknown
parameter, and handles ``reasoning_effort`` natively.
"""
"""Build provider-specific extra parameters."""
prov = provider or self._provider
if prov.provider_name == "openai-compatible":
if prov.provider_name == "openai":
kwargs = dict(self._chat_template_kwargs_base)
if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
@@ -1417,9 +1378,11 @@ class ChatSession:
if self._health_monitor:
self._health_monitor.record_success()
return result
except Exception as primary_err:
except BaseException as primary_err:
if self._health_monitor:
self._health_monitor.record_failure()
if isinstance(primary_err, (KeyboardInterrupt, SystemExit)):
raise
if not self._registry or not self._registry.fallback:
raise
# Try each fallback model. Fallbacks may use different backends;
@@ -1605,44 +1568,7 @@ class ChatSession:
self._emit_state("thinking")
self.ui.on_thinking_start()
try:
try:
stream = self._create_stream_with_retry(msgs)
except Exception as ctx_err:
# Context overflow recovery: if the API rejects the
# request due to exceeding the context window, compact
# the conversation and retry once.
err_text = str(ctx_err).lower()
is_ctx_overflow = any(
s in err_text
for s in (
"context length",
"maximum context",
"too many tokens",
"prompt is too long",
"input tokens",
)
)
if not is_ctx_overflow:
raise
log.warning(
"Context overflow detected (%s), compacting and retrying",
type(ctx_err).__name__,
)
self.ui.on_info("\n[Context overflow — auto-compacting and retrying]")
# Stop thinking indicator before compact (which has
# its own thinking start/stop) to avoid nested spinners.
self.ui.on_thinking_stop()
try:
self._compact_messages(auto=True)
msgs = self._full_messages()
self.ui.on_thinking_start()
stream = self._create_stream_with_retry(msgs)
except Exception:
log.warning(
"Compact-and-retry failed, raising original error",
exc_info=True,
)
raise ctx_err from None
stream = self._create_stream_with_retry(msgs)
assistant_msg = self._stream_response(stream, my_generation)
finally:
# Only clear if this generation is still active —
@@ -1808,12 +1734,6 @@ class ChatSession:
tc_id, p["text"], _tc_names.get(tc_id, "")
)
# Safety truncation: clamp output to remaining context budget
# so a single large result cannot overflow the context window.
if isinstance(output, str):
budget = self._remaining_token_budget()
output = self._truncate_output(output, remaining_budget_tokens=budget)
tool_msg: dict[str, Any] = {
"role": "tool",
"tool_call_id": tc_id,
@@ -2665,6 +2585,7 @@ class ChatSession:
return None
if self._judge is not None:
return self._judge
return None
# Frozen config required for IntentJudge init (LLM client fields).
# _judge_cfg already returns None when _judge_config is None, but
# this guard makes the dependency explicit for type narrowing.
@@ -2883,8 +2804,7 @@ class ChatSession:
continue
cid, output = results[i]
if not isinstance(output, str):
raise TypeError(f"plan_agent must return str, got {type(output).__name__}")
assert isinstance(output, str) # plan always returns text
plan_path = f".plan-{self._ws_id}.md"
if not self.auto_approve:
@@ -2937,10 +2857,7 @@ class ChatSession:
with open(plan_path, "w") as f:
f.write(output)
except OSError:
log.warning("Failed to write plan to %s", plan_path, exc_info=True)
output += "\n\n---\nPlan could not be saved to disk."
results[i] = (cid, output)
continue
pass
# Always include file path in the tool result so the
# outer model knows where the plan lives on disk.
@@ -3019,7 +2936,7 @@ class ChatSession:
"call_id": call_id,
"func_name": func_name,
"header": f"\u2717 {func_name}: {exc}",
"preview": f" {preview}",
"preview": f" {RED}{preview}{RESET}",
"needs_approval": False,
"error": (
f"JSON parse error for tool '{func_name}': {exc}\n"
@@ -3409,10 +3326,9 @@ class ChatSession:
"error": "Error: provide old_string/new_string or edits array, not both",
}
if has_batch:
# raw_edits is guaranteed to be a list by the has_batch check above
batch_edits: list[Any] = raw_edits # type: ignore[assignment]
assert isinstance(raw_edits, list)
edits: list[dict[str, Any]] = []
for i, e in enumerate(batch_edits):
for i, e in enumerate(raw_edits):
if not isinstance(e, dict):
return {
"call_id": call_id,
@@ -3612,7 +3528,7 @@ class ChatSession:
"call_id": call_id,
"func_name": "man",
"header": "\u2717 man: invalid page name",
"preview": f" {page}",
"preview": f" {RED}{page}{RESET}",
"needs_approval": False,
"error": f"Error: invalid page name {page!r}",
}
@@ -3658,7 +3574,7 @@ class ChatSession:
"call_id": call_id,
"func_name": "web_fetch",
"header": "\u2717 web_fetch: invalid url",
"preview": f" {url}",
"preview": f" {RED}{url}{RESET}",
"needs_approval": False,
"error": f"Error: URL must start with http:// or https:// (got {url!r})",
}
@@ -3669,12 +3585,12 @@ class ChatSession:
"call_id": call_id,
"func_name": "web_fetch",
"header": "\u2717 web_fetch: blocked (private network)",
"preview": f" {url}",
"preview": f" {RED}{url}{RESET}",
"needs_approval": False,
"error": f"Error: {ssrf_err}",
}
q_preview = question[:200] + ("..." if len(question) > 200 else "")
preview = f" {url}\n Q: {q_preview}"
preview = f" {DIM}{url}\n Q: {q_preview}{RESET}"
return {
"call_id": call_id,
"func_name": "web_fetch",
@@ -3720,7 +3636,7 @@ class ChatSession:
if topic not in ("general", "news", "finance"):
topic = "general"
q_preview = query[:200] + ("..." if len(query) > 200 else "")
preview = f" {q_preview}"
preview = f" {DIM}{q_preview}{RESET}"
return {
"call_id": call_id,
"func_name": "web_search",
@@ -3759,7 +3675,7 @@ class ChatSession:
"call_id": call_id,
"func_name": "tool_search",
"header": f"\u2699 tool_search: {query[:80]}",
"preview": f" {query}",
"preview": f" {DIM}{query}{RESET}",
"needs_approval": False,
"execute": self._exec_tool_search,
"query": query,
@@ -3793,7 +3709,7 @@ class ChatSession:
"call_id": call_id,
"func_name": "task_agent",
"header": "\u2699 task_agent (autonomous agent)",
"preview": f" {preview_text}",
"preview": f" {DIM}{preview_text}{RESET}",
"needs_approval": True,
"approval_label": "task_agent",
"execute": self._exec_task,
@@ -3817,7 +3733,7 @@ class ChatSession:
"call_id": call_id,
"func_name": "plan_agent",
"header": "\u2699 plan_agent (planning agent)",
"preview": f" {preview_text}",
"preview": f" {DIM}{preview_text}{RESET}",
"needs_approval": True,
"approval_label": "plan_agent",
"execute": self._exec_plan,
@@ -4298,7 +4214,7 @@ class ChatSession:
if isinstance(parsed, list):
return " ".join(str(t) for t in parsed)
except (ValueError, TypeError):
pass # falls back to raw string
pass
return raw
# Build corpus from name + description + tags + category
@@ -4371,7 +4287,7 @@ class ChatSession:
"call_id": call_id,
"func_name": func_name,
"header": f"\u2699 mcp:{display}",
"preview": preview,
"preview": f"{DIM}{preview}{RESET}",
"needs_approval": True,
"approval_label": func_name,
"execute": self._exec_mcp_tool,
@@ -4449,7 +4365,7 @@ class ChatSession:
"call_id": call_id,
"func_name": "read_resource",
"header": "\u2699 read_resource",
"preview": f" uri: {uri}",
"preview": f"{DIM} uri: {uri}{RESET}",
"needs_approval": True,
"approval_label": f"mcp_resource__{self._normalize_resource_uri(uri)}",
"execute": self._exec_read_resource,
@@ -4926,12 +4842,6 @@ class ChatSession:
label_b = "(provided content)"
lines_b = (content_b or "").splitlines(keepends=True)
# When content_b is a baseline, swap so diff reads as "what changed"
# (--- old/baseline, +++ new/current file).
if content_b is not None:
lines_a, lines_b = lines_b, lines_a
path_a, label_b = label_b, path_a
# Stream diff with early cutoff to avoid large allocations
max_chars = self.tool_truncation or 262_144
chunks: list[str] = []
@@ -4989,10 +4899,12 @@ class ChatSession:
tools = _without_tool(tools, "web_search")
# Build extra params for agent calls
agent_extra = self._provider_extra_params(
reasoning_effort=reasoning_effort,
provider=agent_provider,
)
agent_extra: dict[str, Any] | None = None
if agent_provider.provider_name == "openai":
agent_kwargs = dict(self._chat_template_kwargs_base)
if reasoning_effort:
agent_kwargs["reasoning_effort"] = reasoning_effort
agent_extra = {"chat_template_kwargs": agent_kwargs}
def _api_call(
messages: list[dict[str, Any]],
@@ -6354,7 +6266,6 @@ class ChatSession:
self._report_tool_result(call_id, "web_search", msg, is_error=True)
return call_id, msg
output = self._truncate_output(output)
self._report_tool_result(call_id, "web_search", output)
return call_id, output
+2 -2
View File
@@ -199,7 +199,7 @@ async def _fetch_resource_contents(
if resp.status_code == 200:
return rf["path"], resp.text
except httpx.HTTPError:
pass # best-effort fetch, skip on failure
pass
return None
results = await asyncio.gather(*[_fetch_one(rf) for rf in resource_files])
@@ -399,7 +399,7 @@ async def fetch_skills_from_github_repo(url: str) -> list[SkillPackage]:
if r.status_code == 200 and len(r.content) <= _MAX_SKILL_MD_SIZE:
return p, r.text
except httpx.HTTPError:
pass # best-effort fetch, skip on failure
pass
return None
md_results = await asyncio.gather(*[_fetch_skill_md(p) for p in skill_md_paths])
+1 -1
View File
@@ -149,7 +149,7 @@ def scan_skill_content(content: str, allowed_tools: str) -> tuple[str, str, str]
if not tools:
tools = None
except (json.JSONDecodeError, TypeError):
pass # falls back to None (no tool filter)
pass
result = scan_skill(content, tools)
return result.tier, json.dumps(result.to_dict(), ensure_ascii=False), SCANNER_VERSION
except Exception:
+1 -1
View File
@@ -369,7 +369,7 @@ class WatchRunner:
created_dt = datetime.fromisoformat(created).replace(tzinfo=UTC)
elapsed_secs = (now - created_dt).total_seconds()
except (ValueError, TypeError):
pass # elapsed stays 0.0
pass
message = format_watch_message(
name=watch_row["name"],
-5
View File
@@ -1,5 +0,0 @@
"""Bundled deployment templates (compose files, overlays).
These files are included in the wheel so that ``turnstone-bootstrap`` can
extract them for users who install via pip/pipx and don't have a git clone.
"""
-173
View File
@@ -1,173 +0,0 @@
# =============================================================================
# Turnstone Docker Compose Stack — Production
#
# This file is bundled with the turnstone wheel and written by
# turnstone-bootstrap for users who install via pip/pipx.
# It pulls pre-built images from ghcr.io instead of building locally.
#
# Usage:
# Infra only: docker compose up
# Single node: docker compose --profile production up
# Production (PG): docker compose --profile production up
# (set DB_BACKEND, DATABASE_URL, POSTGRES_PASSWORD in .env)
#
# Set TURNSTONE_IMAGE_TAG in .env to pin the image version (default: latest).
# =============================================================================
name: turnstone
networks:
turnstone-net:
driver: bridge
volumes:
turnstone-data:
workspace:
postgres-data:
services:
# -------------------------------------------------------------------
# PostgreSQL — production database (profile: production)
# -------------------------------------------------------------------
postgres:
image: pgautoupgrade/pgautoupgrade:18-alpine
profiles:
- production
command:
- postgres
- -c
- max_connections=${POSTGRES_MAX_CONNECTIONS:-300}
- -c
- shared_buffers=128MB
environment:
POSTGRES_DB: turnstone
POSTGRES_USER: ${POSTGRES_USER:-turnstone}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for production profile}
PGDATA: /var/lib/postgresql/data
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- turnstone-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-turnstone}"]
interval: 5s
timeout: 3s
retries: 5
start_period: 30s
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
restart: unless-stopped
# -------------------------------------------------------------------
# turnstone-server — Web UI + chat workstreams + LLM interaction
# -------------------------------------------------------------------
server:
image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest}
profiles:
- production
command:
- sh
- -c
- >-
turnstone-server
--host 0.0.0.0
--port 8080
--base-url "$${LLM_BASE_URL}"
--api-key "$${OPENAI_API_KEY}"
$${MODEL:+--model $$MODEL}
$${SKIP_PERMISSIONS:+--skip-permissions}
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
ports:
- "${SERVER_PORT:-8080}:8080"
volumes:
- turnstone-data:/data
- ${WORKSPACE_MOUNT:-workspace}:/workspace
environment:
- LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- MODEL=${MODEL:-}
- MCP_CONFIG=${MCP_CONFIG:-}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${DATABASE_URL:-}
- TURNSTONE_NODE_ID=${TURNSTONE_NODE_ID:-}
- TURNSTONE_ADVERTISE_URL=${TURNSTONE_ADVERTISE_URL:-http://server:8080}
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- turnstone-net
depends_on:
postgres:
condition: service_healthy
required: false
healthcheck:
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 60s
restart: unless-stopped
# -------------------------------------------------------------------
# turnstone-console — Cluster dashboard
# -------------------------------------------------------------------
console:
image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest}
command:
- turnstone-console
- --host=0.0.0.0
- --port=8090
ports:
- "${CONSOLE_PORT:-8090}:8090"
environment:
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${DATABASE_URL:-}
- TURNSTONE_CONSOLE_URL=http://console:8090
networks:
- turnstone-net
healthcheck:
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8090/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
# -------------------------------------------------------------------
# turnstone-channel — Channel gateway (Discord, Slack, etc.)
# Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter
# -------------------------------------------------------------------
channel:
image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest}
profiles:
- production
command:
- sh
- -c
- >-
turnstone-channel
--http-host=0.0.0.0
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
environment:
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql}
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
networks:
- turnstone-net
depends_on:
postgres:
condition: service_healthy
required: false
restart: unless-stopped
+1 -5
View File
@@ -45,11 +45,7 @@ _MCP_ONLY_TOOLS = frozenset({"read_resource", "use_prompt"})
def _detect_provider(base_url: str) -> str:
"""Infer provider name from a base URL."""
from urllib.parse import urlparse
normalized = base_url if "://" in base_url else f"https://{base_url}"
hostname = urlparse(normalized).hostname or ""
if hostname == "anthropic.com" or hostname.endswith(".anthropic.com"):
if "anthropic.com" in base_url:
return "anthropic"
return "openai"
+7 -9
View File
@@ -923,7 +923,7 @@ async def events_sse(request: Request) -> Response:
return
yield {"data": json.dumps(event)}
except queue.Empty:
pass # poll timeout, retry
pass
finally:
_metrics.record_sse_disconnect()
ui._unregister_listener(client_queue)
@@ -1045,7 +1045,7 @@ async def global_events_sse(request: Request) -> Response:
)
yield {"data": json.dumps(event)}
except queue.Empty:
pass # poll timeout, retry
pass
finally:
_metrics.record_sse_disconnect()
with listeners_lock:
@@ -1652,8 +1652,7 @@ async def create_workstream(request: Request) -> JSONResponse:
ws_id=requested_ws_id,
client_type=body.get("client_type", "") or "",
)
if not isinstance(ws.ui, WebUI):
raise TypeError(f"Expected WebUI, got {type(ws.ui).__name__}")
assert isinstance(ws.ui, WebUI)
if skip or body.get("auto_approve", False):
ws.ui.auto_approve = True
# Register watch runner for this workstream
@@ -1753,7 +1752,7 @@ async def create_workstream(request: Request) -> JSONResponse:
_gs().set_workstream_override(ws.id, node_id, reason="local")
except Exception:
log.debug("Failed to set routing override for %s", ws.id, exc_info=True)
pass # best-effort; routing will still work via resume
# If an initial_message was provided, send it as the first user message.
# This replaces the old bridge behavior where CreateWorkstreamMessage
@@ -2684,7 +2683,7 @@ def main() -> None:
if host and host != "localhost":
return f"{host}_{suffix}"
except OSError:
pass # hostname unavailable, fall back to UUID
pass
return uuid.uuid4().hex[:12]
_node_id = os.environ.get("TURNSTONE_NODE_ID") or _default_node_id()
@@ -2902,7 +2901,7 @@ def main() -> None:
if _u:
_username = _u.get("username", "")
except Exception:
log.debug("Failed to resolve username for uid %s", uid, exc_info=True)
pass
# Re-resolve from ConfigStore so new workstreams pick up hot-reloaded settings.
live_memory_config = _build_memory_config()
@@ -2990,8 +2989,7 @@ def main() -> None:
name="default",
ui_factory=lambda wid: WebUI(ws_id=wid),
)
if not isinstance(ws.ui, WebUI):
raise TypeError(f"Expected WebUI, got {type(ws.ui).__name__}")
assert isinstance(ws.ui, WebUI)
if config_store.get("tools.skip_permissions"):
ws.ui.auto_approve = True
+1 -5
View File
@@ -66,7 +66,7 @@
--accent-dim: rgba(140, 94, 27, 0.1);
--accent-glow: rgba(140, 94, 27, 0.05);
--green: #047857;
--red: #b91c1c;
--red: #dc2626;
--yellow: #b45309;
--cyan: #0e7490;
--magenta: #7c3aed;
@@ -512,10 +512,6 @@ body {
overflow: hidden;
text-overflow: ellipsis;
}
#toast.toast-error {
border-color: var(--red, #c44);
color: var(--red, #c44);
}
#toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
@@ -1,28 +0,0 @@
Copyright (c) 2017 Dailymotion (http://www.dailymotion.com)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
src/remux/mp4-generator.js and src/demux/exp-golomb.ts implementation in this project
are derived from the HLS library for video.js (https://github.com/videojs/videojs-contrib-hls)
That work is also covered by the Apache 2 License, following copyright:
Copyright (c) 2013-2015 Brightcove
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
+5 -8
View File
@@ -6,20 +6,18 @@ var _toastTimer = null;
var _toastShowing = false;
var _TOAST_TIMEOUT = window.TURNSTONE_TOAST_TIMEOUT || 3000;
function showToast(message, type) {
function showToast(message) {
var el = document.getElementById("toast");
if (!el) return;
if (_toastShowing) {
_toastQueue.push({ message: message, type: type });
_toastQueue.push(message);
return;
}
_displayToast(el, message, type);
_displayToast(el, message);
}
function _displayToast(el, message, type) {
function _displayToast(el, message) {
el.textContent = message;
el.classList.remove("toast-error");
if (type === "error") el.classList.add("toast-error");
el.classList.add("show");
_toastShowing = true;
if (_toastTimer) clearTimeout(_toastTimer);
@@ -29,8 +27,7 @@ function _displayToast(el, message, type) {
_toastTimer = null;
if (_toastQueue.length) {
setTimeout(function () {
var item = _toastQueue.shift();
_displayToast(el, item.message, item.type);
_displayToast(el, _toastQueue.shift());
}, 300);
}
}, _TOAST_TIMEOUT);
+18 -446
View File
@@ -172,20 +172,13 @@ Pane.prototype._createDOM = function () {
this.inputEl = document.createElement("textarea");
this.inputEl.className = "pane-input";
this.inputEl.rows = 1;
this._isTouch = window.matchMedia(
"(hover: none) and (pointer: coarse)",
).matches;
this.inputEl.placeholder = this._isTouch
? "Type a message\u2026"
: "Type a message\u2026 (Shift+Enter for newline)";
this.inputEl.placeholder = "Type a message\u2026 (Shift+Enter for newline)";
this.inputEl.setAttribute("aria-label", "Message input");
this.inputEl.addEventListener("input", function () {
self._autoResize();
});
this.inputEl.addEventListener("keydown", function (e) {
// On touch devices, let Enter insert newlines — users tap Send button.
// On desktop, Enter sends and Shift+Enter inserts a newline.
if (e.key === "Enter" && !e.shiftKey && !self._isTouch) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
self.sendMessage();
}
@@ -868,23 +861,13 @@ Pane.prototype.replayHistory = function (messages) {
cmd.className = "tool-cmd";
try {
var args = JSON.parse(tc.arguments);
var preview = Object.values(args)[0] || "";
if (tc.name === "bash") {
var preview = Object.values(args)[0] || "";
cmd.innerHTML =
'<span class="dollar">$ </span>' +
escapeHtml(String(preview));
} else {
var parts = [];
var keys = Object.keys(args);
for (var k = 0; k < keys.length; k++) {
var val = args[keys[k]];
var valStr =
val === null || val === undefined ? "null" : String(val);
if (valStr.length > 80)
valStr = valStr.substring(0, 77) + "...";
parts.push(keys[k] + ": " + valStr);
}
cmd.textContent = parts.join("\n");
cmd.textContent = String(preview).substring(0, 200);
}
} catch (e) {
cmd.textContent = tc.arguments.substring(0, 100);
@@ -923,21 +906,16 @@ Pane.prototype.replayHistory = function (messages) {
/^Blocked/.test(stripped);
var isToolError = !!msg.is_error;
if (stripped && !isDenied) {
var media = !isToolError ? tryParseMedia(stripped) : null;
if (media) {
var embed = buildMediaEmbed(media, stripped);
var bdg = lastToolBlock.querySelector(".approval-badge");
if (bdg) lastToolBlock.insertBefore(embed, bdg);
else lastToolBlock.appendChild(embed);
} else {
var out = renderToolOutput(stripped, isToolError);
if (out.textContent.split("\n").length > 10) {
makeCollapsible(out);
}
var bdg = lastToolBlock.querySelector(".approval-badge");
if (bdg) lastToolBlock.insertBefore(out, bdg);
else lastToolBlock.appendChild(out);
var out = document.createElement("div");
out.className =
"tool-output" + (isToolError ? " tool-output-error" : "");
out.textContent = stripped;
if (stripped.split("\n").length > 10) {
makeCollapsible(out);
}
var bdg = lastToolBlock.querySelector(".approval-badge");
if (bdg) lastToolBlock.insertBefore(out, bdg);
else lastToolBlock.appendChild(out);
}
if (isToolError && !lastToolBlock.classList.contains("denied")) {
lastToolBlock.classList.add("error");
@@ -1243,18 +1221,10 @@ Pane.prototype.appendToolOutput = function (callId, name, output, isError) {
var stripped = stripAnsi(output || "").trim();
if (!stripped) return;
// Detect structured media output and render interactive embed
if (!isError) {
var media = tryParseMedia(stripped);
if (media) {
var embed = buildMediaEmbed(media, stripped);
target.after(embed);
this.scrollToBottom();
return;
}
}
var out = renderToolOutput(stripped, isError);
// Style tool output as error when indicated by isError flag
var out = document.createElement("div");
out.className = "tool-output" + (isError ? " tool-output-error" : "");
out.textContent = stripped;
// Mark the parent approval block as errored
if (isError) {
@@ -1269,7 +1239,7 @@ Pane.prototype.appendToolOutput = function (callId, name, output, isError) {
}
}
if (out.textContent.split("\n").length > 10) {
if (stripped.split("\n").length > 10) {
makeCollapsible(out);
}
@@ -3200,404 +3170,6 @@ function makeCollapsible(el) {
});
}
// ===========================================================================
// 12a. Media embed renderer (MCP tool output with stream_url / results)
// ===========================================================================
function tryParseMedia(text) {
try {
var obj = JSON.parse(text);
} catch (e) {
return null;
}
if (obj && typeof obj.stream_url === "string") return obj;
if (obj && obj.name && obj.type && obj.id) return obj;
if (obj && Array.isArray(obj.results) && obj.results.length > 0) return obj;
if (obj && Array.isArray(obj.sessions)) return obj;
return null;
}
function _formatRuntime(item) {
var mins = 0;
if (typeof item.runtime_minutes === "number") {
mins = Math.round(item.runtime_minutes);
} else if (typeof item.runtime_ticks === "number") {
mins = Math.round(item.runtime_ticks / 600000000);
}
if (!mins) return "";
var h = Math.floor(mins / 60);
var m = mins % 60;
return h > 0 ? h + "h " + m + "m" : m + "m";
}
function _redactApiKeys(text) {
// Query-string style: api_key=VALUE
var redacted = text.replace(
/(?:api_key|apiKey|api-key|token)=[^&\s"]+/g,
function (m) {
return m.split("=")[0] + "=***";
},
);
// JSON style: "api_key": "VALUE"
redacted = redacted.replace(
/(["'](?:api_key|apiKey|api-key|token)["']\s*:\s*["'])([^"']*)(['"])/gi,
"$1***$3",
);
return redacted;
}
/**
* Try to pretty-print JSON text with indentation and API key redaction.
* Returns a formatted string if valid JSON, otherwise null.
*/
function _tryPrettyJson(text) {
try {
var obj = JSON.parse(text);
} catch (e) {
return null;
}
return _redactApiKeys(JSON.stringify(obj, null, 2));
}
/**
* Render tool output text into a DOM element.
* If the text is valid JSON, pretty-prints it with indentation.
* Otherwise renders as plain text. Always redacts API keys.
*/
function renderToolOutput(stripped, isError) {
var out = document.createElement("div");
out.className = "tool-output" + (isError ? " tool-output-error" : "");
if (!isError) {
var pretty = _tryPrettyJson(stripped);
if (pretty) {
out.textContent = pretty;
return out;
}
}
out.textContent = _redactApiKeys(stripped);
return out;
}
function buildMediaEmbed(media, rawJson) {
var wrapper = document.createElement("div");
wrapper.className = "media-embed";
if (media.stream_url) {
var card = buildMediaCard(media);
card.querySelector(".media-card-info").appendChild(buildPlayButton(media));
wrapper.appendChild(card);
} else if (media.results) {
wrapper.appendChild(
buildMediaResultsList(media.results, media.total_count),
);
} else if (media.sessions) {
wrapper.appendChild(buildMediaResultsList(media.sessions, null));
} else if (media.name && media.type && media.id) {
wrapper.appendChild(buildMediaCard(media));
}
// Collapsed raw JSON for inspection (with redacted API keys)
var raw = document.createElement("div");
raw.className = "tool-output";
raw.textContent = _tryPrettyJson(rawJson) || _redactApiKeys(rawJson);
makeCollapsible(raw);
wrapper.appendChild(raw);
return wrapper;
}
function buildMediaCard(item) {
var card = document.createElement("div");
card.className = "media-card";
// Thumbnail
var thumbUrl = item.thumbnail_url || item.image_url || "";
if (thumbUrl) {
var img = document.createElement("img");
img.className = "media-card-thumb";
img.loading = "lazy";
img.alt = item.title || item.name || "Media thumbnail";
img.onerror = function () {
this.style.display = "none";
};
img.src = thumbUrl;
card.appendChild(img);
}
// Info container
var info = document.createElement("div");
info.className = "media-card-info";
// Title (Year)
var title = document.createElement("div");
title.className = "media-card-title";
var titleText = item.title || item.name || "Untitled";
if (item.year || item.production_year) {
titleText += " (" + (item.year || item.production_year) + ")";
}
title.textContent = titleText;
info.appendChild(title);
// Metadata line: type, runtime, genres
var metaParts = [];
if (item.type || item.media_type) {
metaParts.push(item.type || item.media_type);
}
var runtime = _formatRuntime(item);
if (runtime) metaParts.push(runtime);
if (item.genres && item.genres.length) {
metaParts.push(item.genres.join(", "));
}
if (metaParts.length) {
var meta = document.createElement("div");
meta.className = "media-card-meta";
meta.textContent = metaParts.join(" \u00b7 ");
info.appendChild(meta);
}
card.appendChild(info);
return card;
}
function buildPlayButton(media) {
var btn = document.createElement("button");
btn.className = "media-play-btn";
btn.type = "button";
btn.dataset.streamUrl = media.stream_url || "";
btn.dataset.hlsUrl = media.hls_url || "";
btn.dataset.audioOnly =
media.audio_only === true ||
(media.container &&
/^(mp3|flac|ogg|aac|wma|wav|m4a|opus)$/i.test(media.container))
? "true"
: "false";
btn.dataset.directStream =
media.supports_direct_play || media.supports_direct_stream
? "true"
: "false";
btn.setAttribute(
"aria-label",
"Play " + (media.title || media.name || "media"),
);
var icon = document.createElement("span");
icon.textContent = "\u25b6";
btn.appendChild(icon);
var label = document.createElement("span");
label.textContent = "Play";
btn.appendChild(label);
return btn;
}
function buildMediaResultsList(results, totalCount) {
var container = document.createElement("div");
container.className = "media-results-list";
for (var i = 0; i < results.length; i++) {
var item = results[i];
var row = document.createElement("div");
row.className = "media-result-row";
// Small thumbnail
var thumbUrl = item.thumbnail_url || item.image_url || "";
if (thumbUrl) {
var img = document.createElement("img");
img.className = "media-result-thumb";
img.loading = "lazy";
img.alt = item.name || item.title || "Media thumbnail";
img.onerror = function () {
this.style.display = "none";
};
img.src = thumbUrl;
row.appendChild(img);
}
// Title (Year)
var titleSpan = document.createElement("span");
titleSpan.className = "media-result-title";
var titleText = item.name || item.title || "Untitled";
if (item.year || item.production_year) {
titleText += " (" + (item.year || item.production_year) + ")";
}
titleSpan.textContent = titleText;
row.appendChild(titleSpan);
// Metadata: type, runtime or season info
var metaParts = [];
if (item.type || item.media_type) {
metaParts.push(item.type || item.media_type);
}
var runtime = _formatRuntime(item);
if (runtime) metaParts.push(runtime);
if (item.season_name) metaParts.push(item.season_name);
if (
typeof item.index_number === "number" &&
typeof item.parent_index_number === "number"
) {
metaParts.push(
"S" +
String(item.parent_index_number).padStart(2, "0") +
"E" +
String(item.index_number).padStart(2, "0"),
);
}
if (metaParts.length) {
var metaSpan = document.createElement("span");
metaSpan.className = "media-result-meta";
metaSpan.textContent = " \u00b7 " + metaParts.join(" \u00b7 ");
row.appendChild(metaSpan);
}
container.appendChild(row);
}
// "showing X of Y results" footer
if (typeof totalCount === "number" && totalCount > results.length) {
var count = document.createElement("div");
count.className = "media-results-count";
count.textContent =
"showing " + results.length + " of " + totalCount + " results";
container.appendChild(count);
}
return container;
}
// ---------------------------------------------------------------------------
// HLS lazy-loader (follows mermaid.js pattern from renderer.js:724-751)
// ---------------------------------------------------------------------------
var _hlsState = "idle";
var _hlsQueue = [];
function _loadHls(callback) {
if (_hlsState === "ready") {
callback();
return;
}
_hlsQueue.push(callback);
if (_hlsState === "loading") return;
_hlsState = "loading";
var script = document.createElement("script");
script.src = "/shared/hls-1.6.15/hls.min.js";
script.onload = function () {
_hlsState = "ready";
var q = _hlsQueue;
_hlsQueue = [];
for (var i = 0; i < q.length; i++) q[i]();
};
script.onerror = function () {
_hlsState = "idle";
var q = _hlsQueue;
_hlsQueue = [];
// Fall through — _activatePlayer will use stream_url since Hls is undefined
for (var i = 0; i < q.length; i++) q[i]();
};
document.head.appendChild(script);
}
function _isHlsUrl(url) {
return typeof url === "string" && /\.m3u8(\?|$)/i.test(url);
}
// ---------------------------------------------------------------------------
// Click-to-play delegated handler (follows img-placeholder pattern)
// ---------------------------------------------------------------------------
function _activatePlayer(btn) {
var url = btn.dataset.streamUrl;
var hlsUrl = btn.dataset.hlsUrl;
var isAudio = btn.dataset.audioOnly === "true";
var directStream = btn.dataset.directStream === "true";
var player = document.createElement(isAudio ? "audio" : "video");
player.controls = true;
player.autoplay = true;
player.className = "media-player";
// Prefer direct stream when the source supports it; fall back to HLS
// only when transcoding is needed.
if (directStream && url) {
player.src = url;
} else if (
hlsUrl &&
!isAudio &&
typeof Hls !== "undefined" &&
Hls.isSupported()
) {
var hls = new Hls();
hls.loadSource(hlsUrl);
hls.attachMedia(player);
} else if (
hlsUrl &&
!isAudio &&
player.canPlayType("application/vnd.apple.mpegurl")
) {
player.src = hlsUrl;
} else {
player.src = url;
}
player.addEventListener("error", function () {
var card = player.closest(".media-embed");
var titleEl = card ? card.querySelector(".media-card-title") : null;
var label = titleEl ? ": " + titleEl.textContent : "";
var err = document.createElement("div");
err.className = "media-player-error";
err.setAttribute("role", "alert");
err.textContent = "Failed to load stream" + label;
var retry = document.createElement("button");
retry.className = "media-play-btn";
retry.type = "button";
retry.dataset.streamUrl = url;
retry.dataset.hlsUrl = hlsUrl || "";
retry.dataset.audioOnly = String(isAudio);
retry.dataset.directStream = String(directStream);
retry.setAttribute("aria-label", "Retry" + label);
retry.appendChild(document.createTextNode("\u25b6 Retry"));
var container = document.createElement("div");
container.appendChild(err);
container.appendChild(retry);
player.replaceWith(container);
});
btn.replaceWith(player);
}
document.addEventListener("click", function (e) {
var btn = e.target.closest(".media-play-btn");
if (!btn) return;
e.preventDefault();
btn.disabled = true;
var labelEl = btn.querySelector("span:last-child");
if (labelEl) {
labelEl.textContent = "Loading\u2026";
} else {
btn.textContent = "\u25b6 Loading\u2026";
}
var hlsUrl = btn.dataset.hlsUrl;
var isAudio = btn.dataset.audioOnly === "true";
// If HLS URL present and not audio, ensure hls.js is loaded first
if (hlsUrl && !isAudio && _isHlsUrl(hlsUrl)) {
_loadHls(function () {
_activatePlayer(btn);
});
} else {
_activatePlayer(btn);
}
});
document.addEventListener("keydown", function (e) {
if (e.key !== "Enter") return;
var btn = e.target.closest(".media-play-btn");
if (!btn) return;
btn.click();
});
// ===========================================================================
// 13. Plan review dialog
// ===========================================================================
+3 -12
View File
@@ -26,7 +26,6 @@ function inlineMarkdown(text) {
text = text.replace(/~~(.+?)~~/g, "<del>$1</del>");
// Images (must come before links — render as click-to-load placeholder)
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (m, alt, url) {
if (!/^\s*(https?:\/\/|data:image\/)/i.test(url)) return m;
var safeAlt = alt || "Image";
var domain = "";
try {
@@ -54,9 +53,9 @@ function inlineMarkdown(text) {
"</span>"
);
});
// Links (allow http, https, and same-origin relative URLs only)
// Links (block javascript: scheme)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (m, label, url) {
if (!/^\s*(https?:\/\/|\/(?!\/))/i.test(url)) return m;
if (/^\s*javascript:/i.test(url)) return m;
return (
'<a href="' +
url +
@@ -90,16 +89,8 @@ function inlineMarkdown(text) {
document.addEventListener("click", function (e) {
var ph = e.target.closest(".img-placeholder");
if (!ph) return;
var raw = ph.getAttribute("data-src") || "";
if (!/^(https?:\/\/|data:image\/)/i.test(raw)) return;
var src;
try {
src = new URL(raw).href;
} catch (_e) {
return;
}
var img = document.createElement("img");
img.src = src;
img.src = ph.getAttribute("data-src");
img.alt = ph.getAttribute("data-alt");
img.loading = "lazy";
ph.replaceWith(img);
+1 -124
View File
@@ -867,7 +867,7 @@ body { position: static; }
.approval-tool { padding: 8px 12px; border-bottom: 1px solid var(--border); }
.approval-tool:last-of-type { border-bottom: none; }
.approval-tool .tool-name { color: var(--yellow); font-weight: 600; font-size: 11px; margin-bottom: 3px; }
.approval-tool .tool-cmd { color: var(--fg-bright); white-space: pre-wrap; word-break: break-all; max-height: 120px; overflow: hidden; }
.approval-tool .tool-cmd { color: var(--fg-bright); white-space: pre-wrap; word-break: break-all; }
.approval-tool .tool-cmd .dollar { color: var(--green); }
.approval-tool .tool-diff { white-space: pre-wrap; font-size: 12px; margin-top: 4px; }
.approval-tool .tool-diff .diff-del { color: var(--red); }
@@ -961,128 +961,6 @@ body { position: static; }
letter-spacing: 0.03em;
}
/* ==========================================================================
Media embed cards (MCP tool output with stream_url / results)
========================================================================== */
.media-embed {
border-top: 1px solid var(--border);
background: var(--code-bg);
}
.media-card {
display: flex;
gap: 12px;
padding: 10px 12px;
align-items: flex-start;
}
.media-card-thumb {
width: 80px;
height: 120px;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--bg-surface);
flex-shrink: 0;
}
.media-card-info {
flex: 1;
min-width: 0;
}
.media-card-title {
font-family: var(--font-display);
font-size: 14px;
font-weight: 600;
color: var(--fg-bright);
margin-bottom: 2px;
}
.media-card-meta {
font-size: 11px;
color: var(--fg-dim);
margin-bottom: 6px;
}
.media-play-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
font-size: 12px;
font-family: var(--font-mono);
color: var(--accent);
background: transparent;
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.12s ease;
}
.media-play-btn:hover {
background: rgba(229, 160, 66, 0.1);
}
.media-play-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.media-play-btn:active {
background: rgba(229, 160, 66, 0.2);
}
.media-play-btn:disabled {
opacity: 0.5;
cursor: wait;
}
.media-player {
width: 100%;
max-height: 480px;
background: #000;
border-radius: 0;
}
audio.media-player {
max-height: 54px;
}
.media-player-error {
color: var(--red);
font-size: 12px;
padding: 8px 12px;
background: var(--code-bg);
}
.media-results-list {
padding: 6px 12px;
}
.media-result-row {
display: flex;
gap: 8px;
padding: 4px 0;
align-items: center;
font-size: 12px;
border-bottom: 1px solid var(--border);
}
.media-result-row:last-child {
border-bottom: none;
}
.media-result-thumb {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--bg-surface);
flex-shrink: 0;
}
.media-result-title {
color: var(--fg-bright);
font-weight: 500;
}
.media-result-meta {
color: var(--fg-dim);
font-size: 11px;
}
.media-results-count {
text-align: right;
font-size: 10px;
color: var(--fg-dim);
padding: 4px 0;
}
@media (max-width: 480px) {
.media-card { flex-direction: column; }
.media-card-thumb { width: 100%; height: auto; max-height: 200px; }
.media-player { max-height: 280px; }
}
/* ==========================================================================
Plan review dialog
========================================================================== */
@@ -1589,7 +1467,6 @@ audio.media-player {
#health-indicator, #theme-toggle,
#mcp-status, .msg-assistant tbody tr,
.msg-assistant .img-placeholder,
.media-play-btn,
#new-ws-cancel, #new-ws-submit,
#new-ws-box input, #new-ws-box select,
.split-handle, .pane-action-btn,
Generated
+113 -113
View File
@@ -155,7 +155,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.89.0"
version = "0.88.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -167,9 +167,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/af/862e216dd6c5e9bc02fb374eeaaa19017c51b90ddfa5692668a3811947bd/anthropic-0.89.0.tar.gz", hash = "sha256:f3d75b8ccef4b35f3702639519e461eba437d4bcdfabb69378c65a02ab7bda66", size = 596758, upload-time = "2026-04-03T18:57:01.348Z" }
sdist = { url = "https://files.pythonhosted.org/packages/86/68/565f13059c0a6a6fd5f96f306f2a0fb478a0e1174ec18a4df16b5fac9379/anthropic-0.88.0.tar.gz", hash = "sha256:f4c7f6863d08c869913516f08d658fe53caaf8bcc4fbea3218df343d2a876c58", size = 596654, upload-time = "2026-04-01T19:59:05.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/ba/9f973f22abb512d5d17428a76e4ecbc8d49b9dd1b5a1152576d48c24dc1d/anthropic-0.89.0-py3-none-any.whl", hash = "sha256:c6d23854af798f2471ca3bc653cca394d392cc272fe803d3da9d63575b8445f0", size = 478847, upload-time = "2026-04-03T18:56:59.54Z" },
{ url = "https://files.pythonhosted.org/packages/ad/ac/68f646998160c9f2e6f9353a31dd87292ef02b915b455aaf70a52a059a75/anthropic-0.88.0-py3-none-any.whl", hash = "sha256:71898b32332bc75d9739bc10095288d40a29605da6d00da2fe832b1aa036552f", size = 478338, upload-time = "2026-04-01T19:59:03.832Z" },
]
[[package]]
@@ -401,14 +401,14 @@ wheels = [
[[package]]
name = "click"
version = "8.3.2"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
@@ -597,16 +597,16 @@ wheels = [
[[package]]
name = "ddgs"
version = "9.12.1"
version = "9.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "lxml" },
{ name = "primp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a0/2b/4a0124239bf91350d5f04e5fac21a7831e7b7677f61adf56e789fe3d2a42/ddgs-9.12.1.tar.gz", hash = "sha256:8105c5db9025c9d2bcaa085542cd8f9ce6defe20f2c5ca7b8d7ac0061148bc8e", size = 36892, upload-time = "2026-04-03T09:38:47.706Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/0e/8059c8e804cb9f7d24606536c6c3449375a8a04abdb845a33d740eb8f2e4/ddgs-9.12.0.tar.gz", hash = "sha256:29e8285cb0492602d979ea5b0842baa9960e9168f82ccf8c21841a8341128835", size = 36930, upload-time = "2026-03-27T16:16:04.869Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/ee/6984ec65b489bb50d9481a24c122ca9ad78bb4994efab673b237223caf9a/ddgs-9.12.1-py3-none-any.whl", hash = "sha256:1492b2e15e35bcf3a671f2d686f4a86f5e2eca0b26c056ac7b66618432d2e562", size = 45407, upload-time = "2026-04-03T09:38:46.505Z" },
{ url = "https://files.pythonhosted.org/packages/62/8f/c5229d519af06a1405ad83bf4d0429d5d3c29b7c9dc51a96d2afba26bdeb/ddgs-9.12.0-py3-none-any.whl", hash = "sha256:54f24abdff538e8f9b83f99af99455776021419b704b11d796b17825f8baff1a", size = 45452, upload-time = "2026-03-27T16:16:03.677Z" },
]
[[package]]
@@ -1249,7 +1249,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.27.0"
version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1267,9 +1267,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
]
[[package]]
@@ -1584,40 +1584,40 @@ wheels = [
[[package]]
name = "primp"
version = "1.2.2"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/7b/ed8be2c72c6c5f8cf3c41dc9b4b94aeb37efcc8990635724abf067e7f2aa/primp-1.2.2.tar.gz", hash = "sha256:ab6150eebfea8bb9a129eb2c43296fa6acde949bc4a9ac70cf3279ffbfdac88f", size = 166291, upload-time = "2026-04-03T07:11:28.083Z" }
sdist = { url = "https://files.pythonhosted.org/packages/15/d1/e4df56552783475e6e580059ab88733e0934e0c9d7b38438501f15c8e06b/primp-1.2.1.tar.gz", hash = "sha256:77da763a7b5ab435e94f667da480ef3aab868d844badd30d1206b79ac5b460a5", size = 165839, upload-time = "2026-03-30T12:19:03.179Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/6b/be0766093587e88e0cf7a4b3dd04da09a1ce282b8b0c0c78a6c59312bb62/primp-1.2.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7b5b1ae100600351266685bef5f73f906dc4d67a0234ddca3a639df360fae4f4", size = 4378451, upload-time = "2026-04-03T07:11:15.557Z" },
{ url = "https://files.pythonhosted.org/packages/d8/6e/3dea83a569d9b1352c293589e33ec3a86f3892c5947a290e6195ccbc3fc4/primp-1.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0b00c906049255c6bbe87a9846d14f3e76886fcc38c1507cc833aa093fb2e680", size = 4041439, upload-time = "2026-04-03T07:11:35.647Z" },
{ url = "https://files.pythonhosted.org/packages/0b/bb/87511a35fe21b33de82550db84154859d71b93020f04f6839f003fc7f4cb/primp-1.2.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9d778e1b64d17270d3e6d652cf0b8e5c864df9e3caa69665dc99904a57f83d", size = 4319301, upload-time = "2026-04-03T07:11:02.583Z" },
{ url = "https://files.pythonhosted.org/packages/a8/1e/18ec7c262f87a4a409ba003c902ce969a4025322c6546b9a3ea68824d7f5/primp-1.2.2-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8c34fcbe1c10f7201d9004502c5af784255accbe518faea91f6989b6f68b2e5", size = 3914005, upload-time = "2026-04-03T07:11:26.891Z" },
{ url = "https://files.pythonhosted.org/packages/38/be/b24293cc6525ed5179a155add307c3a7c93703bf8778f5a2d2fbf78354b5/primp-1.2.2-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20f71d692ed234364da9c7ad9013bd0049b58410e9e28c4c64f0475c6254c1d3", size = 4163586, upload-time = "2026-04-03T07:11:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/dc/9c/3f3d06c085a6fa9cea89d267a5f83a598f7c60dd2510e5eb9119edfa93ad/primp-1.2.2-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d78518ab13b2d63ecc453e7edf33b533492e76b24eb1bfdf744c0ca5d60d49b", size = 4450010, upload-time = "2026-04-03T07:11:05.615Z" },
{ url = "https://files.pythonhosted.org/packages/18/87/df4159d0c40ca42220c99e3f38d4c4806f20f1520ea7259bae00ab781250/primp-1.2.2-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbda1348329778942a6bee8e12f90e56985bcd414b5ee80c2e2413e1fcbd2ee2", size = 4348650, upload-time = "2026-04-03T07:10:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/e0/07/08c337f7800010393b6a2e11669ac923c8572fa5ce9d3f4164c5c5a7475b/primp-1.2.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa354bf08879662acbc3c141eb365d9bb7a768aee06231ee6af693d37c6000b", size = 4556701, upload-time = "2026-04-03T07:11:22.784Z" },
{ url = "https://files.pythonhosted.org/packages/91/c7/13b1c88499fa3b531c93ef6384580539cbd4d0ff12d1abcd8e2acf30b6e7/primp-1.2.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee1225c9688987fb032c00bf241ca10baf371d5b4b7b812bf18468e2f9408b06", size = 4482096, upload-time = "2026-04-03T07:10:56.117Z" },
{ url = "https://files.pythonhosted.org/packages/02/df/1fc8f76ba2893c838e224739e02a200f15a5a483c167587d70d0054a4c00/primp-1.2.2-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e2d9d19536c6bdf62c08070825d199938f744a9ad85c08233c264f7eac8c7531", size = 4148763, upload-time = "2026-04-03T07:11:06.778Z" },
{ url = "https://files.pythonhosted.org/packages/bd/f9/dd33612503e5447d50504fe1aaed3eeeaf718d2a7e30227baf6a3f3f1b58/primp-1.2.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:4e670106e9d54ec5d73b5a1c4ebbb77e1c9b0fcc29f6661d983863d031db3c66", size = 4294449, upload-time = "2026-04-03T07:11:18.491Z" },
{ url = "https://files.pythonhosted.org/packages/3e/45/3c6d2901becc10d7c68f14c91973efe50da0dc5fe65b7d14f12b18f4f248/primp-1.2.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55d7bfb8555f5a8d8d8fcb3bdc2b08be0372f2f60ad5f7c8fb3b30f8ab7558bc", size = 4804400, upload-time = "2026-04-03T07:11:29.267Z" },
{ url = "https://files.pythonhosted.org/packages/f5/12/5db91660965772af39572829195e653b6ab2472f378346045bddf9d3d630/primp-1.2.2-cp310-abi3-win32.whl", hash = "sha256:387d6511b398678eebc5f083b1ba702da201a8719f3e61795946bf7112e3bcfe", size = 3525833, upload-time = "2026-04-03T07:11:30.517Z" },
{ url = "https://files.pythonhosted.org/packages/47/41/15a26263848143adfe06c2bd73373df9cb4fae3852399be95335000fcff9/primp-1.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:7831385b76618ec4916c3b5d11a8630b406dad042fa3eb043d1f6aca6a0c825e", size = 3900541, upload-time = "2026-04-03T07:11:33.982Z" },
{ url = "https://files.pythonhosted.org/packages/d6/31/beb366bf222771e0c7062efd13d26b7a7f9fb1ba42c2c67d7deccec99772/primp-1.2.2-cp310-abi3-win_arm64.whl", hash = "sha256:5002d61c78ab12a63cbc91ed2e195fbb6d9098a41b736ea680192ca9b2986e59", size = 3887324, upload-time = "2026-04-03T07:11:12.593Z" },
{ url = "https://files.pythonhosted.org/packages/15/ad/428427c1963e40ea206c255bf8e4f1186dd9489936a1f6c313608d8f0920/primp-1.2.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:13956a4ca00f5c6e3192bd3202f26f54711ca6e35533dea2518236a08bc2ecec", size = 4362827, upload-time = "2026-04-03T07:11:11.369Z" },
{ url = "https://files.pythonhosted.org/packages/c0/50/d16a911f19bfc9379229289bd1367b6cc20c7206232c30fb2bf6f04997f3/primp-1.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cfb43e112191ca2abd152f6895c08d6463572683070e0e96134c5f6c64449800", size = 4038456, upload-time = "2026-04-03T07:10:59.087Z" },
{ url = "https://files.pythonhosted.org/packages/8d/cb/7de01f6cde950d8066f78d9b8f60c7014f4f54f00e5d24329b0f7db8652c/primp-1.2.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86b6b61443675e906064d839e279acdb731e2ddd5b0aa5d4cc12d7d7c58cbfdd", size = 4312185, upload-time = "2026-04-03T07:11:09.571Z" },
{ url = "https://files.pythonhosted.org/packages/ea/80/e1e76238935a49aa58f5ac258041f3e673056899c9749f0955b19065af39/primp-1.2.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c087296ee90d95fcb733f6c53f6d31a25faa8f3a7a2b787622804a0d8672b78", size = 3909719, upload-time = "2026-04-03T07:11:00.71Z" },
{ url = "https://files.pythonhosted.org/packages/bc/ac/b60259be9d0348cb55012aeab2b39eb9ce858e822288420eabbaca11b082/primp-1.2.2-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a4509820cc6815d97737abff4bfb5b5543d5d9817d08f57d6b4b85bc9716280", size = 4164545, upload-time = "2026-04-03T07:11:21.188Z" },
{ url = "https://files.pythonhosted.org/packages/61/d1/64f642bd5754c3acf848987f0cf21fc0f429043d288bb5b8519fad47524d/primp-1.2.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:68f757812e317f8ad46d329ae3798cf3867876d7b44359f58f62c5067b3cd841", size = 4438427, upload-time = "2026-04-03T07:11:31.626Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/c44b3e001452a88444ad2a6d64f8f0f25eb399a277b44cd45d9ab6ec5810/primp-1.2.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a32f578b29954c697ceca781a2a8cf0842ac25a87cd95e9fe3f6b4fc916953c3", size = 4334964, upload-time = "2026-04-03T07:11:24.061Z" },
{ url = "https://files.pythonhosted.org/packages/0c/10/6b38c0c94d2b5307b7725597b697217d7676d55fd3079afcec0eddbd75e8/primp-1.2.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f50c14684a49554f6fc292796644c5414d7c1e6e8056ec1337920d18e3c86de4", size = 4550519, upload-time = "2026-04-03T07:11:25.717Z" },
{ url = "https://files.pythonhosted.org/packages/27/3d/04d21c16415a82d89f45989bf7216d456eb79268f8b94baa2209fa1ab4b0/primp-1.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bfb74f5c201fb38453ce92bf6bd0af2ff925977642aff4a90d00b54663b1ddc1", size = 4478749, upload-time = "2026-04-03T07:11:36.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b3/52360d0b6051fb712b0ba7f4e3f1ab15997405e62ddf47306dc5f0a290ac/primp-1.2.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db96c7306680acc1b8b777cdfcdfefa7d9ff973d6f8f26759522bfee98f80b7", size = 4148483, upload-time = "2026-04-03T07:10:54.374Z" },
{ url = "https://files.pythonhosted.org/packages/7e/db/049657a1a731869e3243da4802b58c48142629e0a8cfb6af735f783358d3/primp-1.2.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5e98582300230ca702080f81789e6839c6c552feecd183383eb515a693ce1122", size = 4287608, upload-time = "2026-04-03T07:11:04.233Z" },
{ url = "https://files.pythonhosted.org/packages/4b/76/8ea898e77f499293a194d3419579c721ff05704a5b4ea05cc65afd9c99e5/primp-1.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7bbb449ec4705b91a9846ef898fa5b1b7afe7aabdd99e367e2c76690db4b6a9d", size = 4797147, upload-time = "2026-04-03T07:11:20.032Z" },
{ url = "https://files.pythonhosted.org/packages/e9/c3/3898437570bb043ae2af763c5951eee8b1a7fe8b225216e78bfe306c2ff5/primp-1.2.2-cp314-cp314t-win32.whl", hash = "sha256:75be400f178f4e97acd757e27cc2c71948f080cdc52e5474cb1e1e18b5b832d6", size = 3522857, upload-time = "2026-04-03T07:11:08.284Z" },
{ url = "https://files.pythonhosted.org/packages/b4/ea/64c4510e63cc2213afa4938599d996de51e32ba31b62986045f7d32ebb58/primp-1.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:6dbfa3f2a56508bb24aa6b7d1c55cdc26c9d2dd08ac210c9f2affd7be0cdd8cf", size = 3899017, upload-time = "2026-04-03T07:11:14.406Z" },
{ url = "https://files.pythonhosted.org/packages/78/d1/51cdcb9f4ae6d6a823a8f99cfd1db0247dea0e8e0d6962799da8c95006bc/primp-1.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:188a9ac1435447ebca26557a1d80a87b3cd2c8466bce65e64eb599ee12210c78", size = 3886420, upload-time = "2026-04-03T07:11:16.996Z" },
{ url = "https://files.pythonhosted.org/packages/d9/9b/839567a1ed4235fc8dc9e278d613e9ece2c364063e6a1d725f689d9c0405/primp-1.2.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f1123eb5822eadb3cdadb29b910d45b4626ff4559e0b14ac39dda69fce751756", size = 4355623, upload-time = "2026-03-30T12:19:24.955Z" },
{ url = "https://files.pythonhosted.org/packages/db/27/43922770620219fee11be4cbd33ffa8689b9e37780b2464a72cde540edc9/primp-1.2.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eec51c71754c40fd3590c4fe13c4a6dfcdb776ad84d805901659600d88a25ace", size = 4035804, upload-time = "2026-03-30T12:19:16.353Z" },
{ url = "https://files.pythonhosted.org/packages/b8/59/909becbcbfa27b87ab56fb9b730ba64dfcb90de3b6bce58b748f19d5c8ce/primp-1.2.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d2ace1328a153b5a28af98b636cf58271932da62d30cc7ed4a9296fd46abc3a", size = 4308476, upload-time = "2026-03-30T12:19:41.505Z" },
{ url = "https://files.pythonhosted.org/packages/35/7f/006692e21190c83e553ac5da8fb6b5a4a1150fec6cee2e5f2eaba4fc05db/primp-1.2.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6b1b5569c990d2754d7327972bd0fcca1ad3c5fb422ab275b897ac945e552569", size = 3904546, upload-time = "2026-03-30T12:19:38.626Z" },
{ url = "https://files.pythonhosted.org/packages/44/23/1d6a8eb431eb50a264c07f8a90a6a0c64906f544ab66c17afb426fed67b9/primp-1.2.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:945e38ac8dfb440746f83fae519c6461a105f797cc67803ddce12619c9d7dff6", size = 4154849, upload-time = "2026-03-30T12:19:11.031Z" },
{ url = "https://files.pythonhosted.org/packages/8b/e8/587e850c18c686dceb0083ce8cabd96d16bdddf27d5e00521674492cf1c2/primp-1.2.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ca3784de907a8818c4fd69763e84518bbd78ba6d416bbb4b613d64c1f33fb5f", size = 4443881, upload-time = "2026-03-30T12:19:23.4Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ba/b774d79ed000e04f2000fa7939728a35f28fa04006c0c275299824ebe58c/primp-1.2.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca4b8b5e6717d3ecffaf707a15e10e73fb344f9b35752057e6ea320560d0ab60", size = 4334549, upload-time = "2026-03-30T12:19:29.445Z" },
{ url = "https://files.pythonhosted.org/packages/ab/bb/44254a82f76e9ab75311971c6bbded3adfa5a9b6a48c36a2d07eb9449137/primp-1.2.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00d43a98f0725175a8e9f49515d1c211713e40a95f24bdc5a35a84f7edba3566", size = 4539205, upload-time = "2026-03-30T12:19:06.128Z" },
{ url = "https://files.pythonhosted.org/packages/4b/28/cc3ea87172a451d352068f86e76c04d2a8a64a3c2e5178c9bd15d188209f/primp-1.2.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:44d41ac824f13dbb65bede87ad91e5baa27418e744221e03a187e2baa100eab3", size = 4469006, upload-time = "2026-03-30T12:19:35.55Z" },
{ url = "https://files.pythonhosted.org/packages/d2/8e/1a6ec24e1a5a3ab6f77dcbe7df1297d85d39d9315d2a76197c0f71ad03b9/primp-1.2.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5990f6da9909291d3884ff2333ff1caf765769a07175e3be473461f490d8a61c", size = 4132529, upload-time = "2026-03-30T12:19:20.473Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/78f5eaa8a200ba563472bcef3582d4bd9078b15e7922711a1a073e7f478a/primp-1.2.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:79b34bd865712bbb03d8713c08ba4ab5a4a8c7ad580c6c730ade957c76b6be53", size = 4282622, upload-time = "2026-03-30T12:19:13.58Z" },
{ url = "https://files.pythonhosted.org/packages/32/c6/c28b716ba8acaac41a8cb0cd31f9b77c357438d9a87e899acd4cd3d0557d/primp-1.2.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c07f526d9e31054d08c5766a3f3add5ca70dfadb87a652ed5b46d8eb92ae0de7", size = 4794078, upload-time = "2026-03-30T12:19:01.782Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ea/18dbdf68ffff0d6c0a58b70341cf4c881e556a894074ec971c761ca11881/primp-1.2.1-cp310-abi3-win32.whl", hash = "sha256:e82e6a61f972aab5809f6e680867e522d0b475e20fcc70f67c2b061da9eee746", size = 3512964, upload-time = "2026-03-30T12:19:22.002Z" },
{ url = "https://files.pythonhosted.org/packages/24/51/c55ba2e7051d1f2f3dfe4ce93d0a1b0061fa8d70b1dda664ee9f4b238773/primp-1.2.1-cp310-abi3-win_amd64.whl", hash = "sha256:77eae842a8cefdcbc1ca02d11b8a3c40548b82447f7259c50e34314161ee7464", size = 3887675, upload-time = "2026-03-30T12:19:39.86Z" },
{ url = "https://files.pythonhosted.org/packages/af/c4/02325db4b4db008083170dc880b85247edea651d049dde8ed51d1af5897a/primp-1.2.1-cp310-abi3-win_arm64.whl", hash = "sha256:21debb530039c087ad477e07e945c4000ef6b655732b0dea01007d52feb138ae", size = 3879095, upload-time = "2026-03-30T12:19:27.826Z" },
{ url = "https://files.pythonhosted.org/packages/39/d5/27b896cfd4ea38033e0b65ceed34d64ba41a00f0ef6e5ca58a6350b4a4ef/primp-1.2.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:06837bf3ececc1482fbafb7c8cf306121a86c43ac7e57ec7a4d57add7e8ad126", size = 4344723, upload-time = "2026-03-30T12:19:00.457Z" },
{ url = "https://files.pythonhosted.org/packages/14/44/ef009ac63dc7f4053c4e9a82699fa1d259542a8c334f1a46397cf415499b/primp-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3388ed838b8469592d9ae04ada7bc0d324211a1a16b2f64b5ed08f36e9c54ff6", size = 4028205, upload-time = "2026-03-30T12:19:07.739Z" },
{ url = "https://files.pythonhosted.org/packages/e7/11/7f2380127cd174fdc74face38d017a0d4a3feb711b5cd306e34e113911e4/primp-1.2.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c86f524e6380697f16e0eeed74f0a1fb360c4ec6c43e1d2843f0ba0bdd7e238", size = 4300606, upload-time = "2026-03-30T12:19:37.091Z" },
{ url = "https://files.pythonhosted.org/packages/1e/d4/552fef7bae66cfd922506f532c6f15655b9fbbd9821a43db1b00d4541ee0/primp-1.2.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e272d6f97d5e1696018a6cbfef68ba51c89f4d3f676595ca49ce71544cacd7b6", size = 3899190, upload-time = "2026-03-30T12:19:26.271Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/66304f7e8408aac88731001bbbef9f480ba2d351e764e8aecaecfd9b88a3/primp-1.2.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92e992e8d0689511b8311951b3ed0a468c98fda949567616f30a6f0fac824c8b", size = 4152615, upload-time = "2026-03-30T12:18:58.851Z" },
{ url = "https://files.pythonhosted.org/packages/1c/c5/fecebde683382533e7d217da5fb48a262e7af0d288f833e89a96cb0076c0/primp-1.2.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:faa681c76a306db378ef6543ae84c405b604a1315a8ecedac2b52cb4159c4465", size = 4428506, upload-time = "2026-03-30T12:19:12.291Z" },
{ url = "https://files.pythonhosted.org/packages/5d/51/6a25ca1ff9700ab62f6d4bd7f9c8d6f413e135308344bacb9302c0b78043/primp-1.2.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3fa5d5f89fd35f1a7bac6d003f2d23857a4216e5b52adefd6f661a7e6d333ed", size = 4321356, upload-time = "2026-03-30T12:19:31.193Z" },
{ url = "https://files.pythonhosted.org/packages/6b/14/953a923c6ae93f4f7c7ac9d241ede544e9c3684b637874b539ee812bf380/primp-1.2.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0890ff425d2f95cd624ae6789828c5f3f4fd9b82ef091dfd3441c8489b81c12e", size = 4533517, upload-time = "2026-03-30T12:19:09.436Z" },
{ url = "https://files.pythonhosted.org/packages/79/45/559e6a2166fd287eaef56d0a65efaaeed7a76c1b52c485fe40a9a5bccb2f/primp-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:688b21b73ffcababbad757f2a81d641c01567a59c2e0f3b5cc74b37b872598c2", size = 4464386, upload-time = "2026-03-30T12:19:04.42Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/cb2d02b527c15a284055bf417db680c666c52bac1ac714a32a79d5888e24/primp-1.2.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ad9ac3e347e193982961c0d333f0a9f57f83224104b239421ef85b0bab3409e3", size = 4130750, upload-time = "2026-03-30T12:19:43.099Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f1/9e73a29e63d3440522d841700c29f8e49dfceee6f0b701b0d72f70b22c61/primp-1.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:52c8e8eeccc67302120673cdc32ab7022b3df8ea218d46bda9d81ad90f1d32bb", size = 4287536, upload-time = "2026-03-30T12:19:17.915Z" },
{ url = "https://files.pythonhosted.org/packages/cc/8b/0eec2862d8b3b6471cba851de105fda4457ac07f50f1ba62bc2643312054/primp-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3c03c6111c372ea82ed552c37a437ecb2f7e2a0e26033620303ab4fe4380155a", size = 4782711, upload-time = "2026-03-30T12:19:14.903Z" },
{ url = "https://files.pythonhosted.org/packages/a9/69/6439585738b6b3d79d0d8e83f91abaaf857941f5ef81d1c8e24b8869adfc/primp-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:56e7b1f6d062bf72f3e06a0fc03630a8dcb6349c44397a3513da6668f51d3fc9", size = 3506042, upload-time = "2026-03-30T12:19:32.603Z" },
{ url = "https://files.pythonhosted.org/packages/67/f1/a00d35e01512a10ad1c607b52fcdb4a39cfabe2a8c8c7975a84d2da5e5ea/primp-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3e449fa51e7d06889c30247bba7a73f6b2e3958179d9f37e4ffef78fda714a3c", size = 3883151, upload-time = "2026-03-30T12:19:19.225Z" },
{ url = "https://files.pythonhosted.org/packages/ef/7f/1c4bd7a311da73832086cff75274bf2620ed0ceb38e92fbcf606cc0567a4/primp-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a20ca7542e912c6cb83dc86f42a34cc4e2618e3a24878101433888259e566433", size = 3875623, upload-time = "2026-03-30T12:19:33.952Z" },
]
[[package]]
@@ -2216,27 +2216,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.9"
version = "0.15.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" }
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" },
{ url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" },
{ url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" },
{ url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" },
{ url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" },
{ url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" },
{ url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" },
{ url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" },
{ url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" },
{ url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" },
{ url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" },
{ url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" },
{ url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
]
[[package]]
@@ -2330,55 +2330,55 @@ wheels = [
[[package]]
name = "sqlalchemy"
version = "2.0.49"
version = "2.0.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" },
{ url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" },
{ url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" },
{ url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" },
{ url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" },
{ url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" },
{ url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" },
{ url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" },
{ url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" },
{ url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" },
{ url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" },
{ url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" },
{ url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" },
{ url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" },
{ url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" },
{ url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" },
{ url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" },
{ url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" },
{ url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" },
{ url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" },
{ url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" },
{ url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" },
{ url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" },
{ url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" },
{ url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" },
{ url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
{ url = "https://files.pythonhosted.org/packages/d7/6d/b8b78b5b80f3c3ab3f7fa90faa195ec3401f6d884b60221260fd4d51864c/sqlalchemy-2.0.48-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc", size = 2157184, upload-time = "2026-03-02T15:38:28.161Z" },
{ url = "https://files.pythonhosted.org/packages/21/4b/4f3d4a43743ab58b95b9ddf5580a265b593d017693df9e08bd55780af5bb/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c", size = 3313555, upload-time = "2026-03-02T15:58:57.21Z" },
{ url = "https://files.pythonhosted.org/packages/21/dd/3b7c53f1dbbf736fd27041aee68f8ac52226b610f914085b1652c2323442/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7", size = 3313057, upload-time = "2026-03-02T15:52:29.366Z" },
{ url = "https://files.pythonhosted.org/packages/d9/cc/3e600a90ae64047f33313d7d32e5ad025417f09d2ded487e8284b5e21a15/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d", size = 3265431, upload-time = "2026-03-02T15:58:59.096Z" },
{ url = "https://files.pythonhosted.org/packages/8b/19/780138dacfe3f5024f4cf96e4005e91edf6653d53d3673be4844578faf1d/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571", size = 3287646, upload-time = "2026-03-02T15:52:31.569Z" },
{ url = "https://files.pythonhosted.org/packages/40/fd/f32ced124f01a23151f4777e4c705f3a470adc7bd241d9f36a7c941a33bf/sqlalchemy-2.0.48-cp311-cp311-win32.whl", hash = "sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617", size = 2116956, upload-time = "2026-03-02T15:46:54.535Z" },
{ url = "https://files.pythonhosted.org/packages/58/d5/dd767277f6feef12d05651538f280277e661698f617fa4d086cce6055416/sqlalchemy-2.0.48-cp311-cp311-win_amd64.whl", hash = "sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c", size = 2141627, upload-time = "2026-03-02T15:46:55.849Z" },
{ url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" },
{ url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" },
{ url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" },
{ url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" },
{ url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" },
{ url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" },
{ url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" },
{ url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" },
{ url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" },
{ url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" },
{ url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" },
{ url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" },
{ url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" },
{ url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" },
{ url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" },
{ url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" },
{ url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" },
{ url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" },
{ url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" },
{ url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" },
{ url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" },
{ url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" },
]
[[package]]
@@ -2496,7 +2496,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "1.1.0"
version = "1.0.2"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
@@ -2620,24 +2620,24 @@ wheels = [
[[package]]
name = "tzdata"
version = "2026.1"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]
name = "uvicorn"
version = "0.43.0"
version = "0.42.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/f2/368268300fb8af33743508d738ef7bb4d56afdb46c6d9c0fa3dd515df171/uvicorn-0.43.0.tar.gz", hash = "sha256:ab1652d2fb23abf124f36ccc399828558880def222c3cb3d98d24021520dc6e8", size = 85686, upload-time = "2026-04-03T18:37:48.984Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl", hash = "sha256:46fac64f487fd968cd999e5e49efbbe64bd231b5bd8b4a0b482a23ebce499620", size = 68591, upload-time = "2026-04-03T18:37:47.64Z" },
{ url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" },
]
[[package]]