mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06c41f0a59 | |||
| d107e6edf0 | |||
| 22c20a8dbd | |||
| 179143431d | |||
| d4a6866045 | |||
| c4abd62226 | |||
| b3926c372a | |||
| 5efe52d433 |
@@ -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
|
||||
},
|
||||
|
||||
@@ -7,9 +7,6 @@ on:
|
||||
pull_request:
|
||||
branches: [main, "stable/*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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 s–1 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7623df33be9baf7647ca1c2450640df57e1cd73e8be1f8168aae16e546ad683c
|
||||
size 459941
|
||||
oid sha256:a6b7769aa7e732ffbeb1eb7f5b65273a135fb3a78d9802ec36d3b92801c34f6b
|
||||
size 427745
|
||||
|
||||
+1
-3
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+10
-10
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -----------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
// ===========================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user